Author SHA1 Message Date
alve ff392fd066 🏃Add gitea actions demo
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 45s
2025-07-03 20:53:04 +02:00
ponas 4a36cc140c Sort imports 2025-06-05 19:53:56 +02:00
ponas f1ea46188f Make the graphql endpoint CSRF exempt 2025-06-05 19:38:17 +02:00
ponas dd6a559ac6 Add mise.toml 2025-06-05 19:37:59 +02:00
nikolark 5d889ee32c chore(ci): Fix deploy
Ensure uv is on path
2025-05-31 23:13:54 +02:00
nikolark 2b1cdb28ea fix: Fix union syntax 2025-05-30 16:17:45 +02:00
nikolark fbd55bd3a2 fix: strawberry type things 2025-05-30 16:15:18 +02:00
nikolark 71f158da6e chore: Fix most ruff issues
ignore the rest using `uvx ruff check --add-noqa`
2025-05-30 15:43:57 +02:00
nikolark 8b364b0887 chore: Add pre-commit ruff config++ 2025-05-30 15:43:20 +02:00
nikolark 28d747d74d chore(frontend): Roll node to v20 2025-05-30 15:32:55 +02:00
nikolark 6f964a0c57 chore: Roll to python 3.9 to get uv in image 2025-05-30 15:29:41 +02:00
nikolark d256e0eb7e chore: Switch to uv 2025-05-30 15:26:51 +02:00
leander cac189003a increase max saldo to 1000 NOK 2025-01-16 18:25:33 +01:00
alve ff09985944 🥷 Hide users navigation tab 2024-02-15 19:29:13 +00:00
Emma Eilefsen Glenna d76c911d4b fix rendering of register total 2024-01-25 21:45:17 +01:00
Emma Eilefsen Glenna e60473e7ed fix rendering of user balance 2024-01-25 21:44:03 +01:00
Emma Eilefsen Glenna d596c31353 extract function 2024-01-25 21:43:32 +01:00
Emma Eilefsen Glenna 90558a3a28 add comment 2024-01-25 19:01:45 +00:00
alve 57a6c412d5 💄 Hide trailing decimal zeros 2024-01-25 19:01:45 +00:00
alve 19f58211e7 🗃 Make order sum a decimal field 2024-01-25 19:01:45 +00:00
nikolark 0726754678 💡 add some FIXMEs 2024-01-25 19:01:45 +00:00
nikolark 480fba870a 🗃 Replace float fields with int or decimals 2024-01-25 19:01:45 +00:00
alve ed08c60380 💄Fix favicon path 2023-07-19 19:46:01 +02:00
nikolark 79fe5cac0f 🔒 Audit 2022-04-17 21:29:02 +02:00
nikolark 95d7d13f38 ✨ Handle user not found 2022-04-17 21:13:22 +02:00
nikolark ce2cdfd409 ⚡ Code split routes 2022-04-17 16:59:00 +02:00
nikolark b56d103ae7 Merge branch 'feature/synthwave' into 'main'
✨ BBS 2222 synthwave theme

See merge request edb/bbs!3
2022-04-17 14:39:44 +00:00
nikolark 56efe6760e 💄 Fix deposit 2022-04-17 16:37:42 +02:00
nikolark ce31ef84cc 💄 Pretty deposit user select 2022-04-17 16:29:18 +02:00
nikolark 491604c720 🚚 Move ProductCard and UserCard
closer to their feature
2022-04-17 16:04:54 +02:00
nikolark 05d3dcbb3e 💄 Style product grid, rm images
♻ Move Wireframe to own component
2022-04-17 16:01:09 +02:00
nikolark 8577fb9f11 ⬆ Bump minor frontend deps
♻ Stop using React.FC
2022-04-17 15:00:10 +02:00
nikolark a038340e4a 📝 TODO 2022-03-27 21:42:27 +02:00
nikolark 34a01e9293 💄 move grid behind everything else 2022-03-27 21:41:30 +02:00
nikolark 54dd21f1eb 💄 Make you nauseous 2022-03-22 23:59:31 +01:00
nikolark 53ba5a05a1 ✨ Add credits
🐛 Fix navigating below page zero
2022-03-06 21:16:53 +01:00
nikolark f775446224 💄 Style Navlink, primary buttons and headings 2022-03-06 15:16:16 +01:00
nikolark 13cc0d92a2 💄 Put new brand in header and fix toasts 2022-03-06 14:27:52 +01:00
nikolark 7bfa587a77 💄 Avatar rhombus 2022-03-06 13:34:05 +01:00
nikolark 3d91148b7e 💄 First round of styling
✨ Added new splash
2022-03-06 13:13:08 +01:00
nikolark 54bbd75730 🔥 Remove all things bootstrap 2022-03-05 20:00:03 +01:00
nikolark 06f43b06b8 ⚡ Avoid redirect 2022-02-20 12:36:15 +01:00
nikolark ad116c2c28 💚 Update pytest config with new app name 2022-02-20 01:33:16 +01:00
nikolark eb960bdb84 🔧 Update scripts with new path 2022-02-20 01:32:36 +01:00
nikolark f4c99153ca 🔧 Point VITE_API_URL to the right place 2022-02-20 01:31:03 +01:00
nikolark 3e457de416 🐛 Fix VITE_API_URL reference 2022-02-20 01:25:55 +01:00
nikolark faca98b7f9 🔧 Reference static assests from / 2022-02-20 01:20:41 +01:00
nikolark 53ef262ba4 🔧 Build frontend on deploy 2022-02-20 01:16:13 +01:00
nikolark f4749e6a6e 🔧 output frontend build to root static folder 2022-02-20 01:12:37 +01:00
nikolark e5de16f227 🐛 Remove 3.9 type syntax 2022-02-20 00:57:12 +01:00
nikolark eace79f012 ⬇ Downgrade to python 3.8 2022-02-20 00:54:00 +01:00
nikolark 9b903f42d6 🚚 Rename app main to bbs
🚚 Move settings into main
➕ Add django-rename-app
2022-02-20 00:39:16 +01:00
nikolark e1eaf3df98 💚 Try fixing deploy script 2022-02-20 00:17:51 +01:00
nikolark b79d9fc9c8 👷 Add ssh config 2022-02-20 00:08:15 +01:00
nikolark 42f23b1d77 Merge branch 'chore/bbs-bumps' into 'master'
💥 Rewrite in react

See merge request edb/bbs!1
2022-02-19 22:39:07 +00:00
108 changed files with 2725 additions and 2502 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000
+19
View File
@@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
+4 -1
View File
@@ -11,4 +11,7 @@ CACHE/
media/ media/
tmp/ tmp/
frontend/dist frontend/dist
static/modern static/assets
static/index.html
.python-version
.env
+16 -8
View File
@@ -2,33 +2,41 @@ variables:
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip" PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
default: default:
image: nikolaik/python-nodejs:python3.9-nodejs16 image: nikolaik/python-nodejs:python3.9-nodejs20
cache: cache:
paths: paths:
- .cache/pip - .cache/pip
- .venv - .venv
before_script:
- poetry config virtualenvs.in-project true
- poetry install
django-checks: django-checks:
stage: build stage: build
script: script:
- poetry run python manage.py migrate - uv run manage.py migrate
- poetry run python manage.py check - uv run manage.py check
tests: tests:
stage: test stage: test
script: script:
- poetry run bin/test - uv run bin/test
lint: lint:
stage: test stage: test
script: script:
- poetry run bin/lint - uv run bin/pre-commit
deploy: deploy:
stage: deploy stage: deploy
before_script:
# Install ssh if not already
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
# Make SSH key available
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
# Add known host keys
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script: script:
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)" - ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
only: only:
+10 -16
View File
@@ -1,18 +1,12 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos: repos:
- repo: local - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.11.7'
hooks: hooks:
- id: black - id: ruff
name: black args: [--fix, --exit-non-zero-on-fix]
entry: black - id: ruff-format
args: [--check] - repo: https://github.com/adamchainz/django-upgrade
language: system rev: "1.24.0"
require_serial: true hooks:
types: [python] - id: django-upgrade
- id: isort args: [--target-version, "4.2"]
name: isort
entry: isort
args: [--check]
language: system
types: [python]
+6 -9
View File
@@ -4,11 +4,10 @@ Before starting, make sure you have a working python and nodejs setup with pyenv
```bash ```bash
apt-get install libmysqlclient-dev apt-get install libmysqlclient-dev
poetry install
poetry shell
pre-commit install pre-commit install
python manage.py migrate uv run manage.py migrate
bin/run uv run bin/run
cp .env-default .env
# in another terminal # in another terminal
cd frontend cd frontend
@@ -18,19 +17,17 @@ npm start
## References ## References
- We use JS code generation to work with queries/mutations in our graphql API: https://github.com/dotansimha/graphql-typed-document-node - We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
## TODO ## TODO
- update emails - improve product listing (light purple background)
- Stats: products over time - Stats: products over time
- Light on dark colors
- inventory views - inventory views
- Stats: Implement users tab
## TODO-old ## TODO-old
- Larger pictures - Larger pictures
- Compact GUI for max 768 vertical pixels - Compact GUI for max 768 vertical pixels
- Search right of in title - Search right of in title
File renamed without changes.
+9 -2
View File
@@ -1,13 +1,20 @@
from django.contrib import admin from django.contrib import admin
from main.models import * from bbs.models import (
InventoryTransaction,
Order,
OrderLine,
Product,
Transaction,
UserProfile,
)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin):
pass pass
admin.site.register(Product, ProductAdmin)
admin.site.register(InventoryTransaction) admin.site.register(InventoryTransaction)
admin.site.register(Order) admin.site.register(Order)
admin.site.register(OrderLine) admin.site.register(OrderLine)
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,3 +1,5 @@
from typing import Annotated, Union
import strawberry import strawberry
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@@ -5,10 +7,10 @@ from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from strawberry import ID from strawberry import ID
from main import models from bbs import models
from main.api.errors import Error, FieldError, FormErrors from bbs.api.errors import Error, FieldError, FormErrors
from main.api.types.inventory import Transaction from bbs.api.types.inventory import Transaction
from main.api.types.shop import Order from bbs.api.types.shop import Order
UserModel = get_user_model() UserModel = get_user_model()
@@ -25,16 +27,18 @@ class CreateOrderSuccess:
@strawberry.type @strawberry.type
class InsufficientFunds(Error): class InsufficientFunds(Error): # noqa: N818
amount_lacking: float amount_lacking: float
CreateOrderResponse = strawberry.union("CreateOrderResponse", [CreateOrderSuccess, InsufficientFunds, FormErrors]) CreateOrderResponse = Annotated[
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
]
@strawberry.mutation @strawberry.mutation
@transaction.atomic @transaction.atomic
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001
# clean and validate # clean and validate
try: try:
user = UserModel.objects.get(pk=customer_id) user = UserModel.objects.get(pk=customer_id)
@@ -66,7 +70,8 @@ def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external
if not is_external and user.profile.balance <= order_sum: if not is_external and user.profile.balance <= order_sum:
return InsufficientFunds( return InsufficientFunds(
message="Du har ikke nok penger på bok.", amount_lacking=abs(order_sum - user.profile.balance) message="Du har ikke nok penger på bok.",
amount_lacking=abs(order_sum - user.profile.balance),
) )
# create # create
@@ -89,12 +94,14 @@ class CreateDepositSuccess:
@strawberry.type @strawberry.type
class AboveMaxSaldo(Error): class AboveMaxSaldo(Error): # noqa: N818
max_saldo: float max_saldo: float
above: float above: float
CreateDepositResponse = strawberry.union("CreateDepositResponse", [CreateDepositSuccess, AboveMaxSaldo, FormErrors]) CreateDepositResponse = Annotated[
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
]
@strawberry.mutation @strawberry.mutation
@@ -116,7 +123,7 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
fields=[FieldError(field="amount", message=message)], fields=[FieldError(field="amount", message=message)],
) )
# 1337 is special <3 # 1337 is special <3
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: # noqa: PLR2004
return AboveMaxSaldo( return AboveMaxSaldo(
message="Du prøver å sette inn for mye penger", message="Du prøver å sette inn for mye penger",
max_saldo=settings.BBS_SALDO_MAX, max_saldo=settings.BBS_SALDO_MAX,
@@ -1,13 +1,11 @@
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model from django.contrib.auth.models import User as DjangoUser
from django.db import transaction from django.db import transaction
from strawberry_django import auto from strawberry import auto
from main import models from bbs import models
from main.api.types.users import User from bbs.api.types.users import User
DjangoUser = get_user_model()
@strawberry_django.input(DjangoUser) @strawberry_django.input(DjangoUser)
@@ -19,9 +17,12 @@ class UserInput:
@strawberry.mutation @strawberry.mutation
@transaction.atomic @transaction.atomic
def register_mutation(input: UserInput) -> User: def register_mutation(input: UserInput) -> User: # noqa: A002
user = DjangoUser.objects.create_user( user = DjangoUser.objects.create_user(
first_name=input.first_name, last_name=input.last_name, email=input.email, username=input.email first_name=input.first_name,
last_name=input.last_name,
email=input.email,
username=input.email,
) )
models.UserProfile.objects.create(user=user) models.UserProfile.objects.create(user=user)
return user return user
File renamed without changes.
@@ -1,9 +1,9 @@
import strawberry import strawberry
import strawberry_django import strawberry_django
from main.api.types.inventory import Transaction, TransactionOrdering from bbs.api.types.inventory import Transaction, TransactionOrdering
@strawberry.type @strawberry.type
class InventoryQueries: class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, order=TransactionOrdering) transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
@@ -1,15 +1,23 @@
import strawberry import strawberry
import strawberry_django import strawberry_django
from main import models from bbs import models
from main.api.types.shop import Order, OrderOrdering, OrderStats, OrderStatsByTime, Point, Product, ProductOrderStat from bbs.api.types.shop import (
Order,
OrderOrdering,
OrderStats,
OrderStatsByTime,
Point,
Product,
ProductOrderStat,
)
@strawberry.type @strawberry.type
class ShopQueries: class ShopQueries:
all_products: list[Product] = strawberry_django.field() all_products: list[Product] = strawberry_django.field()
all_orders: list[Order] = strawberry_django.field() all_orders: list[Order] = strawberry_django.field()
order_list: list[Order] = strawberry_django.field(pagination=True, order=OrderOrdering) order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering)
@strawberry.field @strawberry.field
def order_stats(self) -> OrderStats: def order_stats(self) -> OrderStats:
@@ -26,6 +34,9 @@ class ShopQueries:
@strawberry.field @strawberry.field
def product_stats(self) -> list[ProductOrderStat]: def product_stats(self) -> list[ProductOrderStat]:
return [ return [
ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]]) ProductOrderStat(
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in models.OrderLine.objects.product_order_stats().as_series() for stat in models.OrderLine.objects.product_order_stats().as_series()
] ]
@@ -3,7 +3,7 @@ import strawberry_django
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
from strawberry import ID from strawberry import ID
from main.api.types.users import User from bbs.api.types.users import User
@strawberry.type @strawberry.type
+34
View File
@@ -0,0 +1,34 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension
from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations
from bbs.api.queries.inventory import InventoryQueries
from bbs.api.queries.shop import ShopQueries
from bbs.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
class HandleNotFound(SchemaExtension):
def on_request_end(self):
result = self.execution_context.result
if result.errors:
for error in result.errors:
if isinstance(error.original_error, ObjectDoesNotExist):
error.extensions = {
"code": "NOT_FOUND",
"model": type(error.original_error).__qualname__.replace(".DoesNotExist", ""),
}
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=[HandleNotFound])
File renamed without changes.
+23
View File
@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import auto
from bbs import models
if TYPE_CHECKING:
from .users import User
@strawberry_django.order_type(models.Transaction)
class TransactionOrdering:
created: auto
@strawberry_django.type(models.Transaction)
class Transaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
amount: auto
created: auto
@@ -1,12 +1,17 @@
from typing import TYPE_CHECKING, Annotated
import strawberry import strawberry
import strawberry_django import strawberry_django
from strawberry_django import auto from strawberry import auto
from main import models from bbs import models
from main.api.types.utils import JSONScalar from bbs.api.types.utils import JSONScalar
if TYPE_CHECKING:
from .users import User
@strawberry_django.filters.filter(models.Product) @strawberry_django.filter_type(models.Product)
class ProductFilter: class ProductFilter:
id: auto id: auto
active: auto active: auto
@@ -23,7 +28,7 @@ class Product:
user_counts: JSONScalar user_counts: JSONScalar
@strawberry_django.ordering.order(models.Order) @strawberry_django.order_type(models.Order)
class OrderOrdering: class OrderOrdering:
created: auto created: auto
@@ -31,7 +36,7 @@ class OrderOrdering:
@strawberry_django.type(models.Order) @strawberry_django.type(models.Order)
class Order: class Order:
id: auto id: auto
customer: strawberry.LazyType["User", "main.api.types.users"] customer: Annotated["User", strawberry.lazy(".users")]
order_sum: auto order_sum: auto
created: auto created: auto
orderlines: list["OrderLine"] orderlines: list["OrderLine"]
@@ -1,14 +1,13 @@
from datetime import datetime from datetime import datetime
from decimal import Decimal
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model from django.contrib.auth.models import User as DjangoUser
from strawberry_django import auto from strawberry import auto
from main import models from bbs import models
from main.api.types.shop import Point, ProductOrderStat from bbs.api.types.shop import Point, ProductOrderStat
UserModel = get_user_model()
@strawberry.type @strawberry.type
@@ -24,7 +23,7 @@ class UserProfile:
balance: auto balance: auto
image: auto image: auto
last_purchase_date: datetime last_purchase_date: datetime
order_sum_total: float order_sum_total: Decimal
@strawberry.field() @strawberry.field()
def top_months(self) -> list[TopMonth]: def top_months(self) -> list[TopMonth]:
@@ -33,12 +32,15 @@ class UserProfile:
@strawberry.field() @strawberry.field()
def product_order_stats(self) -> list[ProductOrderStat]: def product_order_stats(self) -> list[ProductOrderStat]:
return [ return [
ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]]) ProductOrderStat(
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in self.product_order_stats().as_series() for stat in self.product_order_stats().as_series()
] ]
@strawberry_django.type(UserModel) @strawberry_django.type(DjangoUser)
class User: class User:
id: auto id: auto
first_name: auto first_name: auto
File renamed without changes.
+1 -1
View File
@@ -2,7 +2,7 @@ from django.apps import AppConfig
class BBSAppConfig(AppConfig): class BBSAppConfig(AppConfig):
name = "main" name = "bbs"
verbose_name = "Biceps Bar System" verbose_name = "Biceps Bar System"
def ready(self): def ready(self):
+11 -3
View File
@@ -1,14 +1,22 @@
from django import forms from django import forms
from main.models import InventoryTransaction, Product from bbs.models import InventoryTransaction, Product
MAX_DIGITS = 9
DECIMAL_PLACES = 1
class InventoryTransactionForm(forms.ModelForm): class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.FloatField(required=True, localize=True) unit_price = forms.DecimalField(
max_digits=MAX_DIGITS,
decimal_places=DECIMAL_PLACES,
required=True,
localize=True,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Only show active products # Only show active products
super(InventoryTransactionForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(active=True) self.fields["product"].queryset = Product.objects.filter(active=True)
class Meta: class Meta:
File renamed without changes.
File renamed without changes.
@@ -1,10 +1,10 @@
from pathlib import Path from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand, CommandParser from django.core.management import BaseCommand, CommandParser
from strawberry.printer import print_schema from strawberry.printer import print_schema
from bbssite.settings import BASE_DIR from bbs.api.schema import schema as schema_symbol
from main.api.schema import schema as schema_symbol
class Command(BaseCommand): class Command(BaseCommand):
@@ -13,11 +13,11 @@ class Command(BaseCommand):
def add_arguments(self, parser: CommandParser): def add_arguments(self, parser: CommandParser):
parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout") parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout")
def handle(self, *args, filename=None, **options): def handle(self, *args, filename=None, **options): # noqa: ARG002
schema_output = print_schema(schema_symbol) schema_output = print_schema(schema_symbol)
if filename != "-": if filename != "-":
with Path(BASE_DIR, filename).open("w+") as fp: with Path(settings.BASE_DIR, filename).open("w+") as fp:
fp.write(schema_output) fp.write(schema_output)
self.stdout.write(self.style.SUCCESS("OK")) self.stdout.write(self.style.SUCCESS("OK"))
else: else:
print(schema_output) pass
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand, CommandParser from django.core.management.base import BaseCommand, CommandParser
from main.models import UserProfile from bbs.models import UserProfile
class Command(BaseCommand): class Command(BaseCommand):
@@ -10,11 +10,10 @@ class Command(BaseCommand):
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true") parser.add_argument("--force", action="store_true")
def handle(self, *args, dry_run=False, force=False, **options): def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002
for profile in UserProfile.objects.all().order_by("id"): for profile in UserProfile.objects.all().order_by("id"):
if dry_run: if dry_run:
print(profile.user.email, profile.image_url()) pass
else: elif force or not profile.image:
if force or not profile.image:
profile.image = profile.image_url() profile.image = profile.image_url()
profile.save() profile.save()
@@ -3,7 +3,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -72,7 +71,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="orderlines", related_name="orderlines",
to="main.Order", to="bbs.Order",
), ),
), ),
], ],
@@ -157,7 +156,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="orderline", model_name="orderline",
name="product", name="product",
field=models.ForeignKey(on_delete=models.CASCADE, related_name="orderlines", to="main.Product"), field=models.ForeignKey(on_delete=models.CASCADE, related_name="orderlines", to="bbs.Product"),
), ),
migrations.AddField( migrations.AddField(
model_name="inventorytransaction", model_name="inventorytransaction",
@@ -166,7 +165,7 @@ class Migration(migrations.Migration):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="transactions", related_name="transactions",
verbose_name="Produkt", verbose_name="Produkt",
to="main.Product", to="bbs.Product",
), ),
), ),
migrations.AddField( migrations.AddField(
@@ -180,6 +179,6 @@ class Migration(migrations.Migration):
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name="orderline", name="orderline",
unique_together=set([("order", "product")]), unique_together={("order", "product")},
), ),
] ]
@@ -0,0 +1,62 @@
# Generated by Django 4.0.2 on 2022-03-05 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="inventorytransaction",
name="amount",
field=models.IntegerField(verbose_name="Antall"),
),
migrations.AlterField(
model_name="inventorytransaction",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="orderline",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="product",
name="alcohol_percent",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="product",
name="inventory_amount",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="product",
name="sale_price_ext",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="sale_price_int",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="volume_liter",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="userprofile",
name="balance",
field=models.DecimalField(decimal_places=1, default=0, max_digits=9),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2023-06-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0002_alter_inventorytransaction_amount_and_more"),
]
operations = [
migrations.AlterField(
model_name="order",
name="order_sum",
field=models.DecimalField(db_column="sum", decimal_places=1, max_digits=9),
),
]
File renamed without changes.
+43 -32
View File
@@ -1,7 +1,7 @@
import hashlib import hashlib # noqa: EXE002
from datetime import datetime from datetime import datetime
from decimal import Decimal
from itertools import groupby from itertools import groupby
from typing import Type
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@@ -17,6 +17,8 @@ from django.db.models.functions import (
) )
EXTERNAL_USER = "Ekstern" EXTERNAL_USER = "Ekstern"
MAX_DIGITS = 9
DECIMAL_PLACES = 1
def _format_counts(per_user): def _format_counts(per_user):
@@ -36,14 +38,15 @@ def _format_counts(per_user):
class Product(models.Model): class Product(models.Model):
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
sale_price_int = models.FloatField() sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
sale_price_ext = models.FloatField() sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
description = models.CharField(max_length=256, blank=True) description = models.CharField(max_length=256, blank=True)
volume_liter = models.FloatField(null=True) volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
alcohol_percent = models.FloatField(null=True) alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
image = models.ImageField(upload_to="products", blank=True) image = models.ImageField(upload_to="products", blank=True)
customer_support = models.CharField(max_length=32, blank=True) customer_support = models.CharField(max_length=32, blank=True)
inventory_amount = models.FloatField(default=0) # FIXME: rename to inventory_quantity
inventory_amount = models.IntegerField(default=0)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@@ -83,21 +86,26 @@ class Product(models.Model):
return _format_counts(per_user) return _format_counts(per_user)
def __str__(self): def __str__(self): # noqa: DJ012
return self.name return self.name
class Meta: class Meta: # noqa: DJ012
ordering = ["name"] ordering = ["name"]
class OrderManager(models.Manager): class OrderManager(models.Manager):
def count_by_created(self, extract_class: Type[Extract]): def count_by_created(self, extract_class: type[Extract]):
period = extract_class("created") period = extract_class("created")
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id")) return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
def count_by_created_period(self, period: str): def count_by_created_period(self, period: str):
periods = {"yearly": ExtractYear, "monthly": ExtractMonth, "daily": ExtractIsoWeekDay, "hourly": ExtractHour} periods = {
assert period in periods "yearly": ExtractYear,
"monthly": ExtractMonth,
"daily": ExtractIsoWeekDay,
"hourly": ExtractHour,
}
assert period in periods # noqa: S101
return self.count_by_created(periods[period]) return self.count_by_created(periods[period])
@@ -109,7 +117,7 @@ class Order(models.Model):
blank=True, blank=True,
related_name="orders", related_name="orders",
) )
order_sum = models.FloatField(db_column="sum") order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
objects = OrderManager() objects = OrderManager()
@@ -120,7 +128,7 @@ class Order(models.Model):
def reciept(self): def reciept(self):
return ", ".join([str(ol) for ol in self.orderlines.all()]) return ", ".join([str(ol) for ol in self.orderlines.all()])
def __str__(self): def __str__(self): # noqa: DJ012
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr" return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
@@ -146,10 +154,12 @@ class OrderLineManager(models.QuerySet):
class OrderLine(models.Model): class OrderLine(models.Model):
order = models.ForeignKey("main.Order", on_delete=models.CASCADE, related_name="orderlines") order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines")
product = models.ForeignKey("main.Product", on_delete=models.CASCADE, related_name="orderlines") product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines")
# FIXME: rename to quantity
amount = models.IntegerField() amount = models.IntegerField()
unit_price = models.FloatField(verbose_name="Enhetspris") # FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
objects = OrderLineManager.as_manager() objects = OrderLineManager.as_manager()
@@ -157,10 +167,10 @@ class OrderLine(models.Model):
def price(self): def price(self):
return self.amount * self.unit_price return self.amount * self.unit_price
def __str__(self): def __str__(self): # noqa: DJ012
return f"{self.amount} {self.product} ({self.unit_price} kr per)" return f"{self.amount} {self.product} ({self.unit_price} kr per)"
class Meta: class Meta: # noqa: DJ012
unique_together = ("order", "product") unique_together = ("order", "product")
@@ -168,11 +178,11 @@ class Transaction(models.Model):
"""Money""" """Money"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
amount = models.FloatField() amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.id}: { self.user}: {self.amount}" return f"{self.id}: {self.user}: {self.amount}"
class InventoryTransaction(models.Model): class InventoryTransaction(models.Model):
@@ -184,13 +194,15 @@ class InventoryTransaction(models.Model):
related_name="inventory_transactions", related_name="inventory_transactions",
) )
product = models.ForeignKey( product = models.ForeignKey(
"main.Product", "bbs.Product",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="transactions", related_name="transactions",
verbose_name="Produkt", verbose_name="Produkt",
) )
amount = models.FloatField(verbose_name="Antall") # FIXME: rename to quantity
unit_price = models.FloatField(verbose_name="Enhetspris") amount = models.IntegerField(verbose_name="Antall")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
comment = models.TextField(verbose_name="Kommentar", blank=True) comment = models.TextField(verbose_name="Kommentar", blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@@ -198,13 +210,13 @@ class InventoryTransaction(models.Model):
def price(self): def price(self):
return self.amount * self.unit_price return self.amount * self.unit_price
def __str__(self): def __str__(self): # noqa: DJ012
return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)" return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)"
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile") user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
balance = models.FloatField(default=0.0) balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0)
image = models.URLField(blank=True) image = models.URLField(blank=True)
def last_purchase(self): def last_purchase(self):
@@ -212,8 +224,7 @@ class UserProfile(models.Model):
def last_purchase_date(self) -> datetime: def last_purchase_date(self) -> datetime:
last = self.last_purchase() last = self.last_purchase()
last_p = last.created if last else datetime.min return last.created if last else datetime.min # noqa: DTZ901
return last_p
def top_months(self, limit=5): def top_months(self, limit=5):
return ( return (
@@ -227,21 +238,21 @@ class UserProfile(models.Model):
def product_order_stats(self): def product_order_stats(self):
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats() return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
def order_sum_total(self) -> float: def order_sum_total(self) -> Decimal:
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0 return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
def image_url(self) -> str: def image_url(self) -> str:
if self.user.email: if self.user.email:
md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest() md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest() # noqa: S324
return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon" return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
return "" return ""
def save(self, **kwargs): def save(self, **kwargs): # noqa: DJ012
if not self.image: if not self.image:
self.image = self.image_url() self.image = self.image_url()
super().save(**kwargs) super().save(**kwargs)
def __str__(self): def __str__(self): # noqa: DJ012
return f"{self.user}" return f"{self.user}"
+16 -16
View File
@@ -1,12 +1,13 @@
# Django settings for bbs project. # Django settings for bbs project. # noqa: EXE002
import contextlib
import os import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
DEBUG = True DEBUG = True
ADMINS = ( ADMINS = (
# ('Your Name', 'your_email@example.com'), # ('Your Name', 'your_email@example.com'), # noqa: ERA001
) )
MANAGERS = ADMINS MANAGERS = ADMINS
@@ -20,18 +21,18 @@ DATABASES = {
TIME_ZONE = "Europe/Oslo" TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "nb" LANGUAGE_CODE = "nb"
USE_I18N = True USE_I18N = True
USE_L10N = True USE_TZ = True
SITE_ID = 1 SITE_ID = 1
# Media and uploads # Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
# Static files # Static files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
@@ -39,13 +40,13 @@ STATICFILES_FINDERS = (
) )
# Make this unique, and don't share it with anybody. # Make this unique, and don't share it with anybody.
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" # noqa: S105
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [ "DIRS": [
os.path.join(BASE_DIR, "templates"), os.path.join(BASE_DIR, "templates"), # noqa: PTH118
], ],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
@@ -72,7 +73,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
] ]
ROOT_URLCONF = "bbssite.urls" ROOT_URLCONF = "bbs.urls"
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
@@ -85,9 +86,10 @@ INSTALLED_APPS = [
"django_extensions", "django_extensions",
"easy_thumbnails", "easy_thumbnails",
"corsheaders", "corsheaders",
"django_rename_app",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
"main", "bbs",
] ]
INSTALLED_APPS += LOCAL_APPS INSTALLED_APPS += LOCAL_APPS
@@ -96,7 +98,7 @@ LOGIN_REDIRECT_URL = "/"
SESSION_COOKIE_AGE = 1209600 # 15 minutes SESSION_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True
BBS_SALDO_MAX = 600 BBS_SALDO_MAX = 1000
BBS_LIMIT_DEPOSITS = False BBS_LIMIT_DEPOSITS = False
# Thumbnails # Thumbnails
@@ -140,7 +142,5 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", "http://localhost:3000",
] ]
try: with contextlib.suppress(ImportError):
from .local_settings import * from .local_settings import * # noqa: F403
except ImportError:
pass
@@ -1,23 +1,27 @@
import pytest import pytest # noqa: INP001
from django.test import Client from django.test import Client
from strawberry.django.test import GraphQLTestClient from strawberry.django.test import GraphQLTestClient
from main.models import Order, Product from bbs.models import Order, Product
@pytest.fixture(name="gql_client") @pytest.fixture(name="gql_client")
def gql_client_fixture(client: Client): def gql_client_fixture(client: Client):
yield GraphQLTestClient(client) return GraphQLTestClient(client)
@pytest.fixture(name="product") @pytest.fixture(name="product")
def product_fix(db): def product_fix(db): # noqa: ARG001
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30) return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
def test_create_order(product, gql_client): def test_create_order(product, gql_client):
assert Order.objects.count() == 0 assert Order.objects.count() == 0
variables = dict(customerId="external", isExternal=True, orderLines=[{"productId": product.id, "amount": 1}]) variables = {
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """ query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) { mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) { createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
+30
View File
@@ -0,0 +1,30 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from strawberry.django.views import GraphQLView
from bbs.api.schema import schema
from bbs.views.reports import inventory, inventory_add, report
admin.autodiscover()
urlpatterns = [
# TODO: migrate reports to SPA
path("inventory/", inventory, name="inventory"),
path("inventory/add/", inventory_add, name="inventory-add"),
path("report", report, name="report"),
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path(
"accounts/logout/",
LogoutView.as_view(),
name="django.contrib.auth.views.logout",
),
path("admin/", admin.site.urls),
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
]
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
View File
File renamed without changes.
File renamed without changes.
+15 -16
View File
@@ -4,18 +4,18 @@ from itertools import groupby
from django.contrib import messages from django.contrib import messages
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from main.forms import DateRangeForm, InventoryTransactionForm from bbs.forms import DateRangeForm, InventoryTransactionForm
from main.models import InventoryTransaction, Order, Product, Transaction, UserProfile from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
def report(request): def report(request):
# FIXME: split this up in smaller pieces # FIXME: split this up in smaller pieces
datetime_now = datetime.now() datetime_now = datetime.now() # noqa: DTZ005
start_time = datetime.now() - timedelta(days=30) start_time = datetime.now() - timedelta(days=30) # noqa: DTZ005
# start of day, 1 month # start of day, 1 month
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0) start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
# end of day # end of day
end_time = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999) end_time = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999) # noqa: DTZ005
if request.GET.get("start_time", False) or request.GET.get("end_time", False): if request.GET.get("start_time", False) or request.GET.get("end_time", False):
form = DateRangeForm(request.GET) form = DateRangeForm(request.GET)
@@ -50,14 +50,14 @@ def report(request):
k: { k: {
"name": k, "name": k,
"transactions": group, "transactions": group,
"transactions_sum": sum(map(lambda x: x["transactions__price"], group)), "transactions_sum": sum(x["transactions__price"] for x in group),
"transactions_units": sum(map(lambda x: x["transactions__amount"], group)), "transactions_units": sum(x["transactions__amount"] for x in group),
} }
} }
) )
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time]) itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
inv_in = sum(map(lambda x: x[1].get("transactions_sum", 0), inventory_products.items())) inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items())
# out # out
out_products = ( out_products = (
@@ -75,8 +75,8 @@ def report(request):
product = { product = {
"name": k, "name": k,
"orderlines": group, "orderlines": group,
"orderlines_sum": sum(map(lambda x: x["orderlines__price"], group)), "orderlines_sum": sum(x["orderlines__price"] for x in group),
"orderlines_units": sum(map(lambda x: x["orderlines__amount"], group)), "orderlines_units": sum(x["orderlines__amount"] for x in group),
} }
if k in inventory_products: if k in inventory_products:
# add diff # add diff
@@ -88,7 +88,7 @@ def report(request):
p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0) p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0)
p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0) p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0)
inv_out = sum(map(lambda x: x[1].get("orderlines_sum", 0), inventory_products.items())) inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items())
inv_diff = inv_in - inv_out inv_diff = inv_in - inv_out
@@ -110,7 +110,7 @@ def report(request):
purchases_diff = purchases_end - purchases_start purchases_diff = purchases_end - purchases_start
saldo_diff = saldo_end - saldo_start saldo_diff = saldo_end - saldo_start
return render(request, "report.html", locals()) return render(request, "report.html", locals()) # noqa: DJ003
def inventory(request): def inventory(request):
@@ -120,7 +120,7 @@ def inventory(request):
wholesale_value = sum([p.wholesale_value for p in products_active]) wholesale_value = sum([p.wholesale_value for p in products_active])
num_products = sum(products_active.values_list("inventory_amount", flat=True)) num_products = sum(products_active.values_list("inventory_amount", flat=True))
return render(request, "inventory.html", locals()) return render(request, "inventory.html", locals()) # noqa: DJ003
def inventory_add(request): def inventory_add(request):
@@ -133,13 +133,12 @@ def inventory_add(request):
itrans.user = request.user itrans.user = request.user
itrans.product.save() itrans.product.save()
itrans.save() itrans.save()
messages.success(request, "Satt inn {0} stk {1}.".format(itrans.amount, itrans.product)) messages.success(request, f"Satt inn {itrans.amount} stk {itrans.product}.")
return redirect("inventory-add") return redirect("inventory-add")
else:
messages.error(request, "Feil med skjemaet, se under.") messages.error(request, "Feil med skjemaet, se under.")
form = InventoryTransactionForm(data=request.POST) form = InventoryTransactionForm(data=request.POST)
else: else:
form = InventoryTransactionForm() form = InventoryTransactionForm()
return render(request, "inventory_add.html", locals()) return render(request, "inventory_add.html", locals()) # noqa: DJ003
+1 -1
View File
@@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbssite.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbs.settings")
application = get_wsgi_application() application = get_wsgi_application()
-22
View File
@@ -1,22 +0,0 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import include, path, re_path
from strawberry.django.views import GraphQLView
from main import urls as main_urls
from main.api.schema import schema
admin.autodiscover()
urlpatterns = [
re_path(r"", include(main_urls)),
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path("accounts/logout/", LogoutView.as_view(), name="django.contrib.auth.views.logout"),
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
]
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -eu
cd frontend
npm i
npm run build
+16 -6
View File
@@ -1,14 +1,24 @@
#!/bin/bash #!/bin/bash
set -eu set -eu
PROJECT_PATH=/var/www/neuf.no/bbs source "$HOME/.local/bin/env"
PROCESS_NAME="$PROJECT.neuf.no"
export VITE_API_URL="https://bbs.neuf.no/api"
PROJECT_PATH=/var/www/neuf.no/bbs
PROCESS_NAME="bbs.neuf.no"
echo "Deploying bbs to ${PROJECT_PATH}"
cd "$PROJECT_PATH" || exit 1 cd "$PROJECT_PATH" || exit 1
git pull git pull
poetry install
umask 022; poetry run python manage.py collectstatic --noinput # Collect static
poetry run python manage.py migrate # Run DB migrations
# Reload # Backend
uv sync
umask 022; uv run manage.py collectstatic --noinput # Collect static
uv run manage.py migrate # Run DB migrations
# Frontend
bin/build-frontend
# Reload backend
echo "Reloading supervisor process ${PROCESS_NAME}"
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
SRC_DIR="main bbssite"
black $SRC_DIR
isort $SRC_DIR
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
SRC_DIR="main bbssite"
black --check $SRC_DIR
isort --diff $SRC_DIR
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
uvx pre-commit run --all-files
+3
View File
@@ -1 +1,4 @@
#!/bin/bash
cd frontend && npm run start cd frontend && npm run start
+1 -1
View File
@@ -1 +1 @@
v16 v20
+4 -1
View File
@@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBS</title> <title>BBS</title>
</head> </head>
+950 -986
View File
File diff suppressed because it is too large. Load diff
+22 -26
View File
@@ -16,54 +16,50 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.5.8", "@apollo/client": "^3.5.10",
"@nivo/bar": "^0.79.1", "@nivo/bar": "^0.79.1",
"@nivo/line": "^0.79.1", "@nivo/line": "^0.79.1",
"@nivo/pie": "^0.79.1", "@nivo/pie": "^0.79.1",
"bootstrap": "^5.1.3",
"bootstrap-icons": "^1.8.1",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"date-fns": "^2.28.0", "date-fns": "^2.28.0",
"graphql": "^16.3.0", "graphql": "^16.3.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hook-form": "^7.27.1", "react-hook-form": "^7.30.0",
"react-router-dom": "^6.2.1", "react-router-dom": "^6.3.0",
"use-url-search-params": "^2.5.1" "use-url-search-params": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^2.6.1", "@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/typed-document-node": "^2.2.3", "@graphql-codegen/typed-document-node": "^2.2.8",
"@graphql-codegen/typescript-operations": "^2.3.0", "@graphql-codegen/typescript-operations": "^2.3.5",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.1.1",
"@types/bootstrap": "^5.1.9", "@types/react": "^17.0.44",
"@types/react": "^17.0.39", "@types/react-dom": "^17.0.15",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.11.0", "@typescript-eslint/eslint-plugin": "^5.19.0",
"@typescript-eslint/parser": "^5.11.0", "@typescript-eslint/parser": "^5.19.0",
"@vitejs/plugin-react": "^1.2.0", "@vitejs/plugin-react": "^1.3.1",
"@vitejs/plugin-react-refresh": "^1.3.6", "@vitejs/plugin-react-refresh": "^1.3.6",
"eslint": "^8.9.0", "eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.4.0",
"jest": "^27.5.1", "jest": "^27.5.1",
"prettier": "^2.5.1", "prettier": "^2.6.2",
"stylelint": "^14.5.0", "stylelint": "^14.7.0",
"stylelint-config-idiomatic-order": "^8.1.0", "stylelint-config-idiomatic-order": "^8.1.0",
"stylelint-config-prettier": "^9.0.3", "stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^7.0.0", "stylelint-config-recommended": "^7.0.0",
"stylelint-order": "^5.0.0", "stylelint-order": "^5.0.0",
"stylelint-prettier": "^2.0.0", "stylelint-prettier": "^2.0.0",
"typescript": "^4.5.5", "typescript": "^4.6.3",
"vite": "^2.8.1", "vite": "^2.9.5",
"vite-plugin-svgr": "^1.0.1" "vite-plugin-svgr": "^1.1.0"
}, },
"engines": { "engines": {
"node": ">=16", "node": "20"
"npm": ">=7"
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
.app { .app {
margin: 16px; margin: 1rem auto;
max-width: 1220px;
} }
+5 -2
View File
@@ -1,23 +1,26 @@
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import React from "react"; import React from "react";
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import "bootstrap/dist/css/bootstrap.min.css";
import styles from "./App.module.css"; import styles from "./App.module.css";
import { Routes } from "./Routes"; import { Routes } from "./Routes";
import client from "./client"; import client from "./client";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header"; import { Header } from "./components/Header";
import { Toast } from "./components/Toast"; import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider"; import { ToastProvider } from "./components/ToastProvider";
import { Wireframe } from "./components/Wireframe";
const App: React.FC = () => ( const App = (): JSX.Element => (
<ApolloProvider client={client}> <ApolloProvider client={client}>
<Router> <Router>
<ToastProvider> <ToastProvider>
<Header /> <Header />
<div className={styles.app}> <div className={styles.app}>
<Routes /> <Routes />
<Wireframe />
</div> </div>
<Footer />
<Toast /> <Toast />
</ToastProvider> </ToastProvider>
</Router> </Router>
+14 -10
View File
@@ -1,17 +1,19 @@
import React from "react"; import React from "react";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom"; import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
import { UserCreate } from "./features/auth/UserCreate"; const Splash = React.lazy(() => import("./components/Splash"));
import { Deposit } from "./features/deposit/Deposit"; const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
import { Log } from "./features/log/Log"; const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
import { Register } from "./features/register/Register"; const Log = React.lazy(() => import("./features/log/Log"));
import { OrderStats } from "./features/stats/OrderStats"; const Register = React.lazy(() => import("./features/register/Register"));
import { ProductStats } from "./features/stats/ProductStats"; const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
import { Stats } from "./features/stats/Stats"; const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
import { UserDetail } from "./features/users/UserDetail"; const Stats = React.lazy(() => import("./features/stats/Stats"));
import { UserList } from "./features/users/UserList"; const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes: React.FC = () => ( export const Routes = (): JSX.Element => (
<React.Suspense fallback={<div>Loading...</div>}>
<ReactRouterRoutes> <ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} /> <Route path="/deposit" element={<Deposit />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
@@ -22,5 +24,7 @@ export const Routes: React.FC = () => (
<Route path="/users/create" element={<UserCreate />} /> <Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} /> <Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} /> <Route path="/" element={<Register />} />
<Route path="*" element={<Splash brandText="404" noOverlay />} />
</ReactRouterRoutes> </ReactRouterRoutes>
</React.Suspense>
); );
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApolloClient, InMemoryCache } from "@apollo/client"; import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({ const client = new ApolloClient({
uri: import.meta.env.VITE_API_URL || "http://localhost:8000/graphql/", uri: `${import.meta.env.VITE_API_URL}/graphql/`,
cache: new InMemoryCache(), cache: new InMemoryCache(),
}); });
+22
View File
@@ -0,0 +1,22 @@
.footer {
position: absolute;
right: 0;
bottom: 1rem;
left: 0;
height: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
text-align: center;
}
.heart {
color: var(--brand);
}
.brick {
position: relative;
top: 7px;
display: inline-block;
width: 20px;
height: 20px;
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import { ReactComponent as Brick } from "../assets/brick.svg";
import styles from "./Footer.module.css";
export const Footer = (): JSX.Element => {
return (
<footer className={styles.footer}>
Made with <span className={styles.heart}>❤</span> by{" "}
<span title="EDB-gjengen">
<Brick className={styles.brick} />
</span>
</footer>
);
};
@@ -0,0 +1,9 @@
.errorMessage {
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
color: var(--brand);
text-align: center;
width: 100%;
margin-top: 30vh;
}
+23
View File
@@ -0,0 +1,23 @@
import { ApolloError } from "@apollo/client";
import React from "react";
import styles from "./GQLError.module.css";
const formatModel = (text: string) => {
const withSpaces = text
.replace(/([A-Z])/g, " $1")
.toLocaleLowerCase()
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
};
export const GQLError = ({ error }: { error: ApolloError }) => {
if (error?.graphQLErrors?.length == 1) {
const single = error?.graphQLErrors[0];
if (single?.extensions?.code === "NOT_FOUND") {
const modelPretty = formatModel(single?.extensions?.model as string);
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
}
}
return <div className={styles.errorMessage}>Error...</div>;
};
+90 -9
View File
@@ -1,13 +1,94 @@
.logo { .header {
position: relative; margin: 1rem auto;
top: -1px; max-width: 1220px;
display: inline-block;
width: 24px;
height: 24px;
margin: 0 8px;
} }
.navIcon { .nav {
display: flex;
height: 5rem;
align-items: center;
text-transform: uppercase;
}
.navLink:after {
position: absolute;
right: 12px;
left: 14px;
display: block;
height: 1px;
background-color: rgba(243, 33, 61, 0.4);
box-shadow: 0 0 4px rgba(243, 33, 61, 0.4), 0 0 11px rgba(243, 33, 61, 0.4), 0 0 19px rgba(243, 33, 61, 0.4);
content: "";
transform: rotate(-2.5deg);
}
.navLink:hover:after {
background-color: var(--brand);
box-shadow: 0 0 4px var(--brand), 0 0 11px var(--brand), 0 0 19px var(--brand), 0 0 30px var(--brand);
}
.logoNav {
margin-left: 8px;
}
.navItems {
display: flex;
align-items: center;
padding: 0;
margin: 0;
list-style: none;
}
.navLink {
position: relative; position: relative;
top: -2px; padding: 0.4rem 0.8rem;
border: 1px solid transparent;
transition: color 0.1s ease-in-out;
}
.navLink:hover {
color: white;
}
.active.active {
color: white;
}
/* FIXME: Duplicates Splash styles */
.brand {
padding-left: 1rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
left: -8px;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: 10px;
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
} }
+17 -52
View File
@@ -1,85 +1,50 @@
import { ReactComponent as BarChart } from "bootstrap-icons/icons/bar-chart.svg"; import clsx from "clsx";
import { ReactComponent as CardList } from "bootstrap-icons/icons/card-list.svg";
import { ReactComponent as House } from "bootstrap-icons/icons/house.svg";
import { ReactComponent as PeopleIcon } from "bootstrap-icons/icons/people.svg";
import React from "react"; import React from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { ReactComponent as Logo } from "../assets/brick.svg";
import styles from "./Header.module.css"; import styles from "./Header.module.css";
const ADMIN_URL = import.meta.env.VITE_API_URL const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
? `${import.meta.env.VITE_API_URL}/admin`
: "http://localhost:8000/admin/";
const navLinkStyles = ({ isActive }: { isActive: boolean }) => (isActive ? "nav-link active" : "nav-link"); const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const Header: React.FC = () => { export const Header = (): JSX.Element => {
return ( return (
<header> <header className={styles.header}>
<nav className="navbar navbar-dark bg-dark navbar-expand-md"> <nav className={styles.nav}>
<NavLink to="/" className="navbar-brand"> <NavLink to="/" className={styles.logoNav}>
<span className={styles.logo}> <div className={styles.brand}>BBS</div>
<Logo /> <div className={styles.subtitle}>Biceps Bar System</div>
</span>
Biceps Bar System - BBS
</NavLink> </NavLink>
<button <ul className={styles.navItems}>
className="navbar-toggler" <li>
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div className="collapse navbar-collapse" id="navbarNav">
<ul className="navbar-nav">
<li className="nav-item">
<NavLink to="/" className={navLinkStyles} end> <NavLink to="/" className={navLinkStyles} end>
<span className={styles.navIcon}>
<House />
</span>{" "}
Kjøp drikke Kjøp drikke
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/deposit" className={navLinkStyles}> <NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger Sett inn penger
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/log" className={navLinkStyles}> <NavLink to="/log" className={navLinkStyles}>
<span className={styles.navIcon}>
<CardList />
</span>{" "}
Logg Logg
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/stats" className={navLinkStyles}> <NavLink to="/stats" className={navLinkStyles}>
<span className={styles.navIcon}>
<BarChart />
</span>{" "}
Statistikk Statistikk
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/users" className={navLinkStyles}> <NavLink to="/users" className={navLinkStyles}>
<span className={styles.navIcon}>
<PeopleIcon />
</span>{" "}
Folk Folk
</NavLink> </NavLink>
</li> </li>
</ul> <li>
</div> <a href={ADMIN_URL} className={styles.navLink}>
<ul className="navbar-nav">
<li className="nav-item">
<a href={ADMIN_URL} className="nav-link">
Admin Admin
</a> </a>
</li> </li>
@@ -1,57 +0,0 @@
.product {
display: flex;
overflow: hidden;
flex-direction: column;
align-items: center;
justify-content: center;
border: 4px solid transparent;
background: transparent;
border-radius: 8px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
}
.product:hover {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(0, 0, 0, 0.1);
}
.active {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(25, 135, 84, 0.2);
}
.active:hover {
background: rgba(25, 135, 84, 0.2);
}
.productText {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 4px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
color: white;
font-weight: bold;
}
.productImage {
width: 140px;
height: 140px;
object-fit: contain;
}
.noProductImage {
width: 140px;
height: 140px;
}
.amount {
position: absolute;
top: 0px;
left: 0px;
padding: 4px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
color: white;
font-size: 2rem;
font-weight: bold;
line-height: 1;
}
.wrap {
position: relative;
}
-26
View File
@@ -1,26 +0,0 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { Product } from "../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
onProduct: MouseEventHandler;
active: boolean;
amount: number;
};
export const ProductCard: React.FC<ProductCardProps> = ({ product, onProduct, amount, active = false }) => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct}>
<div className={styles.wrap}>
<span className={styles.amount}>{amount}</span>
{product.imageUrl && <img src={product.imageUrl} className={styles.productImage} alt={product.name} />}
{!product.imageUrl && <div className={styles.noProductImage} />}
<span className={styles.productText}>{product.name}</span>
</div>
</button>
);
};
+68
View File
@@ -0,0 +1,68 @@
.splash {
top: 0;
right: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
text-align: center;
}
.overlay {
position: fixed;
}
.wrap {
position: relative;
}
.sun {
width: clamp(100px, 65vw, 600px);
height: clamp(100px, 65vw, 600px);
margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
border-radius: 50% 50%;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun), 0 0 30px var(--sun);
}
.brand {
position: relative;
left: -0.6rem;
padding-top: 3rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: clamp(20px, 20vw, 200px);
font-style: italic;
letter-spacing: 1rem;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
bottom: 0.5rem;
left: 0rem;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: clamp(20px, 5vw, 50px);
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+20
View File
@@ -0,0 +1,20 @@
import clsx from "clsx";
import React from "react";
import styles from "./Splash.module.css";
interface SplashProps {
brandText?: string;
noOverlay?: boolean;
}
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
<div className={clsx(styles.splash, { [styles.overlay]: !noOverlay })}>
<div className={styles.wrap}>
<div className={styles.sun}></div>
<div className={styles.brand}>{brandText}</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</div>
</div>
);
export default Splash;
+23
View File
@@ -2,4 +2,27 @@
position: absolute; position: absolute;
bottom: 32px; bottom: 32px;
left: 32px; left: 32px;
padding: 0.6rem 1.2rem;
border-radius: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease-in-out;
}
.visible {
opacity: 1;
pointer-events: initial;
}
.primary {
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
}
.danger {
background: var(--brand);
box-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.close {
display: none;
} }
+7 -30
View File
@@ -1,41 +1,18 @@
import { Toast as BSToast } from "bootstrap";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect, useRef } from "react"; import React from "react";
import styles from "./Toast.module.css"; import styles from "./Toast.module.css";
import { useToast } from "./ToastProvider"; import { useToast } from "./ToastProvider";
export const Toast: React.FC = () => { export const Toast = (): JSX.Element => {
const ref = useRef<HTMLInputElement>(null); const { showToast, message, background, visible } = useToast();
const { visible, showToast, message, background } = useToast();
const backgroundColor = `bg-${background}`; const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
const classes = clsx("toast align-items-center text-white bg-primary border-0", backgroundColor, styles.toast);
useEffect(() => {
if (!ref.current) return;
const bsToast = BSToast.getInstance(ref.current) || new BSToast(ref.current);
if (visible) {
bsToast?.show();
} else if (!visible) {
bsToast?.hide();
}
return () => bsToast?.hide();
}, [visible]);
return ( return (
<div ref={ref} className={classes} role="alert" aria-live="assertive" aria-atomic="true"> <div className={classes} role="alert" aria-live="assertive" aria-atomic="true">
<div className="d-flex"> <div>{message}</div>
<div className="toast-body">{message}</div> <button type="button" aria-label="Close" onClick={() => showToast(message)} className={styles.close} />
<button
type="button"
className="btn-close btn-close-white me-2 m-auto"
data-bs-dismiss="toast"
aria-label="Close"
onClick={() => showToast(message)}
/>
</div>
</div> </div>
); );
}; };
+10 -8
View File
@@ -1,21 +1,24 @@
import React, { ReactNode, useContext, useState } from "react"; import React, { ReactNode, useContext, useState } from "react";
type Background = "primary" | "danger";
interface ToastProps { interface ToastProps {
visible: boolean; visible: boolean;
message: string; message: string;
showToast: (message: string, background?: string) => void; showToast: (message: string, background?: Background) => void;
hide: () => void; hide: () => void;
background: string; background: Background;
} }
const defaultState = { const defaultState = {
// FIXME: revert
visible: false, visible: false,
message: "", message: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: () => {}, showToast: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
hide: () => {}, hide: () => {},
background: "primary", background: "primary" as Background,
}; };
const ToastContext = React.createContext<ToastProps>(defaultState); const ToastContext = React.createContext<ToastProps>(defaultState);
@@ -23,19 +26,18 @@ const ToastContext = React.createContext<ToastProps>(defaultState);
type ToastProviderProps = { type ToastProviderProps = {
children: ReactNode; children: ReactNode;
}; };
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => { export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
const [visible, setVisible] = useState(defaultState.visible); const [visible, setVisible] = useState(defaultState.visible);
const [message, setMessage] = useState(""); const [message, setMessage] = useState(defaultState.message);
const [background, setBackground] = useState(""); const [background, setBackground] = useState(defaultState.background);
const showToast = (newMessage: string, background = "primary") => { const showToast = (newMessage: string, background: Background = "primary") => {
setMessage(newMessage); setMessage(newMessage);
setBackground(background); setBackground(background);
if (!visible) { if (!visible) {
setVisible(true); setVisible(true);
setTimeout(() => { setTimeout(() => {
setVisible(false); setVisible(false);
setMessage("");
}, 3000); }, 3000);
} }
}; };
@@ -1,34 +0,0 @@
.user {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px;
border: 3px solid transparent;
background: transparent;
border-radius: 8px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
}
.user:hover {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(0, 0, 0, 0.1);
}
.active {
border-color: rgba(25, 135, 84, 0.6);
background: rgba(25, 135, 84, 0.2);
}
.active:hover {
background: rgba(25, 135, 84, 0.2);
}
.avatar {
width: 50px;
height: 50px;
border-radius: 50px;
}
.avatar svg {
width: 50px;
height: 50px;
color: #ccc;
}
.name {
font-weight: bold;
}
-31
View File
@@ -1,31 +0,0 @@
import { ReactComponent as PersonFill } from "bootstrap-icons/icons/person-fill.svg";
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../types";
import styles from "./UserCard.module.css";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
onUser: MouseEventHandler;
active: boolean;
};
/** If user is null, then it's the special external user */
export const UserCard: React.FC<UserCardProps> = ({ user, onUser, active = false }) => {
const classes = clsx(styles.user, { [styles.active]: active });
return (
<button className={classes} onClick={onUser} type="button">
{user?.profile?.image && (
<img src={user.profile?.image} alt={`Profile for ${user.firstName}`} className={styles.avatar} />
)}
{!user?.profile?.image && (
<span className={styles.avatar}>
<PersonFill />
</span>
)}
<span className={styles.name}>{user?.firstName || "Ekstern"}</span>
<span className={styles.balance}>{user?.profile?.balance || ""}</span>
</button>
);
};
@@ -0,0 +1,38 @@
.wireframe {
position: absolute;
bottom: calc(22% - 512px);
left: calc(50% - 512px);
width: 1024px;
height: 1024px;
display: grid;
grid-template-columns: repeat(8, 128px);
animation-iteration-count: infinite;
animation-name: isometric-rotate;
animation-duration: 10s;
animation-timing-function: linear;
pointer-events: none;
z-index: -1;
}
.cell {
border-bottom: 2px solid var(--sun);
border-right: 2px solid var(--sun);
}
.cell:nth-child(8n) {
border-right: none;
}
.cell:nth-child(n+57) {
border-bottom: none;
}
@keyframes isometric-rotate {
0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
}
100% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-405deg);
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import styles from "./Wireframe.module.css";
export const Wireframe = () => (
<div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} />
))}
</div>
);
@@ -0,0 +1,5 @@
.form {
display: grid;
max-width: 500px;
gap: 1rem;
}
+8 -6
View File
@@ -3,6 +3,7 @@ import React from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { CreateUserDocument } from "../../types"; import { CreateUserDocument } from "../../types";
import styles from "./UserCreate.module.css";
type FormValues = { type FormValues = {
firstName: string; firstName: string;
@@ -10,7 +11,7 @@ type FormValues = {
email: string; email: string;
}; };
export const UserCreate = () => { const UserCreate = () => {
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -30,20 +31,21 @@ export const UserCreate = () => {
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <div>
<h2>Opprett ny</h2>
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<label>Fornavn</label> <label>Fornavn</label>
<input {...register("firstName", { required: true })} /> <input {...register("firstName", { required: true })} />
{errors.firstName && <span>This field is required</span>} {errors.firstName && <span>This field is required</span>}
<br />
<label>Etternavn</label> <label>Etternavn</label>
<input {...register("lastName", { required: true })} /> <input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>} {errors.lastName && <span>This field is required</span>}
<br />
<label>E-post</label> <label>E-post</label>
<input type="email" {...register("email", { required: true })} /> <input type="email" {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>} {errors.email && <span>This field is required</span>}
<br /> <input type="submit" disabled={creating} className="btn-primary" />
<input type="submit" disabled={creating} />
</form> </form>
</div>
); );
}; };
export default UserCreate;
@@ -1,13 +1,17 @@
.userList { .userList {
display: grid; display: grid;
height: 400px; height: 800px;
grid-template-columns: repeat(12, 100px); padding-top: 2rem;
grid-template-columns: repeat(5, 220px);
overflow-y: auto; overflow-y: auto;
row-gap: 16px; column-gap: 16px;
row-gap: 24px;
} }
.amountSection { .amountSection {
margin-top: 32px; margin-top: 32px;
} }
.checkout { .checkout {
margin-top: 32px; margin-top: 32px;
} }
+6 -5
View File
@@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { UserCard } from "../../components/UserCard"; import { UserCard } from "../users/UserCard";
import styles from "./Deposit.module.css"; import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit"; import { useDeposit } from "./useDeposit";
export const Deposit: React.FC = () => { const Deposit = (): JSX.Element => {
const { const {
loading, loading,
selectedUser, selectedUser,
@@ -33,7 +33,7 @@ export const Deposit: React.FC = () => {
placeholder="Filtrér på navn" placeholder="Filtrér på navn"
/> />
{userQuery !== "" && ( {userQuery !== "" && (
<button className="btn btn-link" type="button" onClick={() => setUserQuery("")}> <button type="button" onClick={() => setUserQuery("")}>
Vis alle Vis alle
</button> </button>
)} )}
@@ -55,13 +55,14 @@ export const Deposit: React.FC = () => {
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" /> <input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" />
</section> </section>
<section className={styles.checkout}> <section className={styles.checkout}>
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}> <button type="button" onClick={onSubmit} className="primary">
Sett inn Sett inn
</button>{" "} </button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}> <button type="button" onClick={reset}>
Start på nytt Start på nytt
</button> </button>
</section> </section>
</div> </div>
); );
}; };
export default Deposit;
+27
View File
@@ -3,3 +3,30 @@
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.disabled,
.disabled a:link {
color: rgba(121, 152, 238, 0.5);
}
.pagination {
display: flex;
align-items: flex-end;
padding: 0;
margin: 0;
margin-top: 1.5rem;
gap: 0 1rem;
list-style: none;
}
.pagination a {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
margin-top: 1rem;
border-radius: 4px;
}
.pagination a:hover {
color: white;
}
.disabled a:hover {
color: rgba(121, 152, 238, 0.5);
}
+21 -24
View File
@@ -9,7 +9,7 @@ import styles from "./Log.module.css";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
export const Log = () => { const Log = () => {
const types = { const types = {
orderOffset: Number, orderOffset: Number,
transactionOffset: Number, transactionOffset: Number,
@@ -34,7 +34,7 @@ export const Log = () => {
<div className={styles.log}> <div className={styles.log}>
<div> <div>
<h2>Ordre</h2> <h2>Ordre</h2>
<table className="table"> <table>
<thead> <thead>
<tr> <tr>
<th scope="col">Når</th> <th scope="col">Når</th>
@@ -51,39 +51,37 @@ export const Log = () => {
<td> <td>
{(order.orderlines ?? []).map((line) => ( {(order.orderlines ?? []).map((line) => (
<div key={line.id}> <div key={line.id}>
{line.amount}x {line.product.name} {Number(line.amount)}x {line.product.name}
</div> </div>
))} ))}
</td> </td>
<td>{order.orderSum}</td> <td>{Number(order.orderSum)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
<nav aria-label="Order pagination"> <nav aria-label="Order pagination">
<ul className="pagination"> <ul className={styles.pagination}>
<li className={clsx("page-item", { disabled: orderOffset <= 0 })}> <li className={clsx({ [styles.disabled]: orderOffset <= 0 })}>
<a <a
href="#" href="#"
className="page-link"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setParams({ orderOffset: orderOffset - PAGE_SIZE }); setParams({ orderOffset: Math.max(0, orderOffset - PAGE_SIZE) });
}} }}
> >
Previous ⬅
</a> </a>
</li> </li>
<li className="page-item"> <li>
<a <a
href="#" href="#"
className="page-link"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setParams({ orderOffset: orderOffset + PAGE_SIZE }); setParams({ orderOffset: Math.max(0, orderOffset + PAGE_SIZE) });
}} }}
> >
Next ➡
</a> </a>
</li> </li>
</ul> </ul>
@@ -91,7 +89,7 @@ export const Log = () => {
</div> </div>
<div> <div>
<h2>Transaksjoner</h2> <h2>Transaksjoner</h2>
<table className="table"> <table>
<thead> <thead>
<tr> <tr>
<th scope="col">Når</th> <th scope="col">Når</th>
@@ -106,35 +104,33 @@ export const Log = () => {
<td> <td>
{trans.user.firstName} {trans.user.lastName} {trans.user.firstName} {trans.user.lastName}
</td> </td>
<td>{trans.amount}</td> <td>{Number(trans.amount)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
<nav aria-label="Transaction pagination"> <nav aria-label="Transaction pagination">
<ul className="pagination"> <ul className={styles.pagination}>
<li className={clsx("page-item", { disabled: transactionOffset <= 0 })}> <li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}>
<a <a
href="#" href="#"
className="page-link"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setParams({ transactionOffset: transactionOffset - PAGE_SIZE }); setParams({ transactionOffset: Math.max(0, transactionOffset - PAGE_SIZE) });
}} }}
> >
Previous ⬅
</a> </a>
</li> </li>
<li className="page-item"> <li>
<a <a
href="#" href="#"
className="page-link"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setParams({ transactionOffset: transactionOffset + PAGE_SIZE }); setParams({ transactionOffset: Math.max(transactionOffset + PAGE_SIZE) });
}} }}
> >
Next ➡
</a> </a>
</li> </li>
</ul> </ul>
@@ -143,3 +139,4 @@ export const Log = () => {
</div> </div>
); );
}; };
export default Log;
@@ -0,0 +1,51 @@
.product {
display: flex;
border: none;
background: rgb(35, 15, 41);
border-radius: 4px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
position: relative;
align-items: flex-start;
padding: 1rem;
}
.product:hover,
.active.product {
background-color: var(--background);
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
}
.productText {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.2rem;
text-shadow: none;
color: white;
}
.amount {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
right: 0;
padding: 12px 12px 8px 8px;
border-left: 1px solid transparent;
color: transparent;
font-size: 2rem;
font-weight: bold;
line-height: 1;
background-color: transparent;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
display: none;
}
.active .amount {
display: block;
color: var(--background);
text-shadow: none;
border-left-color: rgb(35, 15, 41);
background-color: var(--sun-end);
}
@@ -0,0 +1,22 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { Product } from "../../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
onProduct: MouseEventHandler;
active: boolean;
amount: number;
};
export const ProductCard = ({ product, onProduct, amount, active = false }: ProductCardProps): JSX.Element => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct} title={product.name}>
<span className={styles.amount}>{amount}</span>
<span className={styles.productText}>{product.name}</span>
</button>
);
};
@@ -1,14 +1,21 @@
.userList { .userList {
display: grid; display: grid;
grid-template-columns: repeat(12, 128px); grid-template-columns: repeat(5, 220px);
row-gap: 16px; column-gap: 16px;
row-gap: 24px;
}
.showAll {
margin-top: 1rem;
} }
.productList { .productList {
display: grid; display: grid;
grid-template-columns: repeat(8, 150px); grid-template-columns: repeat(5, 220px);
row-gap: 16px; column-gap: 16px;
row-gap: 24px;
} }
.productListSection { .productListSection {
margin-top: 2rem; margin-top: 2rem;
} }
@@ -22,7 +29,6 @@
min-width: 200px; min-width: 200px;
padding: 4px 8px; padding: 4px 8px;
margin-right: 8px; margin-right: 8px;
background: black;
color: white; color: white;
font-size: 1.8rem; font-size: 1.8rem;
} }
+7 -6
View File
@@ -1,14 +1,14 @@
import React from "react"; import React from "react";
import { ProductCard } from "../../components/ProductCard";
import { UserCard } from "../../components/UserCard";
import { OrderLineInput, Product } from "../../types"; import { OrderLineInput, Product } from "../../types";
import { UserCard } from "../users/UserCard";
import { ProductCard } from "./ProductCard";
import styles from "./Register.module.css"; import styles from "./Register.module.css";
import { useRegister } from "./useRegister"; import { useRegister } from "./useRegister";
const USER_EXTERNAL = "external"; const USER_EXTERNAL = "external";
export const Register: React.FC = () => { const Register = (): JSX.Element => {
const { const {
users, users,
products, products,
@@ -48,7 +48,7 @@ export const Register: React.FC = () => {
/> />
))} ))}
</div> </div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}> <button type="button" onClick={() => setShowAll(!showAll)} className={styles.showAll}>
{showAll ? "Skjul gamliser" : "Vis alle"} {showAll ? "Skjul gamliser" : "Vis alle"}
</button> </button>
</section> </section>
@@ -70,13 +70,14 @@ export const Register: React.FC = () => {
<span className={styles.total}> <span className={styles.total}>
Totalt: <span>{total}</span> Totalt: <span>{total}</span>
</span> </span>
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}> <button type="button" onClick={onSubmit} className="primary">
Kjøp Kjøp
</button>{" "} </button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}> <button type="button" onClick={reset}>
Start på nytt Start på nytt
</button> </button>
</section> </section>
</div> </div>
); );
}; };
export default Register;
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useToast } from "../../components/ToastProvider"; import { useToast } from "../../components/ToastProvider";
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types"; import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types";
import { activeUserFilter } from "../../utils/users"; import { activeUserFilter } from "../../utils/users";
import {formatCurrency} from "../../utils/currency";
const USER_EXTERNAL = "external"; const USER_EXTERNAL = "external";
@@ -53,7 +54,7 @@ export const useRegister = () => {
setOrder(newOrder); setOrder(newOrder);
}; };
const total = sumTotal(products, order, selectedUser === USER_EXTERNAL); const total = formatCurrency(sumTotal(products, order, selectedUser === USER_EXTERNAL));
const onSubmit = async () => { const onSubmit = async () => {
let res; let res;
+2 -1
View File
@@ -35,7 +35,7 @@ const OrderBar = ({ data }: { data: BarDatum[] }) => (
/> />
); );
export const OrderStats = () => { const OrderStats = () => {
const { data, loading } = useQuery(OrderStatsDocument); const { data, loading } = useQuery(OrderStatsDocument);
return ( return (
@@ -77,3 +77,4 @@ export const OrderStats = () => {
</StatsView> </StatsView>
); );
}; };
export default OrderStats;
+2 -1
View File
@@ -11,7 +11,7 @@ type UserCount = {
count: number; count: number;
}; };
export const ProductStats = () => { const ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, { const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true }, variables: { active: true },
}); });
@@ -38,3 +38,4 @@ export const ProductStats = () => {
</StatsView> </StatsView>
); );
}; };
export default ProductStats;
@@ -4,6 +4,26 @@
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.statsNav { .statsNav {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.statsNav ul {
display: flex;
padding: 0;
margin: 0;
gap: 0 1rem;
list-style: none;
}
.statsNav a {
padding: 0.4rem 0.8rem;
border: 1px solid transparent;
}
.active.active {
color: white;
}
.statsNav a:hover {
border: 1px solid var(--border);
border-radius: 4px;
}
+2 -1
View File
@@ -5,7 +5,7 @@ import React from "react";
import { ProductStatsDocument } from "../../types"; import { ProductStatsDocument } from "../../types";
import { StatsView } from "./StatsView"; import { StatsView } from "./StatsView";
export const Stats = () => { const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument); const { data, loading } = useQuery(ProductStatsDocument);
const series: any[] = []; const series: any[] = [];
@@ -68,3 +68,4 @@ export const Stats = () => {
</StatsView> </StatsView>
); );
}; };
export default Stats;
+11 -9
View File
@@ -1,34 +1,36 @@
import clsx from "clsx";
import React from "react"; import React from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import styles from "./Stats.module.css"; import styles from "./Stats.module.css";
const navLinkStyles = ({ isActive }: { isActive: boolean }) => (isActive ? "nav-link active" : "nav-link");
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const StatsNav = () => ( export const StatsNav = () => (
<div className={styles.statsNav}> <nav className={styles.statsNav}>
<ul className="nav nav-tabs"> <ul>
<li className="nav-item"> <li>
<NavLink className={navLinkStyles} to="/stats" end> <NavLink className={navLinkStyles} to="/stats" end>
Oversikt Oversikt
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink className={navLinkStyles} to="/stats/products"> <NavLink className={navLinkStyles} to="/stats/products">
Produkter Produkter
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink className={navLinkStyles} to="/stats/orders"> <NavLink className={navLinkStyles} to="/stats/orders">
Ordre Ordre
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink className="nav-link disabled" to="/stats/users" title="TODO"> <NavLink className="disabled" to="/stats/users" title="TODO" hidden={true}>
Folk Folk
</NavLink> </NavLink>
</li> </li>
</ul> </ul>
</div> </nav>
); );
export const StatsView = ({ children, loading }: { children: React.ReactNode; loading: boolean }) => ( export const StatsView = ({ children, loading }: { children: React.ReactNode; loading: boolean }) => (
@@ -0,0 +1,61 @@
.user {
display: flex;
align-items: center;
padding: 0;
border: none;
border-left: 4px solid transparent;
background: rgb(35, 15, 41);
border-radius: 4px;
color: var(--text);
transition: background-color 0.1s ease-out, border 0.1s ease-out;
max-height: 82px;
}
.user:hover {
cursor: pointer;
}
.user:hover,
.active.user {
background-color: var(--background);
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
}
.active.user {
border-left: 4px solid var(--sun)
}
.avatar {
margin-left: 1rem;
width: 50px;
height: 50px;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
}
.avatarWrap {
filter: drop-shadow(0 0 4px var(--background)) drop-shadow(0 0 11px var(--background))
drop-shadow(0 0 19px var(--background));
}
.noAvatar {
margin-left: 1rem;
display: block;
width: 50px;
height: 50px;
background-color: black;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
content: "";
}
.meta {
display: flex;
padding: 1rem;
flex-direction: column;
align-items: flex-start;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.2rem;
text-shadow: none;
}
.balance {
color: var(--sun)
}
+36
View File
@@ -0,0 +1,36 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../../types";
import styles from "./UserCard.module.css";
import {formatCurrency} from "../../utils/currency";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
onUser: MouseEventHandler;
active: boolean;
};
/** If user is null, then it's the special external user */
export const UserCard = ({ user, onUser, active = false }: UserCardProps): JSX.Element => {
const classes = clsx(styles.user, { [styles.active]: active });
const userBalanceString = formatCurrency(Number(user?.profile?.balance));
return (
<button className={classes} onClick={onUser} type="button" title={user?.firstName || "Ekstern"}>
<div className={styles.avatarWrap}>
{user?.profile?.image && (
<img src={user.profile?.image} alt={`Profile for ${user.firstName}`} className={styles.avatar} />
)}
{!user?.profile?.image && (
<span className={styles.avatar}>
<span className={styles.noAvatar} />
</span>
)}
</div>
<div className={styles.meta}>
{user?.firstName || "Ekstern"}
<span className={styles.balance}>{userBalanceString}</span>
</div>
</button>
);
};
+16 -12
View File
@@ -3,16 +3,19 @@ import { Line } from "@nivo/line";
import React from "react"; import React from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { User, UserDetailDocument, UserProfile } from "../../types"; import { User, UserDetailDocument, UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time"; import { formatMonth, formatTime } from "../../utils/time";
import styles from "./UserDetail.module.css"; import styles from "./UserDetail.module.css";
export const UserDetail = () => { const UserDetail = () => {
const { userId } = useParams(); const { userId } = useParams();
const { data, loading } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId }); const { data, loading, error } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const user = data?.user as User; const user = data?.user as User;
const profile = user?.profile as UserProfile; const profile = user?.profile as UserProfile;
// FIXME: types // FIXME: types
@@ -30,26 +33,26 @@ export const UserDetail = () => {
<h1> <h1>
{profile.image && <img src={profile.image} alt={`${user.firstName}s profile picture`} />} {user.firstName} {profile.image && <img src={profile.image} alt={`${user.firstName}s profile picture`} />} {user.firstName}
</h1> </h1>
<div className="card"> <div>
<div className="card-header">Stats</div> <div>Stats</div>
<ul className="list-group list-group-flush"> <ul>
<li className="list-group-item"> <li>
Saldo: <strong>{profile.balance.toLocaleString("no")} kr</strong> Saldo: <strong>{profile.balance.toLocaleString("no")} kr</strong>
</li> </li>
<li className="list-group-item"> <li>
Siste kjøp: <strong>{formatTime(profile.lastPurchaseDate)}</strong> Siste kjøp: <strong>{formatTime(profile.lastPurchaseDate)}</strong>
</li> </li>
<li className="list-group-item"> <li>
Kjøp totalt: <strong>{profile.orderSumTotal.toLocaleString("no")} kr</strong> Kjøp totalt: <strong>{profile.orderSumTotal.toLocaleString("no")} kr</strong>
</li> </li>
</ul> </ul>
</div> </div>
<br /> <br />
<div className="card"> <div>
<div className="card-header">Top 5 måneder</div> <div>Top 5 måneder</div>
<ul className="list-group list-group-flush"> <ul>
{profile.topMonths.map((topMonth) => ( {profile.topMonths.map((topMonth) => (
<li key={topMonth.period} className="list-group-item"> <li key={topMonth.period}>
{formatMonth(topMonth.period)}: <strong>{topMonth.count}</strong> kjøp på totalt{" "} {formatMonth(topMonth.period)}: <strong>{topMonth.count}</strong> kjøp på totalt{" "}
<strong>{topMonth.sum} kr</strong> <strong>{topMonth.sum} kr</strong>
</li> </li>
@@ -108,3 +111,4 @@ export const UserDetail = () => {
</div> </div>
); );
}; };
export default UserDetail;
+5 -3
View File
@@ -5,7 +5,7 @@ import { Link } from "react-router-dom";
import { AllUsersDocument } from "../../types"; import { AllUsersDocument } from "../../types";
import { activeUserFilter } from "../../utils/users"; import { activeUserFilter } from "../../utils/users";
export const UserList = () => { const UserList = () => {
const { data, loading } = useQuery(AllUsersDocument); const { data, loading } = useQuery(AllUsersDocument);
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
@@ -15,7 +15,8 @@ export const UserList = () => {
const users = showAll ? allUsers : allUsers.filter((u) => activeUserFilter(u)); const users = showAll ? allUsers : allUsers.filter((u) => activeUserFilter(u));
return ( return (
<div> <div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}> <h2>Folk</h2>
<button type="button" onClick={() => setShowAll(!showAll)}>
{showAll ? "Skjul gamliser" : "Vis alle"} {showAll ? "Skjul gamliser" : "Vis alle"}
</button> </button>
<ul> <ul>
@@ -28,9 +29,10 @@ export const UserList = () => {
))} ))}
</ul> </ul>
<br /> <br />
<Link to="/users/create" className="btn btn-primary"> <Link to="/users/create" className="btn-primary">
Opprett ny Opprett ny
</Link> </Link>
</div> </div>
); );
}; };
export default UserList;
+145
View File
@@ -0,0 +1,145 @@
/* Custom props */
:root {
--brand: rgb(243, 33, 61);
--background: rgb(70, 30, 82);
--text: rgb(255, 255, 255);
--sun: rgb(221, 81, 127);
--sun-end: rgb(230, 142, 54);
--border: rgb(85, 109, 200);
--interlace: rgb(121, 152, 238);
--font-family: "IBM Plex Mono", monospace;
}
/* Resets */
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
/* Body and interitable styles */
body {
background: linear-gradient(rgba(0, 0, 0, 0.8) 60%, var(--background) 100%);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
/* Links */
a:link {
color: var(--interlace);
text-decoration: none;
}
a:visited {
color: var(--interlace);
}
a:active {
color: var(--interlace);
}
a:hover {
color: var(--text);
}
h1,
h2,
h3,
h4,
h5 {
color: var(--sun);
}
a.btn-primary {
padding: 0.4rem 1rem;
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
border-radius: 4px;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
/* Buttons */
button {
padding: 0.4rem 1rem;
border: 1px solid var(--border);
background: transparent;
border-radius: 4px;
color: var(--border);
font-family: var(--font-family);
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
button:hover {
color: var(--text);
cursor: pointer;
}
button.primary {
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
/* Inputs */
input {
padding: 0.4rem 0.4rem;
border: 1px solid var(--border);
background: transparent;
border-radius: 4px;
color: var(--text);
font-family: var(--font-family);
font-size: 1.5rem;
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
input:focus {
background: rgba(85, 109, 200, 0.1);
outline: none;
}
input.btn-primary {
padding: 0.4rem 1rem;
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
border-radius: 4px;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
::placeholder {
color: var(--border);
}
/* Tables */
table {
border: 1px solid var(--border);
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0.5rem 0.8rem;
border: 1px solid var(--border);
}
/* Fonts*/
@font-face {
font-family: "Rocket Clouds DEMO";
font-style: normal;
font-weight: normal;
src: url("assets/rocket-clouds.ttf") format("truetype");
}
@font-face {
font-family: "Rocket Rinder";
font-style: normal;
font-weight: normal;
src: url("assets/rocket-rinder.ttf") format("truetype");
}
+2
View File
@@ -3,6 +3,8 @@ import ReactDOM from "react-dom";
import App from "./App"; import App from "./App";
import "./global.css";
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
+7
View File
@@ -0,0 +1,7 @@
export function formatCurrency(value: number) {
if (isNaN(value)) {
return "";
} else {
return value.toFixed(2).replace(/\.00$/, '');
}
}
+1 -2
View File
@@ -4,11 +4,10 @@ import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: process.env.NODE_ENV !== "production" ? "/" : "/static/modern/",
plugins: [svgr()], plugins: [svgr()],
define: { define: {
__DEV__: process.env.NODE_ENV !== "production", __DEV__: process.env.NODE_ENV !== "production",
}, },
envDir: "..", envDir: "..",
build: { outDir: "../static/modern", emptyOutDir: true }, build: { outDir: "../static", emptyOutDir: false },
}); });
-20
View File
@@ -1,20 +0,0 @@
import strawberry
from main.api.mutations.shop import ShopMutations
from main.api.mutations.users import UsersMutations
from main.api.queries.inventory import InventoryQueries
from main.api.queries.shop import ShopQueries
from main.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
schema = strawberry.Schema(query=Query, mutation=Mutation)
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more