Author SHA1 Message Date
leanderandCopilot 53968fa949 Integrate compatible tests-and-bumps updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 21:13:26 +02:00
leanderandCopilot d979558905 Toggle selected users in register and deposit
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 20:57:27 +02:00
leanderandCopilot 78d5b06258 Add user purchase statistics tab
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:39:45 +02:00
leanderandCopilot 4848dda4a4 Migrate inventory and reports to the SPA
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:38:49 +02:00
leanderandCopilot 1d507d1976 Refresh README TODOs and product card styling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:22:47 +02:00
leanderandCopilot ec5e8dc791 Prevent rotating background from shifting layout
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:12:17 +02:00
leanderandCopilot ac2ceddf78 Improve mobile responsiveness across pages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:05:44 +02:00
leanderandCopilot c05820f61b Adjust chart x-axis legend placement
Move the date legend inward to align with the y-axis label on overview and user detail charts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:59:14 +02:00
leanderandCopilot 4d64126581 Keep statistics charts clear of the footer
Use a full-height flex layout and normal footer flow so long chart content scrolls without overlapping the footer. Keep the page gradient fixed while scrolling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:54:28 +02:00
leanderandCopilot 9ad7b8aa28 Keep deposit entry visible while selecting a user
Place the amount entry and actions in a clear adjacent panel on larger screens and above the scrollable user list on mobile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:50:02 +02:00
leanderandCopilot 64b77217f4 Improve chart text contrast
Use a brighter shared chart theme and a dark styled tooltip, with high-contrast pie slice labels.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:47:27 +02:00
leanderandCopilot 65e532be30 Fix user detail GraphQL query
Return monthly purchase totals with the correct GraphQL scalar and align frontend queries and generated types with the upgraded schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:40:53 +02:00
leanderandCopilot 407523b61a Upgrade vulnerable Python dependencies
Raise the supported Python floor to 3.11 for patched dependency releases and update CI/runtime metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:33:15 +02:00
leanderandCopilot e23cbb1d27 Make local frontend backend configurable
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:21:44 +02:00
leanderandCopilot 098c6701f2 Add repeatable demo database seed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:00:46 +02:00
leanderandCopilot 18da8335a8 Upgrade frontend dependencies and tooling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:00:31 +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
143 changed files with 10005 additions and 18927 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000
+5 -1
View File
@@ -11,4 +11,8 @@ CACHE/
media/ media/
tmp/ tmp/
frontend/dist frontend/dist
static/modern static/assets
static/index.html
.python-version
.env
.coverage
+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.11-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:
+11 -17
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
hooks: rev: 'v0.11.7'
- id: black hooks:
name: black - id: ruff
entry: black args: [--fix, --exit-non-zero-on-fix]
args: [--check] - id: ruff-format
language: system - repo: https://github.com/adamchainz/django-upgrade
require_serial: true rev: "1.24.0"
types: [python] hooks:
- id: isort - id: django-upgrade
name: isort args: [--target-version, "5.2"]
entry: isort
args: [--check]
language: system
types: [python]
+32 -21
View File
@@ -1,36 +1,47 @@
# Getting started # Getting started
Before starting, make sure you have a working python and nodejs setup with pyenv and nvm. Before starting, make sure you have Python 3.11 or newer and Node.js 20 available.
```bash ```bash
apt-get install libmysqlclient-dev uv run manage.py migrate
poetry install cp .env-default .env
poetry shell uv run manage.py seed_demo_data
pre-commit install
python manage.py migrate
bin/run
# in another terminal # Optional: create an admin login
cd frontend uv run manage.py createsuperuser
npm install
npm start # Install frontend packages once
cd frontend && npm install
```
Start the backend and frontend in separate terminals from the project root:
```bash
uv run bin/run
```
```bash
uv run bin/run-frontend
```
Vite prints the frontend URL when it starts (normally <http://localhost:5173/>; if that port is busy, it selects another). Django runs at <http://localhost:3000/>; open the admin directly at <http://localhost:3000/admin/> or through the frontend dev proxy at the printed Vite URL followed by `/admin/`. The GraphQL API is at <http://localhost:3000/graphql/>; the frontend uses Vite's proxy for development API requests.
To test the frontend against a different backend, set `VITE_DEV_API_PROXY` when starting Vite, for example `VITE_DEV_API_PROXY=https://bbs.neuf.no uv run bin/run-frontend`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data.
To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` for that command; the command only creates a local `admin` superuser if one does not already exist.
## Testing
```bash
mise run test
``` ```
## 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
- update emails
- Stats: products over time
- Light on dark colors
- inventory views
## 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.
+63
View File
@@ -0,0 +1,63 @@
from decimal import Decimal
from typing import Annotated
import strawberry
from django.db import transaction
from strawberry import ID, Info
from bbs import models
from bbs.api.errors import FieldError, FormErrors
from bbs.api.types.inventory import InventoryTransaction
@strawberry.input
class InventoryTransactionInput:
product_id: ID
amount: int
unit_price: Decimal
comment: str = ""
@strawberry.type
class CreateInventoryTransactionSuccess:
inventory_transaction: InventoryTransaction
CreateInventoryTransactionResponse = Annotated[
CreateInventoryTransactionSuccess | FormErrors,
strawberry.union("CreateInventoryTransactionResponse"),
]
@strawberry.mutation
@transaction.atomic
def create_inventory_transaction(
info: Info,
data: InventoryTransactionInput,
) -> CreateInventoryTransactionResponse:
user = info.context.request.user
if not user.is_authenticated:
message = "Logg inn for å registrere varetransaksjoner."
return FormErrors(message=message, fields=[FieldError(field="user", message=message)])
try:
product = models.Product.objects.get(pk=data.product_id, active=True)
except (models.Product.DoesNotExist, ValueError):
message = "Velg et aktivt produkt."
return FormErrors(message=message, fields=[FieldError(field="product_id", message=message)])
product.inventory_amount += data.amount
product.save()
inventory_transaction = models.InventoryTransaction.objects.create(
user=user,
product=product,
amount=data.amount,
unit_price=data.unit_price,
comment=data.comment,
)
return CreateInventoryTransactionSuccess(inventory_transaction=inventory_transaction)
@strawberry.type
class InventoryMutations:
create_inventory_transaction = create_inventory_transaction
@@ -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.
+113
View File
@@ -0,0 +1,113 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.db.models import F, Sum
from django.utils import timezone
from bbs import models
from bbs.api.types.inventory import (
InventoryReport,
InventoryReportProduct,
InventoryTransaction,
InventoryTransactionOrdering,
Transaction,
TransactionOrdering,
)
@strawberry.type
class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
inventory_transaction_list: list[InventoryTransaction] = strawberry_django.field(
pagination=True,
ordering=InventoryTransactionOrdering,
)
@strawberry.field
def inventory_report(self, start_time: datetime, end_time: datetime) -> InventoryReport:
if timezone.is_naive(start_time):
start_time = timezone.make_aware(start_time)
if timezone.is_naive(end_time):
end_time = timezone.make_aware(end_time)
if start_time > end_time:
raise ValueError("The report start date must not be after the end date.")
purchases = (
models.InventoryTransaction.objects.filter(created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
sales = (
models.OrderLine.objects.filter(order__created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
product_totals = {}
for product in purchases:
product_totals[product["product_id"]] = {
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": product["units"] or 0,
"purchase_value": product["value"] or Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
}
for product in sales:
product_data = product_totals.setdefault(
product["product_id"],
{
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": 0,
"purchase_value": Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
},
)
product_data["sales_units"] = product["units"] or 0
product_data["sales_value"] = product["value"] or Decimal(0)
product_data = sorted(product_totals.values(), key=lambda product: product["product_name"])
inventory_purchases = sum((product["purchase_value"] for product in product_data), Decimal(0))
product_sales = sum((product["sales_value"] for product in product_data), Decimal(0))
deposits = (
models.Transaction.objects.filter(created__range=(start_time, end_time)).aggregate(total=Sum("amount"))[
"total"
]
or Decimal(0)
)
order_purchases = (
models.Order.objects.filter(created__range=(start_time, end_time)).aggregate(total=Sum("order_sum"))[
"total"
]
or Decimal(0)
)
deposits_start = (
models.Transaction.objects.filter(created__lt=start_time).aggregate(total=Sum("amount"))["total"]
or Decimal(0)
)
orders_start = (
models.Order.objects.filter(created__lt=start_time).aggregate(total=Sum("order_sum"))["total"] or Decimal(0)
)
deposits_end = (
models.Transaction.objects.filter(created__lte=end_time).aggregate(total=Sum("amount"))["total"]
or Decimal(0)
)
orders_end = (
models.Order.objects.filter(created__lte=end_time).aggregate(total=Sum("order_sum"))["total"] or Decimal(0)
)
return InventoryReport(
products=[InventoryReportProduct(**product) for product in product_data],
inventory_purchases=inventory_purchases,
product_sales=product_sales,
deposits=deposits,
order_purchases=order_purchases,
balance_at_start=deposits_start - orders_start,
balance_at_end=deposits_end - orders_end,
)
@@ -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()
] ]
@@ -1,14 +1,15 @@
import strawberry import strawberry
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
class UsersQueries: class UsersQueries:
all_users: list[User] = strawberry_django.field() @strawberry.field
def all_users(self) -> list[User]:
return DjangoUser.objects.filter(profile__isnull=False)
@strawberry.field @strawberry.field
def user(self, user_id: ID) -> User: def user(self, user_id: ID) -> User:
+36
View File
@@ -0,0 +1,36 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension
from bbs.api.mutations.inventory import InventoryMutations
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(InventoryMutations, ShopMutations, UsersMutations):
pass
class HandleNotFound(SchemaExtension):
def on_operation(self):
yield
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.
+62
View File
@@ -0,0 +1,62 @@
from decimal import Decimal
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import ID, auto
from bbs import models
if TYPE_CHECKING:
from .shop import Product
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
@strawberry_django.type(models.InventoryTransaction)
class InventoryTransaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
product: Annotated["Product", strawberry.lazy(".shop")]
amount: auto
unit_price: auto
comment: auto
created: auto
@strawberry_django.order_type(models.InventoryTransaction)
class InventoryTransactionOrdering:
created: auto
@strawberry.type
class InventoryReportProduct:
product_id: ID
product_name: str
purchase_units: int
purchase_value: Decimal
sales_units: int
sales_value: Decimal
@strawberry.type
class InventoryReport:
products: list[InventoryReportProduct]
inventory_purchases: Decimal
product_sales: Decimal
deposits: Decimal
order_purchases: Decimal
balance_at_start: Decimal
balance_at_end: Decimal
@@ -1,12 +1,18 @@
from decimal import Decimal
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 strawberry.scalars import JSON
from main import models from bbs import models
from main.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
@@ -19,11 +25,16 @@ class Product:
image_url: str image_url: str
sale_price_int: auto sale_price_int: auto
sale_price_ext: auto sale_price_ext: auto
active: auto
inventory_amount: auto
updated: auto
wholesale_unit_price: Decimal
wholesale_value: Decimal
# FIXME: stricter types # FIXME: stricter types
user_counts: JSONScalar user_counts: JSON
@strawberry_django.ordering.order(models.Order) @strawberry_django.order_type(models.Order)
class OrderOrdering: class OrderOrdering:
created: auto created: auto
@@ -31,7 +42,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")] | None
order_sum: auto order_sum: auto
created: auto created: auto
orderlines: list["OrderLine"] orderlines: list["OrderLine"]
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from strawberry import auto
from bbs import models
from bbs.api.types.shop import Point, ProductOrderStat
@strawberry.type
class TopMonth:
period: str
count: int
sum: float
@strawberry_django.type(models.UserProfile)
class UserProfile:
id: auto
balance: auto
image: auto
last_purchase_date: datetime
order_sum_total: Decimal
@strawberry.field()
def top_months(self) -> list[TopMonth]:
return [
TopMonth(period=month["period"], count=month["count"], sum=float(month["sum"]))
for month in self.top_months()
]
@strawberry.field()
def product_order_stats(self) -> list[ProductOrderStat]:
return [
ProductOrderStat(
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in self.product_order_stats().as_series()
]
@strawberry_django.type(DjangoUser)
class User:
id: auto
first_name: auto
last_name: auto
profile: UserProfile | None
+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()
+151
View File
@@ -0,0 +1,151 @@
import os
from datetime import datetime, time, timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand
from django.db.models import Sum
from django.utils import timezone
from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction, UserProfile
DEMO_USERS = (
("demo-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")),
("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.0")),
("demo-casey", "Casey", "Sol", "casey@example.test", Decimal("200.0")),
)
DEMO_PRODUCTS = (
("Demo Cola", Decimal("25.0"), Decimal("30.0"), 30),
("Demo Kaffe", Decimal("15.0"), Decimal("20.0"), 30),
("Demo Sjokolade", Decimal("20.0"), Decimal("25.0"), 30),
("Demo Energidrikk", Decimal("35.0"), Decimal("40.0"), 30),
)
class Command(BaseCommand):
help = "Create repeatable demo users, products, deposits, and orders for local testing."
def handle(self, *args, **options): # noqa: ARG002
users = self.seed_users()
products = self.seed_products(users[0][0])
self.seed_deposits(users)
self.seed_orders(users, products)
self.update_balances(users)
self.create_admin()
self.stdout.write(self.style.SUCCESS("Demo data is ready."))
if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"):
self.stdout.write("Create an admin login with: uv run manage.py createsuperuser")
@staticmethod
def seed_users():
user_model = get_user_model()
users = []
for username, first_name, last_name, email, deposit_amount in DEMO_USERS:
user, _ = user_model.objects.get_or_create(
username=username,
defaults={"first_name": first_name, "last_name": last_name, "email": email},
)
UserProfile.objects.get_or_create(user=user)
users.append((user, deposit_amount))
return users
@staticmethod
def seed_products(stock_user):
products = {}
for name, sale_price_int, sale_price_ext, stock in DEMO_PRODUCTS:
product, created = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": sale_price_int,
"sale_price_ext": sale_price_ext,
"inventory_amount": stock,
"description": "Demo product for local development.",
},
)
products[name] = product
if created:
InventoryTransaction.objects.get_or_create(
user=stock_user,
product=product,
comment="Demo opening stock",
defaults={"amount": stock, "unit_price": Decimal("10.0")},
)
return products
def seed_deposits(self, users):
for index, (user, deposit_amount) in enumerate(users):
created_at = self.timestamp(days_ago=32 + index)
deposit, created = Transaction.objects.get_or_create(
user=user,
created=created_at,
defaults={"amount": deposit_amount},
)
if created:
deposit.created = created_at
deposit.save(update_fields=["created"])
def seed_orders(self, users, products):
orders = (
(0, 24, (("Demo Cola", 2), ("Demo Sjokolade", 1))),
(1, 19, (("Demo Kaffe", 2), ("Demo Sjokolade", 1))),
(2, 11, (("Demo Energidrikk", 1),)),
(0, 4, (("Demo Kaffe", 1), ("Demo Cola", 1))),
(1, 1, (("Demo Cola", 2),)),
)
for user_index, days_ago, lines in orders:
user = users[user_index][0]
created_at = self.timestamp(days_ago=days_ago)
total = sum(
(products[name].sale_price_int * amount for name, amount in lines),
start=Decimal("0"),
)
order, created = Order.objects.get_or_create(
customer=user,
created=created_at,
defaults={"order_sum": total},
)
if created:
order.created = created_at
order.save(update_fields=["created"])
for name, amount in lines:
product = products[name]
OrderLine.objects.create(
order=order,
product=product,
amount=amount,
unit_price=product.sale_price_int,
)
product.inventory_amount -= amount
product.save(update_fields=["inventory_amount"])
@staticmethod
def update_balances(users):
for user, _ in users:
deposits = Transaction.objects.filter(user=user).aggregate(total=Sum("amount"))["total"] or Decimal("0")
purchases = Order.objects.filter(customer=user).aggregate(total=Sum("order_sum"))["total"] or Decimal("0")
profile = user.profile
profile.balance = deposits - purchases
profile.save(update_fields=["balance"])
def create_admin(self):
admin_password = os.environ.get("BBS_SEED_ADMIN_PASSWORD")
if not admin_password:
return
user_model = get_user_model()
if user_model.objects.filter(username="admin").exists():
self.stdout.write("Existing 'admin' account left unchanged.")
else:
user_model.objects.create_superuser(
username="admin",
email="admin@example.test",
password=admin_password,
)
self.stdout.write("Created superuser 'admin'.")
@staticmethod
def timestamp(days_ago: int) -> datetime:
date = timezone.localdate() - timedelta(days=days_ago)
return timezone.make_aware(datetime.combine(date, time(hour=12)))
@@ -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.
+44 -33
View File
@@ -1,7 +1,7 @@
import hashlib import hashlib # noqa: EXE002
from datetime import datetime from datetime import UTC, 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.replace(tzinfo=UTC)
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
File renamed without changes.
+38
View File
@@ -0,0 +1,38 @@
from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import Product
def test_inventory_form_filters_active_products(db): # noqa: ARG001
active = Product.objects.create(name="active", sale_price_int=10, sale_price_ext=12, active=True)
inactive = Product.objects.create(name="inactive", sale_price_int=10, sale_price_ext=12, active=False)
product_queryset = InventoryTransactionForm().fields["product"].queryset
assert active in product_queryset
assert inactive not in product_queryset
def test_inventory_form_valid(db): # noqa: ARG001
product = Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
form = InventoryTransactionForm(
data={"product": product.pk, "amount": 5, "unit_price": "12.5", "comment": ""},
)
assert form.is_valid()
def test_inventory_form_invalid_missing_required_fields(db): # noqa: ARG001
form = InventoryTransactionForm(data={})
assert not form.is_valid()
assert "product" in form.errors
def test_date_range_form_allows_missing_dates():
assert DateRangeForm(data={}).is_valid()
def test_date_range_form_accepts_dates():
form = DateRangeForm(data={"start_time": "2026-01-01 00:00:00", "end_time": "2026-01-31 23:59:59"})
assert form.is_valid()
assert form.cleaned_data["start_time"] is not None
+60
View File
@@ -0,0 +1,60 @@
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from bbs.models import UserProfile
@pytest.fixture
def user(db): # noqa: ARG001
user = get_user_model().objects.create_user(username="profile-image-user", email="image@example.com")
UserProfile.objects.create(user=user)
return user
def test_profile_images_updates_missing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images")
user.profile.refresh_from_db()
assert user.profile.image != ""
def test_profile_images_dry_run_skips_update(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images", dry_run=True)
user.profile.refresh_from_db()
assert user.profile.image == ""
def test_profile_images_force_overwrites_existing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="https://example.com/old.jpg")
call_command("profile_images", force=True)
user.profile.refresh_from_db()
assert user.profile.image not in ("", "https://example.com/old.jpg")
@pytest.mark.django_db
def test_export_schema_writes_file(tmp_path, settings):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema")
schema_file = tmp_path / "schema.graphql"
assert schema_file.exists()
assert "type Query" in schema_file.read_text()
@pytest.mark.django_db
def test_export_schema_stdout_does_not_write_file(tmp_path, settings, capsys):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema", filename="-")
assert not (tmp_path / "schema.graphql").exists()
assert capsys.readouterr().out == ""
+12
View File
@@ -0,0 +1,12 @@
from datetime import UTC, datetime
from django.contrib.auth import get_user_model
from bbs.models import UserProfile
def test_last_purchase_date_without_orders_is_timezone_aware(db): # noqa: ARG001
user = get_user_model().objects.create_user(username="no-purchases")
profile = UserProfile.objects.create(user=user)
assert profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC)
+208
View File
@@ -0,0 +1,208 @@
import pytest
from django.contrib.auth import get_user_model
from django.test import Client
from django.utils import timezone
from strawberry.django.test import GraphQLTestClient
from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction
@pytest.fixture(name="gql_client")
def gql_client_fixture(client: Client):
return GraphQLTestClient(client)
@pytest.fixture(name="product")
def product_fix(db): # noqa: ARG001
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
def test_user_not_found(db, gql_client): # noqa: ARG001
result = gql_client.query(
"""
query User($userId: ID!) {
user(userId: $userId) {
id
}
}
""",
{"userId": "999"},
assert_no_errors=False,
)
assert result.errors
error = result.errors[0]
assert error["path"] == ["user"]
assert error["extensions"]["code"] == "NOT_FOUND"
assert error["extensions"]["model"] == "User"
def test_create_order(product, gql_client):
assert Order.objects.count() == 0
variables = {
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
... on CreateOrderSuccess {
order {
id
orderSum
orderlines {
id
product {
id
}
}
}
}
}
}
"""
res = gql_client.query(query, variables)
assert res
assert not res.errors
assert res.data
order_lines = res.data.get("createOrder", {}).get("order", {}).get("orderlines", [])
assert order_lines
assert order_lines[0]["product"]["id"] == str(product.id)
assert Order.objects.count() == 1
o = Order.objects.first()
assert o.orderlines.first().product.id == product.id
def test_create_inventory_transaction_requires_login(product, gql_client):
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on FormErrors { message }
}
}
""",
variables={"data": {"productId": str(product.id), "amount": 5, "unitPrice": "12.5"}},
)
assert not result.errors
assert result.data["createInventoryTransaction"]["message"] == "Logg inn for å registrere varetransaksjoner."
product.refresh_from_db()
assert product.inventory_amount == 0
assert InventoryTransaction.objects.count() == 0
def test_create_inventory_transaction_updates_stock(product, gql_client, client):
user = get_user_model().objects.create_user(username="stock-manager")
client.force_login(user)
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
amount
unitPrice
product { id inventoryAmount }
user { id }
}
}
}
}
""",
variables={
"data": {
"productId": str(product.id),
"amount": 5,
"unitPrice": "12.5",
"comment": "Restock",
}
},
)
assert not result.errors
transaction = result.data["createInventoryTransaction"]["inventoryTransaction"]
assert transaction["amount"] == 5
assert transaction["unitPrice"] == "12.5"
assert transaction["product"]["inventoryAmount"] == 5
assert transaction["user"]["id"] == str(user.id)
product.refresh_from_db()
assert product.inventory_amount == 5
def test_inventory_report_aggregates_period_data(product, gql_client):
user = get_user_model().objects.create_user(username="report-user")
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=10)
transaction = Transaction.objects.create(user=user, amount=100)
order = Order.objects.create(order_sum=30)
OrderLine.objects.create(order=order, product=product, amount=2, unit_price=15)
start_time = timezone.make_naive(timezone.now() - timezone.timedelta(days=1))
end_time = timezone.make_naive(timezone.now() + timezone.timedelta(days=1))
result = gql_client.query(
"""
query InventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
""",
variables={"startTime": start_time.isoformat(), "endTime": end_time.isoformat()},
)
assert not result.errors
report = result.data["inventoryReport"]
assert report["products"] == [
{
"productId": str(product.id),
"purchaseUnits": 5,
"purchaseValue": "50",
"salesUnits": 2,
"salesValue": "30",
}
]
assert report["inventoryPurchases"] == "50"
assert report["productSales"] == "30"
assert report["deposits"] == "100"
assert report["orderPurchases"] == "30"
assert report["balanceAtStart"] == "0"
assert report["balanceAtEnd"] == "70"
assert transaction.amount == 100
products_result = gql_client.query(
"""
query InventoryProducts {
allProducts {
id
active
inventoryAmount
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{created: DESC}], pagination: {limit: 20, offset: 0}) {
amount
unitPrice
product { name }
user { firstName lastName }
}
}
"""
)
assert not products_result.errors
assert products_result.data["allProducts"][0]["active"]
assert products_result.data["inventoryTransactionList"][0]["amount"] == 5
+65
View File
@@ -0,0 +1,65 @@
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.models import InventoryTransaction, Order, Product, Transaction
@pytest.mark.django_db
def test_seed_demo_data_is_repeatable():
call_command("seed_demo_data")
call_command("seed_demo_data")
assert get_user_model().objects.filter(username__startswith="demo-").count() == 3
assert Product.objects.filter(name__startswith="Demo ").count() == 4
assert Order.objects.count() == 5
assert Transaction.objects.count() == 3
assert InventoryTransaction.objects.count() == 4
assert sum(product.inventory_amount for product in Product.objects.filter(name__startswith="Demo ")) == 109
@pytest.mark.django_db
def test_demo_data_is_available_when_an_admin_has_no_profile():
user_model = get_user_model()
user_model.objects.create_superuser(username="admin", email="admin@example.test", password="password")
call_command("seed_demo_data")
gql_client = GraphQLTestClient(Client())
result = gql_client.query(
"""
{
allProducts(filters: {active: true}) { name }
allUsers { firstName lastName profile { balance } }
}
"""
)
assert not result.errors
assert result.data
assert len(result.data["allProducts"]) == 4
assert len(result.data["allUsers"]) == 3
ada = user_model.objects.get(username="demo-ada")
detail_result = gql_client.query(
"""
query UserDetail($userId: ID!) {
user(userId: $userId) {
firstName
profile {
balance
lastPurchaseDate
orderSumTotal
topMonths { period sum count }
productOrderStats { productName data { x y } }
}
}
}
""",
variables={"userId": str(ada.pk)},
)
assert not detail_result.errors
assert detail_result.data["user"]["profile"]["topMonths"]
assert isinstance(detail_result.data["user"]["profile"]["topMonths"][0]["sum"], float)
+10
View File
@@ -0,0 +1,10 @@
import pytest
from django.test import Client
@pytest.mark.parametrize("path", ["/inventory", "/inventory/add", "/report"])
def test_inventory_pages_serve_the_spa_entry(client: Client, path: str):
response = client.get(path)
assert response.status_code == 200
assert b'<div id="root"></div>' in b"".join(response.streaming_content)
+21
View File
@@ -0,0 +1,21 @@
from bbs.utils import format_username
def test_format_username():
assert format_username("Ada", "Lovelace") == "adalovela"
def test_format_username_truncates_names():
assert format_username("Aleksandar", "Vukovic") == "aleksavukovi"
def test_format_username_removes_spaces():
assert format_username("Mary Jane", "van Dijk") == "maryjavandij"
def test_format_username_drops_non_ascii_characters():
assert format_username("Ångström", "Ø") == "ngstr"
def test_format_username_allows_empty_names():
assert format_username("", "") == ""
+32
View File
@@ -0,0 +1,32 @@
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.spa import spa_index
admin.autodiscover()
urlpatterns = [
path("inventory", spa_index, name="inventory"),
path("inventory/", spa_index),
path("inventory/add", spa_index, name="inventory-add"),
path("inventory/add/", spa_index),
path("report", spa_index, name="report"),
path("report/", spa_index),
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.
+19 -19
View File
@@ -1,21 +1,22 @@
from datetime import datetime, timedelta from datetime import timedelta
from itertools import groupby 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 django.utils import timezone
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 = timezone.localtime()
start_time = datetime.now() - timedelta(days=30) start_time = timezone.localtime() - timedelta(days=30)
# 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 = timezone.localtime().replace(hour=23, minute=59, second=59, microsecond=999)
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 +51,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 +76,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 +89,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 +111,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 +121,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 +134,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
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from django.contrib.staticfiles import finders
from django.http import FileResponse, Http404
def spa_index(_request):
index_path = finders.find("index.html")
if not index_path:
raise Http404("The frontend has not been built.")
return FileResponse(Path(index_path).open("rb"), content_type="text/html")
+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
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
python manage.py runserver exec python manage.py runserver 127.0.0.1:3000
+3 -1
View File
@@ -1 +1,3 @@
cd frontend && npm run start #!/bin/bash
cd frontend && npm run start -- --host 127.0.0.1
+1
View File
@@ -19,6 +19,7 @@ module.exports = {
rules: { rules: {
"no-console": "off", "no-console": "off",
"prefer-destructuring": "off", "prefer-destructuring": "off",
"import/no-unresolved": ["error", { ignore: ["\\?react$"] }],
"import/no-extraneous-dependencies": [ "import/no-extraneous-dependencies": [
"error", "error",
{ {
+1 -1
View File
@@ -1 +1 @@
v16 v20
+24 -2
View File
@@ -3,6 +3,28 @@ documents: ./src/**/*.graphql
generates: generates:
./src/types.ts: ./src/types.ts:
plugins: plugins:
- typescript - typescript:
- typescript-operations typesPrefix: Schema
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typescript-operations:
nonOptionalTypename: true
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typed-document-node - typed-document-node
+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>
+5328 -16750
View File
File diff suppressed because it is too large. Load diff
+35 -44
View File
@@ -7,63 +7,54 @@
"check-types": "tsc", "check-types": "tsc",
"dev": "vite", "dev": "vite",
"format:js": "eslint --fix src", "format:js": "eslint --fix src",
"format:styles": "stylelint --fix src", "format:styles": "prettier --write \"src/**/*.css\"",
"generate": "graphql-codegen", "generate": "node scripts/generate-types.mjs",
"lint:js": "eslint src", "lint:js": "eslint src",
"lint:styles": "stylelint src", "lint:styles": "prettier --check \"src/**/*.css\"",
"preview": "vite preview", "preview": "vite preview",
"start": "vite", "start": "vite",
"test": "jest" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.5.8", "@apollo/client": "^3.14.1",
"@nivo/bar": "^0.79.1", "@nivo/bar": "^0.99.0",
"@nivo/line": "^0.79.1", "@nivo/line": "^0.99.0",
"@nivo/pie": "^0.79.1", "@nivo/pie": "^0.99.0",
"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": "^18.3.1",
"react-dom": "^17.0.2", "react-dom": "^18.3.1",
"react-hook-form": "^7.27.1", "react-hook-form": "^7.30.0",
"react-router-dom": "^6.2.1", "react-router-dom": "^7.18.4",
"use-url-search-params": "^2.5.1" "use-url-search-params": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^2.6.1", "@graphql-codegen/core": "^6.2.2",
"@graphql-codegen/typed-document-node": "^2.2.3", "@graphql-codegen/typescript": "^6.1.2",
"@graphql-codegen/typescript-operations": "^2.3.0", "@graphql-codegen/typed-document-node": "^7.2.0",
"@graphql-codegen/typescript-operations": "^6.1.10",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.1.1",
"@types/bootstrap": "^5.1.9", "@types/react": "^18.3.28",
"@types/react": "^17.0.39", "@types/react-dom": "^18.3.7",
"@types/react-dom": "^17.0.11", "@typescript-eslint/eslint-plugin": "^8.72.0",
"@types/react-router-dom": "^5.3.3", "@typescript-eslint/parser": "^8.72.0",
"@typescript-eslint/eslint-plugin": "^5.11.0", "eslint": "^8.57.1",
"@typescript-eslint/parser": "^5.11.0", "eslint-config-prettier": "^10.1.8",
"@vitejs/plugin-react": "^1.2.0", "eslint-plugin-import": "^2.26.0",
"@vitejs/plugin-react-refresh": "^1.3.6", "eslint-plugin-prettier": "^5.5.7",
"eslint": "^8.9.0", "eslint-plugin-react": "^7.29.4",
"eslint-config-prettier": "^8.3.0", "eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-import": "^2.25.4", "prettier": "^3.6.2",
"eslint-plugin-prettier": "^4.0.0", "typescript": "^5.9.3",
"eslint-plugin-react": "^7.28.0", "vite": "^6.4.5",
"eslint-plugin-react-hooks": "^4.3.0", "vite-plugin-svgr": "^5.2.0",
"jest": "^27.5.1", "yaml": "^2.8.1"
"prettier": "^2.5.1",
"stylelint": "^14.5.0",
"stylelint-config-idiomatic-order": "^8.1.0",
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^7.0.0",
"stylelint-order": "^5.0.0",
"stylelint-prettier": "^2.0.0",
"typescript": "^4.5.5",
"vite": "^2.8.1",
"vite-plugin-svgr": "^1.0.1"
}, },
"engines": { "engines": {
"node": ">=16", "node": "20"
"npm": ">=7" },
"overrides": {
"@whatwg-node/promise-helpers": "^1.3.2"
} }
} }
+66
View File
@@ -0,0 +1,66 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { codegen } from "@graphql-codegen/core";
import { plugin as typedDocumentNodePlugin } from "@graphql-codegen/typed-document-node";
import { plugin as typescriptOperationsPlugin } from "@graphql-codegen/typescript-operations";
import { plugin as typescriptPlugin } from "@graphql-codegen/typescript";
import { buildASTSchema, parse } from "graphql";
import { parse as parseYaml } from "yaml";
const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const configPath = path.join(frontendRoot, "codegen.yml");
const config = parseYaml(await readFile(configPath, "utf8"));
const schemaDocument = parse(await readFile(path.resolve(frontendRoot, config.schema), "utf8"));
const outputEntries = Object.entries(config.generates ?? {});
if (outputEntries.length !== 1) {
throw new Error(`Expected one GraphQL Codegen output in ${configPath}`);
}
const [outputName, outputConfig] = outputEntries[0];
const outputPath = path.resolve(frontendRoot, outputName);
const sourceRoot = path.join(frontendRoot, "src");
const plugins = outputConfig.plugins.map((plugin) =>
typeof plugin === "string" ? { [plugin]: {} } : plugin,
);
async function findGraphqlFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nestedFiles = await Promise.all(
entries.map((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return findGraphqlFiles(entryPath);
return entry.isFile() && entry.name.endsWith(".graphql") ? [entryPath] : [];
}),
);
return nestedFiles.flat();
}
const documents = await Promise.all(
(await findGraphqlFiles(sourceRoot))
.sort()
.map(async (location) => ({
location,
document: parse(await readFile(location, "utf8")),
})),
);
const pluginMap = {
typescript: { plugin: typescriptPlugin },
"typescript-operations": { plugin: typescriptOperationsPlugin },
"typed-document-node": { plugin: typedDocumentNodePlugin },
};
const generated = await codegen({
filename: outputPath,
schema: schemaDocument,
schemaAst: buildASTSchema(schemaDocument),
documents,
config: {},
plugins,
pluginMap,
});
await writeFile(outputPath, generated);
+3 -1
View File
@@ -1,3 +1,5 @@
.app { .app {
margin: 16px; flex: 1 0 auto;
width: min(100% - 2rem, 1220px);
margin: 1rem auto;
} }
+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>
+33 -21
View File
@@ -1,26 +1,38 @@
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 Inventory = React.lazy(() => import("./features/inventory/Inventory"));
import { OrderStats } from "./features/stats/OrderStats"; const InventoryAdd = React.lazy(() => import("./features/inventory/InventoryAdd"));
import { ProductStats } from "./features/stats/ProductStats"; const InventoryReport = React.lazy(() => import("./features/inventory/InventoryReport"));
import { Stats } from "./features/stats/Stats"; const Log = React.lazy(() => import("./features/log/Log"));
import { UserDetail } from "./features/users/UserDetail"; const Register = React.lazy(() => import("./features/register/Register"));
import { UserList } from "./features/users/UserList"; const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
const Stats = React.lazy(() => import("./features/stats/Stats"));
const UserStats = React.lazy(() => import("./features/stats/UserStats"));
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 => (
<ReactRouterRoutes> <React.Suspense fallback={<div>Loading...</div>}>
<Route path="/deposit" element={<Deposit />} /> <ReactRouterRoutes>
<Route path="/log" element={<Log />} /> <Route path="/deposit" element={<Deposit />} />
<Route path="/stats" element={<Stats />} /> <Route path="/inventory" element={<Inventory />} />
<Route path="/stats/orders" element={<OrderStats />} /> <Route path="/inventory/add" element={<InventoryAdd />} />
<Route path="/stats/products" element={<ProductStats />} /> <Route path="/report" element={<InventoryReport />} />
<Route path="/users" element={<UserList />} /> <Route path="/log" element={<Log />} />
<Route path="/users/create" element={<UserCreate />} /> <Route path="/stats" element={<Stats />} />
<Route path="/user/:userId" element={<UserDetail />} /> <Route path="/stats/orders" element={<OrderStats />} />
<Route path="/" element={<Register />} /> <Route path="/stats/products" element={<ProductStats />} />
</ReactRouterRoutes> <Route path="/stats/users" element={<UserStats />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} />
<Route path="*" element={<Splash brandText="404" noOverlay />} />
</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.DEV ? "/graphql/" : `${import.meta.env.VITE_API_URL}/graphql/`,
cache: new InMemoryCache(), cache: new InMemoryCache(),
}); });
+20
View File
@@ -0,0 +1,20 @@
.footer {
flex-shrink: 0;
padding: 0 0 1rem;
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 Brick from "../assets/brick.svg?react";
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,11 @@
.errorMessage {
width: 100%;
margin-top: 30vh;
color: var(--brand);
font-family:
Rocket Rinder,
monospace;
font-size: 3rem;
font-style: italic;
text-align: center;
}
+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>;
};
+138 -9
View File
@@ -1,13 +1,142 @@
.logo { .header {
position: relative; width: min(100% - 2rem, 1220px);
top: -1px; max-width: 1220px;
display: inline-block; margin: 1rem auto;
width: 24px;
height: 24px;
margin: 0 8px;
} }
.navIcon { .nav {
display: flex;
min-height: 5rem;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 1rem;
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;
flex: 1;
flex-wrap: wrap;
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;
}
@media (max-width: 640px) {
.nav {
align-items: flex-start;
}
.logoNav {
margin: 0 auto;
}
.navItems {
justify-content: center;
gap: 0.25rem;
}
.navLink {
display: inline-block;
padding: 0.5rem;
font-size: 0.8rem;
}
.brand {
padding-left: 0;
font-size: 2.5rem;
}
}
/* 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;
}
} }
+47 -72
View File
@@ -1,85 +1,60 @@
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" <NavLink to="/" className={navLinkStyles} end>
data-bs-toggle="collapse" Kjøp drikke
data-bs-target="#navbarNav" </NavLink>
aria-controls="navbarNav" </li>
aria-expanded="false" <li>
aria-label="Toggle navigation" <NavLink to="/deposit" className={navLinkStyles}>
> Sett inn penger
<span className="navbar-toggler-icon" /> </NavLink>
</button> </li>
<div className="collapse navbar-collapse" id="navbarNav"> <li>
<ul className="navbar-nav"> <NavLink to="/log" className={navLinkStyles}>
<li className="nav-item"> Logg
<NavLink to="/" className={navLinkStyles} end> </NavLink>
<span className={styles.navIcon}> </li>
<House /> <li>
</span>{" "} <NavLink to="/inventory" className={navLinkStyles}>
Kjøp drikke Varebeholdning
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/deposit" className={navLinkStyles}> <NavLink to="/report" className={navLinkStyles}>
Sett inn penger Rapport
</NavLink> </NavLink>
</li> </li>
<li className="nav-item"> <li>
<NavLink to="/log" className={navLinkStyles}> <NavLink to="/stats" className={navLinkStyles}>
<span className={styles.navIcon}> Statistikk
<CardList /> </NavLink>
</span>{" "} </li>
Logg
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/stats" className={navLinkStyles}>
<span className={styles.navIcon}>
<BarChart />
</span>{" "}
Statistikk
</NavLink>
</li>
<li className="nav-item"> <li>
<NavLink to="/users" className={navLinkStyles}> <NavLink to="/users" className={navLinkStyles}>
<span className={styles.navIcon}> Folk
<PeopleIcon /> </NavLink>
</span>{" "} </li>
Folk <li>
</NavLink> <a href={ADMIN_URL} className={styles.navLink}>
</li>
</ul>
</div>
<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>
);
};
+82
View File
@@ -0,0 +1,82 @@
.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);
border-radius: 50% 50%;
margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
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;
+29
View File
@@ -2,4 +2,33 @@
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,52 @@
.decoration {
position: absolute;
z-index: -1;
top: 0;
right: 0;
left: 0;
height: 100vh;
overflow: clip;
pointer-events: none;
}
.wireframe {
position: absolute;
bottom: calc(22% - 512px);
left: calc(50% - 512px);
display: grid;
width: 1024px;
height: 1024px;
animation-duration: 10s;
animation-iteration-count: infinite;
animation-name: isometric-rotate;
animation-timing-function: linear;
grid-template-columns: repeat(8, 128px);
}
.cell {
border-right: 2px solid var(--sun);
border-bottom: 2px solid var(--sun);
}
.cell:nth-child(8n) {
border-right: none;
}
.cell:nth-child(n + 57) {
border-bottom: none;
}
@media (max-width: 640px) {
.decoration {
display: none;
}
}
@keyframes isometric-rotate {
0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
}
100% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-405deg);
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import styles from "./Wireframe.module.css";
export const Wireframe = () => (
<div className={styles.decoration}>
<div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} />
))}
</div>
</div>
);
@@ -0,0 +1,10 @@
.form {
display: grid;
max-width: 500px;
gap: 1rem;
}
.form input {
width: 100%;
min-width: 0;
}
+18 -16
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>
<label>Fornavn</label> <h2>Opprett ny</h2>
<input {...register("firstName", { required: true })} /> <form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
{errors.firstName && <span>This field is required</span>} <label>Fornavn</label>
<br /> <input {...register("firstName", { required: true })} />
<label>Etternavn</label> {errors.firstName && <span>This field is required</span>}
<input {...register("lastName", { required: true })} /> <label>Etternavn</label>
{errors.lastName && <span>This field is required</span>} <input {...register("lastName", { required: true })} />
<br /> {errors.lastName && <span>This field is required</span>}
<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,113 @@
.deposit {
display: grid;
align-items: start;
gap: 1rem 2rem;
grid-template:
"heading amount" auto
"search amount" auto
"users amount" 1fr
/ minmax(0, 1fr) minmax(260px, 320px);
}
.heading {
grid-area: heading;
margin-bottom: 0;
}
.search {
display: flex;
align-items: center;
gap: 0.75rem;
grid-area: search;
}
.search input {
width: min(100%, 420px);
}
.userList { .userList {
display: grid; display: grid;
height: 400px; max-height: min(62vh, 680px);
grid-template-columns: repeat(12, 100px); padding: 0.5rem 0.5rem 0.5rem 0;
gap: 1rem;
grid-area: users;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
overflow-y: auto; overflow-y: auto;
row-gap: 16px;
} }
.amountSection { .amountSection {
margin-top: 32px; position: sticky;
top: 1rem;
padding: 1.25rem;
border: 1px solid rgba(221, 81, 127, 0.45);
border-radius: 8px;
background: rgba(35, 15, 41, 0.84);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
grid-area: amount;
} }
.amountSection h2 {
margin-top: 0;
}
.selectedUser {
min-height: 1.5em;
margin: 0 0 1.25rem;
color: rgba(255, 255, 255, 0.78);
text-shadow: none;
}
.selectedUser strong {
color: var(--text);
}
.amountLabel {
display: block;
margin-bottom: 0.4rem;
color: var(--sun);
}
.amountInput {
display: flex;
align-items: center;
gap: 0.75rem;
}
.amountInput input {
width: 100%;
min-width: 0;
font-size: 1.8rem;
}
.amountInput span {
color: rgba(255, 255, 255, 0.8);
text-shadow: none;
}
.checkout { .checkout {
margin-top: 32px; display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
@media (max-width: 760px) {
.deposit {
gap: 0.75rem;
grid-template:
"heading" auto
"search" auto
"amount" auto
"users" auto
/ minmax(0, 1fr);
}
.amountSection {
position: static;
}
.userList {
max-height: 45vh;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
} }
+54 -30
View File
@@ -1,19 +1,20 @@
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,
setSelectedUser, toggleSelectedUser,
amount, amount,
setAmount, setAmount,
userQuery, userQuery,
setUserQuery, setUserQuery,
filteredUsers, filteredUsers,
selectedUserDetails,
reset, reset,
onSubmit, onSubmit,
} = useDeposit(); } = useDeposit();
@@ -23,45 +24,68 @@ export const Deposit: React.FC = () => {
} }
return ( return (
<div> <div className={styles.deposit}>
<section> <h2 className={styles.heading}>Hvem skal sette inn penger?</h2>
<h2>Hvem skal sette inn penger?</h2> <div className={styles.search}>
<input <input
name="userQuery" name="userQuery"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)} onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
value={userQuery} value={userQuery}
placeholder="Filtrér på navn" placeholder="Filtrér på navn"
aria-label="Filtrér personer på navn"
/> />
{userQuery !== "" && ( {userQuery !== "" && (
<button className="btn btn-link" type="button" onClick={() => setUserQuery("")}> <button type="button" onClick={() => setUserQuery("")}>
Vis alle Vis alle
</button> </button>
)} )}
<div className={styles.userList}> </div>
{filteredUsers.map((user) => ( <section className={styles.amountSection} aria-label="Innskudd">
<UserCard <h2>Hvor mye?</h2>
key={user.id} {selectedUserDetails ? (
active={selectedUser === user.id} <p className={styles.selectedUser}>
onUser={() => { Innskudd til{" "}
setSelectedUser(user.id); <strong>
}} {selectedUserDetails.firstName} {selectedUserDetails.lastName}
user={user} </strong>
/> </p>
))} ) : (
<p className={styles.selectedUser}>Velg en person fra listen</p>
)}
<label className={styles.amountLabel} htmlFor="deposit-amount">
Beløp
</label>
<div className={styles.amountInput}>
<input
id="deposit-amount"
name="amount"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="0"
/>
<span>kr</span>
</div>
<div className={styles.checkout}>
<button type="button" onClick={onSubmit} className="primary">
Sett inn
</button>
<button type="button" onClick={reset}>
Start på nytt
</button>
</div> </div>
</section> </section>
<section className={styles.amountSection}> <div className={styles.userList}>
<h2>Hvor mye?</h2> {filteredUsers.map((user) => (
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" /> <UserCard
</section> key={user.id}
<section className={styles.checkout}> active={selectedUser === user.id}
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}> onUser={() => toggleSelectedUser(user.id)}
Sett inn user={user}
</button>{" "} />
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}> ))}
Start på nytt </div>
</button>
</section>
</div> </div>
); );
}; };
export default Deposit;
+9 -4
View File
@@ -10,9 +10,13 @@ export const useDeposit = () => {
const [selectedUser, setSelectedUser] = useState(""); const [selectedUser, setSelectedUser] = useState("");
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [userQuery, setUserQuery] = useState(""); const [userQuery, setUserQuery] = useState("");
const toggleSelectedUser = (userId: string) => {
setSelectedUser((currentUserId) => (currentUserId === userId ? "" : userId));
};
const filteredUsers = (data?.allUsers || []).filter((user) => const filteredUsers = (data?.allUsers || []).filter((user) =>
`${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase()) `${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase()),
); );
const selectedUserDetails = data?.allUsers.find((user) => user.id === selectedUser);
const { showToast } = useToast(); const { showToast } = useToast();
const onSubmit = async () => { const onSubmit = async () => {
@@ -30,7 +34,7 @@ export const useDeposit = () => {
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") { if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
const { transaction } = res.data?.createDeposit; const { transaction } = res.data?.createDeposit;
showToast( showToast(
`${transaction.user.firstName} har satt inn${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.` `${transaction.user.firstName} har satt inn${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`,
); );
reset(); reset();
return; return;
@@ -42,7 +46,7 @@ export const useDeposit = () => {
if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") { if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") {
showToast( showToast(
`${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`, `${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`,
"danger" "danger",
); );
} }
}; };
@@ -55,12 +59,13 @@ export const useDeposit = () => {
return { return {
loading, loading,
selectedUser, selectedUser,
setSelectedUser, toggleSelectedUser,
amount, amount,
setAmount, setAmount,
userQuery, userQuery,
setUserQuery, setUserQuery,
filteredUsers, filteredUsers,
selectedUserDetails,
reset, reset,
onSubmit, onSubmit,
}; };
@@ -0,0 +1,71 @@
.section {
margin-top: 2rem;
}
.tableWrap {
max-width: 100%;
overflow-x: auto;
}
.tableWrap table {
width: 100%;
}
.inactive {
opacity: 0.65;
}
.transactions {
margin-top: 2rem;
}
.pagination {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.form {
display: grid;
max-width: 36rem;
gap: 1rem;
}
.form label {
display: grid;
gap: 0.35rem;
}
.form textarea,
.form select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: rgb(35, 15, 41);
color: var(--text);
font: inherit;
}
.form textarea {
min-height: 6rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
}
.report {
margin-top: 2rem;
}
.error {
color: var(--sun);
}
@media (max-width: 640px) {
.tableWrap table {
min-width: 42rem;
}
}
@@ -0,0 +1,126 @@
import { useQuery } from "@apollo/client";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { InventoryPageDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
import { formatTime } from "../../utils/time";
import styles from "./Inventory.module.css";
const PAGE_SIZE = 20;
const Inventory = () => {
const [offset, setOffset] = useState(0);
const { data, loading, error } = useQuery(InventoryPageDocument, {
variables: { limit: PAGE_SIZE, offset },
});
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const products = [...(data?.allProducts || [])].sort(
(a, b) =>
Number(b.active) - Number(a.active) || a.inventoryAmount - b.inventoryAmount || a.name.localeCompare(b.name),
);
const activeProducts = products.filter((product) => product.active);
const transactions = data?.inventoryTransactionList || [];
return (
<div>
<h2>Varebeholdning</h2>
<div className={styles.summary}>
<div>Antall aktive varer: {activeProducts.reduce((total, product) => total + product.inventoryAmount, 0)}</div>
<div>
Innkjøpsverdi:{" "}
{formatCurrency(activeProducts.reduce((total, product) => total + Number(product.wholesaleValue), 0))} kr
</div>
</div>
<section className={styles.section}>
<div className={styles.summary}>
<h3>Varer</h3>
<Link className="btn-primary" to="/inventory/add">
Registrer varetransaksjon
</Link>
<Link to="/report">Åpne rapport</Link>
</div>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Beholdning</th>
<th scope="col">Innkjøpspris</th>
<th scope="col">Verdi</th>
<th scope="col">I salg</th>
<th scope="col">Sist oppdatert</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id} className={!product.active ? styles.inactive : undefined}>
<td>{product.name}</td>
<td>{product.inventoryAmount}</td>
<td>{formatCurrency(Number(product.wholesaleUnitPrice))} kr</td>
<td>{formatCurrency(Number(product.wholesaleValue))} kr</td>
<td>{product.active ? "Ja" : "Nei"}</td>
<td>{formatTime(product.updated)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.transactions}>
<h3>Varetransaksjoner</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Når</th>
<th scope="col">Vare</th>
<th scope="col">Antall</th>
<th scope="col">Enhetspris</th>
<th scope="col">Hvem</th>
<th scope="col">Kommentar</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<tr key={transaction.id}>
<td>{formatTime(transaction.created)}</td>
<td>{transaction.product.name}</td>
<td>{transaction.amount}</td>
<td>{formatCurrency(Number(transaction.unitPrice))} kr</td>
<td>
{transaction.user.firstName} {transaction.user.lastName}
</td>
<td>{transaction.comment}</td>
</tr>
))}
{!transactions.length && (
<tr>
<td colSpan={6}>Ingen varetransaksjoner ennå.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Forrige
</button>
<button
type="button"
disabled={transactions.length < PAGE_SIZE}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Neste
</button>
</div>
</section>
</div>
);
};
export default Inventory;
@@ -0,0 +1,102 @@
import { useMutation, useQuery } from "@apollo/client";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { useToast } from "../../components/ToastProvider";
import { CreateInventoryTransactionDocument, InventoryProductsDocument } from "../../types";
import styles from "./Inventory.module.css";
const InventoryAdd = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const { data, loading, error } = useQuery(InventoryProductsDocument);
const [createInventoryTransaction, { loading: saving }] = useMutation(CreateInventoryTransactionDocument);
const [productId, setProductId] = useState("");
const [amount, setAmount] = useState("");
const [unitPrice, setUnitPrice] = useState("");
const [comment, setComment] = useState("");
const products = data?.allProducts || [];
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const result = await createInventoryTransaction({
variables: {
data: {
productId,
amount: Number(amount),
unitPrice,
comment,
},
},
});
const response = result.data?.createInventoryTransaction;
if (response?.__typename === "CreateInventoryTransactionSuccess") {
showToast(`Registrerte ${amount} stk ${response.inventoryTransaction.product.name}.`);
navigate("/inventory");
} else if (response?.__typename === "FormErrors") {
showToast(response.message, "danger");
} else {
showToast("Kunne ikke registrere varetransaksjonen.", "danger");
}
} catch (mutationError) {
showToast(
mutationError instanceof Error ? mutationError.message : "Kunne ikke registrere varetransaksjonen.",
"danger",
);
}
};
return (
<div>
<h2>Registrer varetransaksjon</h2>
<p>
Du må være innlogget for å registrere varer. <a href="/accounts/login/">Logg inn</a>.
</p>
<form className={styles.form} onSubmit={onSubmit}>
<label>
Vare
<select required value={productId} onChange={(event) => setProductId(event.target.value)}>
<option value="">Velg vare</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.name} ({product.inventoryAmount} på lager)
</option>
))}
</select>
</label>
<label>
Antall
<input required type="number" step="1" value={amount} onChange={(event) => setAmount(event.target.value)} />
</label>
<label>
Innkjøpspris per enhet (kr)
<input
required
step="0.1"
type="number"
value={unitPrice}
onChange={(event) => setUnitPrice(event.target.value)}
/>
</label>
<label>
Kommentar
<textarea value={comment} onChange={(event) => setComment(event.target.value)} />
</label>
<div className={styles.summary}>
<button type="submit" className="primary" disabled={saving || !products.length}>
{saving ? "Lagrer..." : "Registrer"}
</button>
<Link to="/inventory">Avbryt</Link>
</div>
</form>
{!products.length && <p>Ingen aktive varer å registrere.</p>}
</div>
);
};
export default InventoryAdd;
@@ -0,0 +1,138 @@
import { useQuery } from "@apollo/client";
import { format, subDays } from "date-fns";
import React, { useState } from "react";
import { GQLError } from "../../components/GQLError";
import { InventoryReportDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
import styles from "./Inventory.module.css";
const today = () => format(new Date(), "yyyy-MM-dd");
const monthAgo = () => format(subDays(new Date(), 30), "yyyy-MM-dd");
const timestampAt = (date: string, endOfDay = false) => `${date}T${endOfDay ? "23:59:59.999999" : "00:00:00"}`;
const InventoryReport = () => {
const [startDate, setStartDate] = useState(monthAgo);
const [endDate, setEndDate] = useState(today);
const [appliedRange, setAppliedRange] = useState({ startDate: monthAgo(), endDate: today() });
const invalidRange = startDate > endDate;
const { data, loading, error } = useQuery(InventoryReportDocument, {
variables: {
startTime: timestampAt(appliedRange.startDate),
endTime: timestampAt(appliedRange.endDate, true),
},
});
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!invalidRange) setAppliedRange({ startDate, endDate });
};
if (error) return <GQLError error={error} />;
const report = data?.inventoryReport;
const balanceChange = report ? Number(report.balanceAtEnd) - Number(report.balanceAtStart) : 0;
const inventoryChange = report ? Number(report.inventoryPurchases) - Number(report.productSales) : 0;
return (
<div>
<h2>Varerapport</h2>
<form className={styles.summary} onSubmit={onSubmit}>
<label>
Fra
<input type="date" required value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</label>
<label>
Til
<input type="date" required value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
<button type="submit" disabled={invalidRange}>
Oppdater
</button>
</form>
{invalidRange && <p className={styles.error}>Startdato må være før eller lik sluttdato.</p>}
{loading && <div>Loading...</div>}
{report && (
<>
<section className={styles.report}>
<h3>Beholdningsendring</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Innkjøp</th>
<th scope="col">Salg</th>
<th scope="col">Endring verdi</th>
</tr>
</thead>
<tbody>
{report.products.map((product) => (
<tr key={product.productId}>
<td>{product.productName}</td>
<td>
{formatCurrency(Number(product.purchaseValue))} kr ({product.purchaseUnits} stk)
</td>
<td>
{formatCurrency(Number(product.salesValue))} kr ({product.salesUnits} stk)
</td>
<td>{formatCurrency(Number(product.purchaseValue) - Number(product.salesValue))} kr</td>
</tr>
))}
{!report.products.length && (
<tr>
<td colSpan={4}>Ingen varetransaksjoner i perioden.</td>
</tr>
)}
<tr>
<th scope="row">Totalt</th>
<td>{formatCurrency(Number(report.inventoryPurchases))} kr</td>
<td>{formatCurrency(Number(report.productSales))} kr</td>
<td>{formatCurrency(inventoryChange)} kr</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className={styles.report}>
<h3>Innskudd og kjøp</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Post</th>
<th scope="col">Saldo</th>
<th scope="col">Innskudd</th>
<th scope="col">Kjøp</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Saldo ved periodestart</th>
<td>{formatCurrency(Number(report.balanceAtStart))} kr</td>
<td />
<td />
</tr>
<tr>
<th scope="row">Endring i perioden</th>
<td>{formatCurrency(balanceChange)} kr</td>
<td>{formatCurrency(Number(report.deposits))} kr</td>
<td>{formatCurrency(Number(report.orderPurchases))} kr</td>
</tr>
<tr>
<th scope="row">Saldo ved periodeslutt</th>
<td>{formatCurrency(Number(report.balanceAtEnd))} kr</td>
<td />
<td />
</tr>
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
};
export default InventoryReport;
@@ -0,0 +1,20 @@
mutation createInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
id
product {
name
inventoryAmount
}
}
}
... on FormErrors {
message
fields {
field
message
}
}
}
}
@@ -0,0 +1,25 @@
query inventoryPage($limit: Int = 20, $offset: Int = 0) {
allProducts {
id
name
active
inventoryAmount
updated
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{ created: DESC }], pagination: { limit: $limit, offset: $offset }) {
id
created
amount
unitPrice
comment
product {
name
}
user {
firstName
lastName
}
}
}
@@ -0,0 +1,7 @@
query inventoryProducts {
allProducts(filters: { active: true }) {
id
name
inventoryAmount
}
}
@@ -0,0 +1,18 @@
query inventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
productName
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more