Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53968fa949
|
||
|
|
d979558905
|
||
|
|
78d5b06258
|
||
|
|
4848dda4a4
|
||
|
|
1d507d1976
|
||
|
|
ec5e8dc791
|
||
|
|
ac2ceddf78
|
||
|
|
c05820f61b
|
||
|
|
4d64126581
|
||
|
|
9ad7b8aa28
|
||
|
|
64b77217f4
|
||
|
|
65e532be30
|
||
|
|
407523b61a
|
||
|
|
e23cbb1d27
|
||
|
|
098c6701f2
|
||
|
|
18da8335a8
|
||
|
|
4a36cc140c | ||
|
|
f1ea46188f | ||
|
|
dd6a559ac6 | ||
|
|
5d889ee32c | ||
|
|
2b1cdb28ea | ||
|
|
fbd55bd3a2 | ||
|
|
71f158da6e | ||
|
|
8b364b0887 | ||
|
|
28d747d74d | ||
|
|
6f964a0c57 | ||
|
|
d256e0eb7e | ||
|
|
cac189003a
|
||
|
|
ff09985944 | ||
|
|
d76c911d4b | ||
|
|
e60473e7ed | ||
|
|
d596c31353 | ||
|
|
90558a3a28 | ||
|
|
57a6c412d5 | ||
|
|
19f58211e7 | ||
|
|
0726754678 | ||
|
|
480fba870a | ||
|
|
ed08c60380 | ||
|
|
79fe5cac0f | ||
|
|
95d7d13f38 | ||
|
|
ce2cdfd409 | ||
|
|
b56d103ae7 | ||
|
|
56efe6760e | ||
|
|
ce31ef84cc | ||
|
|
491604c720 | ||
|
|
05d3dcbb3e | ||
|
|
8577fb9f11 | ||
|
|
a038340e4a | ||
|
|
34a01e9293 | ||
|
|
54dd21f1eb | ||
|
|
53ba5a05a1 | ||
|
|
f775446224 | ||
|
|
13cc0d92a2 | ||
|
|
7bfa587a77 | ||
|
|
3d91148b7e | ||
|
|
54bbd75730 | ||
|
|
06f43b06b8 | ||
|
|
ad116c2c28 | ||
|
|
eb960bdb84 | ||
|
|
f4c99153ca | ||
|
|
3e457de416 | ||
|
|
faca98b7f9 | ||
|
|
53ef262ba4 | ||
|
|
f4749e6a6e | ||
|
|
e5de16f227 | ||
|
|
eace79f012 | ||
|
|
9b903f42d6 | ||
|
|
e1eaf3df98 | ||
|
|
b79d9fc9c8 | ||
|
|
42f23b1d77 |
No files matched your search
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
+5
-1
@@ -11,4 +11,8 @@ CACHE/
|
||||
media/
|
||||
tmp/
|
||||
frontend/dist
|
||||
static/modern
|
||||
static/assets
|
||||
static/index.html
|
||||
.python-version
|
||||
.env
|
||||
.coverage
|
||||
+16
-8
@@ -2,33 +2,41 @@ variables:
|
||||
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
|
||||
|
||||
default:
|
||||
image: nikolaik/python-nodejs:python3.9-nodejs16
|
||||
image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
cache:
|
||||
paths:
|
||||
- .cache/pip
|
||||
- .venv
|
||||
before_script:
|
||||
- poetry config virtualenvs.in-project true
|
||||
- poetry install
|
||||
|
||||
django-checks:
|
||||
stage: build
|
||||
script:
|
||||
- poetry run python manage.py migrate
|
||||
- poetry run python manage.py check
|
||||
- uv run manage.py migrate
|
||||
- uv run manage.py check
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
script:
|
||||
- poetry run bin/test
|
||||
- uv run bin/test
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
script:
|
||||
- poetry run bin/lint
|
||||
- uv run bin/pre-commit
|
||||
|
||||
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:
|
||||
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
|
||||
only:
|
||||
|
||||
+10
-16
@@ -1,18 +1,12 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: local
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.11.7'
|
||||
hooks:
|
||||
- id: black
|
||||
name: black
|
||||
entry: black
|
||||
args: [--check]
|
||||
language: system
|
||||
require_serial: true
|
||||
types: [python]
|
||||
- id: isort
|
||||
name: isort
|
||||
entry: isort
|
||||
args: [--check]
|
||||
language: system
|
||||
types: [python]
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: "1.24.0"
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version, "5.2"]
|
||||
@@ -1,36 +1,47 @@
|
||||
# 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
|
||||
apt-get install libmysqlclient-dev
|
||||
poetry install
|
||||
poetry shell
|
||||
pre-commit install
|
||||
python manage.py migrate
|
||||
bin/run
|
||||
uv run manage.py migrate
|
||||
cp .env-default .env
|
||||
uv run manage.py seed_demo_data
|
||||
|
||||
# in another terminal
|
||||
cd frontend
|
||||
npm install
|
||||
npm start
|
||||
# Optional: create an admin login
|
||||
uv run manage.py createsuperuser
|
||||
|
||||
# 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
|
||||
|
||||
- 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
|
||||
|
||||
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
|
||||
|
||||
## TODO-old
|
||||
|
||||
- Larger pictures
|
||||
- Compact GUI for max 768 vertical pixels
|
||||
- Search right of in title
|
||||
|
||||
File renamed without changes.
@@ -1,13 +1,20 @@
|
||||
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):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(Product, ProductAdmin)
|
||||
admin.site.register(InventoryTransaction)
|
||||
admin.site.register(Order)
|
||||
admin.site.register(OrderLine)
|
||||
Executable → Regular
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
from django.conf import settings
|
||||
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 strawberry import ID
|
||||
|
||||
from main import models
|
||||
from main.api.errors import Error, FieldError, FormErrors
|
||||
from main.api.types.inventory import Transaction
|
||||
from main.api.types.shop import Order
|
||||
from bbs import models
|
||||
from bbs.api.errors import Error, FieldError, FormErrors
|
||||
from bbs.api.types.inventory import Transaction
|
||||
from bbs.api.types.shop import Order
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -25,16 +27,18 @@ class CreateOrderSuccess:
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class InsufficientFunds(Error):
|
||||
class InsufficientFunds(Error): # noqa: N818
|
||||
amount_lacking: float
|
||||
|
||||
|
||||
CreateOrderResponse = strawberry.union("CreateOrderResponse", [CreateOrderSuccess, InsufficientFunds, FormErrors])
|
||||
CreateOrderResponse = Annotated[
|
||||
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
|
||||
]
|
||||
|
||||
|
||||
@strawberry.mutation
|
||||
@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
|
||||
try:
|
||||
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:
|
||||
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
|
||||
@@ -89,12 +94,14 @@ class CreateDepositSuccess:
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class AboveMaxSaldo(Error):
|
||||
class AboveMaxSaldo(Error): # noqa: N818
|
||||
max_saldo: float
|
||||
above: float
|
||||
|
||||
|
||||
CreateDepositResponse = strawberry.union("CreateDepositResponse", [CreateDepositSuccess, AboveMaxSaldo, FormErrors])
|
||||
CreateDepositResponse = Annotated[
|
||||
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
|
||||
]
|
||||
|
||||
|
||||
@strawberry.mutation
|
||||
@@ -116,7 +123,7 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
|
||||
fields=[FieldError(field="amount", message=message)],
|
||||
)
|
||||
# 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(
|
||||
message="Du prøver å sette inn for mye penger",
|
||||
max_saldo=settings.BBS_SALDO_MAX,
|
||||
@@ -1,13 +1,11 @@
|
||||
import strawberry
|
||||
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 strawberry_django import auto
|
||||
from strawberry import auto
|
||||
|
||||
from main import models
|
||||
from main.api.types.users import User
|
||||
|
||||
DjangoUser = get_user_model()
|
||||
from bbs import models
|
||||
from bbs.api.types.users import User
|
||||
|
||||
|
||||
@strawberry_django.input(DjangoUser)
|
||||
@@ -19,9 +17,12 @@ class UserInput:
|
||||
|
||||
@strawberry.mutation
|
||||
@transaction.atomic
|
||||
def register_mutation(input: UserInput) -> User:
|
||||
def register_mutation(input: UserInput) -> User: # noqa: A002
|
||||
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)
|
||||
return user
|
||||
File renamed without changes.
@@ -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_django
|
||||
|
||||
from main import models
|
||||
from main.api.types.shop import Order, OrderOrdering, OrderStats, OrderStatsByTime, Point, Product, ProductOrderStat
|
||||
from bbs import models
|
||||
from bbs.api.types.shop import (
|
||||
Order,
|
||||
OrderOrdering,
|
||||
OrderStats,
|
||||
OrderStatsByTime,
|
||||
Point,
|
||||
Product,
|
||||
ProductOrderStat,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class ShopQueries:
|
||||
all_products: list[Product] = 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
|
||||
def order_stats(self) -> OrderStats:
|
||||
@@ -26,6 +34,9 @@ class ShopQueries:
|
||||
@strawberry.field
|
||||
def product_stats(self) -> list[ProductOrderStat]:
|
||||
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()
|
||||
]
|
||||
@@ -1,14 +1,15 @@
|
||||
import strawberry
|
||||
import strawberry_django
|
||||
from django.contrib.auth.models import User as DjangoUser
|
||||
from strawberry import ID
|
||||
|
||||
from main.api.types.users import User
|
||||
from bbs.api.types.users import User
|
||||
|
||||
|
||||
@strawberry.type
|
||||
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
|
||||
def user(self, user_id: ID) -> User:
|
||||
@@ -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.
@@ -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_django
|
||||
from strawberry_django import auto
|
||||
from strawberry import auto
|
||||
from strawberry.scalars import JSON
|
||||
|
||||
from main import models
|
||||
from main.api.types.utils import JSONScalar
|
||||
from bbs import models
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .users import User
|
||||
|
||||
|
||||
@strawberry_django.filters.filter(models.Product)
|
||||
@strawberry_django.filter_type(models.Product)
|
||||
class ProductFilter:
|
||||
id: auto
|
||||
active: auto
|
||||
@@ -19,11 +25,16 @@ class Product:
|
||||
image_url: str
|
||||
sale_price_int: auto
|
||||
sale_price_ext: auto
|
||||
active: auto
|
||||
inventory_amount: auto
|
||||
updated: auto
|
||||
wholesale_unit_price: Decimal
|
||||
wholesale_value: Decimal
|
||||
# FIXME: stricter types
|
||||
user_counts: JSONScalar
|
||||
user_counts: JSON
|
||||
|
||||
|
||||
@strawberry_django.ordering.order(models.Order)
|
||||
@strawberry_django.order_type(models.Order)
|
||||
class OrderOrdering:
|
||||
created: auto
|
||||
|
||||
@@ -31,7 +42,7 @@ class OrderOrdering:
|
||||
@strawberry_django.type(models.Order)
|
||||
class Order:
|
||||
id: auto
|
||||
customer: strawberry.LazyType["User", "main.api.types.users"]
|
||||
customer: Annotated["User", strawberry.lazy(".users")] | None
|
||||
order_sum: auto
|
||||
created: auto
|
||||
orderlines: list["OrderLine"]
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class BBSAppConfig(AppConfig):
|
||||
name = "main"
|
||||
name = "bbs"
|
||||
verbose_name = "Biceps Bar System"
|
||||
|
||||
def ready(self):
|
||||
@@ -1,14 +1,22 @@
|
||||
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):
|
||||
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):
|
||||
# Only show active products
|
||||
super(InventoryTransactionForm, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["product"].queryset = Product.objects.filter(active=True)
|
||||
|
||||
class Meta:
|
||||
File renamed without changes.
File renamed without changes.
+5
-5
@@ -1,10 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand, CommandParser
|
||||
from strawberry.printer import print_schema
|
||||
|
||||
from bbssite.settings import BASE_DIR
|
||||
from main.api.schema import schema as schema_symbol
|
||||
from bbs.api.schema import schema as schema_symbol
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -13,11 +13,11 @@ class Command(BaseCommand):
|
||||
def add_arguments(self, parser: CommandParser):
|
||||
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)
|
||||
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)
|
||||
self.stdout.write(self.style.SUCCESS("OK"))
|
||||
else:
|
||||
print(schema_output)
|
||||
pass
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
|
||||
from main.models import UserProfile
|
||||
from bbs.models import UserProfile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -10,11 +10,10 @@ class Command(BaseCommand):
|
||||
parser.add_argument("--dry-run", 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"):
|
||||
if dry_run:
|
||||
print(profile.user.email, profile.image_url())
|
||||
else:
|
||||
if force or not profile.image:
|
||||
pass
|
||||
elif force or not profile.image:
|
||||
profile.image = profile.image_url()
|
||||
profile.save()
|
||||
@@ -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):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
@@ -72,7 +71,7 @@ class Migration(migrations.Migration):
|
||||
models.ForeignKey(
|
||||
on_delete=models.CASCADE,
|
||||
related_name="orderlines",
|
||||
to="main.Order",
|
||||
to="bbs.Order",
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -157,7 +156,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name="orderline",
|
||||
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(
|
||||
model_name="inventorytransaction",
|
||||
@@ -166,7 +165,7 @@ class Migration(migrations.Migration):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="transactions",
|
||||
verbose_name="Produkt",
|
||||
to="main.Product",
|
||||
to="bbs.Product",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -180,6 +179,6 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
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.
@@ -1,7 +1,7 @@
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
import hashlib # noqa: EXE002
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from typing import Type
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -17,6 +17,8 @@ from django.db.models.functions import (
|
||||
)
|
||||
|
||||
EXTERNAL_USER = "Ekstern"
|
||||
MAX_DIGITS = 9
|
||||
DECIMAL_PLACES = 1
|
||||
|
||||
|
||||
def _format_counts(per_user):
|
||||
@@ -36,14 +38,15 @@ def _format_counts(per_user):
|
||||
|
||||
class Product(models.Model):
|
||||
name = models.CharField(max_length=64)
|
||||
sale_price_int = models.FloatField()
|
||||
sale_price_ext = models.FloatField()
|
||||
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
||||
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
||||
description = models.CharField(max_length=256, blank=True)
|
||||
volume_liter = models.FloatField(null=True)
|
||||
alcohol_percent = models.FloatField(null=True)
|
||||
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
|
||||
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
|
||||
image = models.ImageField(upload_to="products", 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)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
@@ -83,21 +86,26 @@ class Product(models.Model):
|
||||
|
||||
return _format_counts(per_user)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self): # noqa: DJ012
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
class Meta: # noqa: DJ012
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
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")
|
||||
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
|
||||
|
||||
def count_by_created_period(self, period: str):
|
||||
periods = {"yearly": ExtractYear, "monthly": ExtractMonth, "daily": ExtractIsoWeekDay, "hourly": ExtractHour}
|
||||
assert period in periods
|
||||
periods = {
|
||||
"yearly": ExtractYear,
|
||||
"monthly": ExtractMonth,
|
||||
"daily": ExtractIsoWeekDay,
|
||||
"hourly": ExtractHour,
|
||||
}
|
||||
assert period in periods # noqa: S101
|
||||
return self.count_by_created(periods[period])
|
||||
|
||||
|
||||
@@ -109,7 +117,7 @@ class Order(models.Model):
|
||||
blank=True,
|
||||
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)
|
||||
|
||||
objects = OrderManager()
|
||||
@@ -120,7 +128,7 @@ class Order(models.Model):
|
||||
def reciept(self):
|
||||
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"
|
||||
|
||||
|
||||
@@ -146,10 +154,12 @@ class OrderLineManager(models.QuerySet):
|
||||
|
||||
|
||||
class OrderLine(models.Model):
|
||||
order = models.ForeignKey("main.Order", on_delete=models.CASCADE, related_name="orderlines")
|
||||
product = models.ForeignKey("main.Product", on_delete=models.CASCADE, related_name="orderlines")
|
||||
order = models.ForeignKey("bbs.Order", 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()
|
||||
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()
|
||||
|
||||
@@ -157,10 +167,10 @@ class OrderLine(models.Model):
|
||||
def price(self):
|
||||
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)"
|
||||
|
||||
class Meta:
|
||||
class Meta: # noqa: DJ012
|
||||
unique_together = ("order", "product")
|
||||
|
||||
|
||||
@@ -168,7 +178,7 @@ class Transaction(models.Model):
|
||||
"""Money"""
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
@@ -184,13 +194,15 @@ class InventoryTransaction(models.Model):
|
||||
related_name="inventory_transactions",
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
"main.Product",
|
||||
"bbs.Product",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="transactions",
|
||||
verbose_name="Produkt",
|
||||
)
|
||||
amount = models.FloatField(verbose_name="Antall")
|
||||
unit_price = models.FloatField(verbose_name="Enhetspris")
|
||||
# FIXME: rename to quantity
|
||||
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)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -198,13 +210,13 @@ class InventoryTransaction(models.Model):
|
||||
def price(self):
|
||||
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)"
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
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)
|
||||
|
||||
def last_purchase(self):
|
||||
@@ -212,8 +224,7 @@ class UserProfile(models.Model):
|
||||
|
||||
def last_purchase_date(self) -> datetime:
|
||||
last = self.last_purchase()
|
||||
last_p = last.created if last else datetime.min
|
||||
return last_p
|
||||
return last.created if last else datetime.min.replace(tzinfo=UTC)
|
||||
|
||||
def top_months(self, limit=5):
|
||||
return (
|
||||
@@ -227,21 +238,21 @@ class UserProfile(models.Model):
|
||||
def product_order_stats(self):
|
||||
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
|
||||
|
||||
def image_url(self) -> str:
|
||||
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 ""
|
||||
|
||||
def save(self, **kwargs):
|
||||
def save(self, **kwargs): # noqa: DJ012
|
||||
if not self.image:
|
||||
self.image = self.image_url()
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self): # noqa: DJ012
|
||||
return f"{self.user}"
|
||||
@@ -1,12 +1,13 @@
|
||||
# Django settings for bbs project.
|
||||
# Django settings for bbs project. # noqa: EXE002
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ADMINS = (
|
||||
# ('Your Name', 'your_email@example.com'),
|
||||
# ('Your Name', 'your_email@example.com'), # noqa: ERA001
|
||||
)
|
||||
MANAGERS = ADMINS
|
||||
|
||||
@@ -20,18 +21,18 @@ DATABASES = {
|
||||
TIME_ZONE = "Europe/Oslo"
|
||||
LANGUAGE_CODE = "nb"
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
# Media and uploads
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
# Static files
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
|
||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
|
||||
|
||||
STATICFILES_FINDERS = (
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
@@ -39,13 +40,13 @@ STATICFILES_FINDERS = (
|
||||
)
|
||||
|
||||
# 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 = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [
|
||||
os.path.join(BASE_DIR, "templates"),
|
||||
os.path.join(BASE_DIR, "templates"), # noqa: PTH118
|
||||
],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
@@ -72,7 +73,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "bbssite.urls"
|
||||
ROOT_URLCONF = "bbs.urls"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
@@ -85,9 +86,10 @@ INSTALLED_APPS = [
|
||||
"django_extensions",
|
||||
"easy_thumbnails",
|
||||
"corsheaders",
|
||||
"django_rename_app",
|
||||
]
|
||||
LOCAL_APPS = [
|
||||
"main",
|
||||
"bbs",
|
||||
]
|
||||
INSTALLED_APPS += LOCAL_APPS
|
||||
|
||||
@@ -96,7 +98,7 @@ LOGIN_REDIRECT_URL = "/"
|
||||
SESSION_COOKIE_AGE = 1209600 # 15 minutes
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
BBS_SALDO_MAX = 600
|
||||
BBS_SALDO_MAX = 1000
|
||||
BBS_LIMIT_DEPOSITS = False
|
||||
|
||||
# Thumbnails
|
||||
@@ -140,7 +142,5 @@ CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
]
|
||||
|
||||
try:
|
||||
from .local_settings import *
|
||||
except ImportError:
|
||||
pass
|
||||
with contextlib.suppress(ImportError):
|
||||
from .local_settings import * # noqa: F403
|
||||
File renamed without changes.
@@ -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
|
||||
@@ -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 == ""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
File renamed without changes.
File renamed without changes.
@@ -1,21 +1,22 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from main.forms import DateRangeForm, InventoryTransactionForm
|
||||
from main.models import InventoryTransaction, Order, Product, Transaction, UserProfile
|
||||
from bbs.forms import DateRangeForm, InventoryTransactionForm
|
||||
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
|
||||
|
||||
|
||||
def report(request):
|
||||
# FIXME: split this up in smaller pieces
|
||||
datetime_now = datetime.now()
|
||||
start_time = datetime.now() - timedelta(days=30)
|
||||
datetime_now = timezone.localtime()
|
||||
start_time = timezone.localtime() - timedelta(days=30)
|
||||
# start of day, 1 month
|
||||
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# 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):
|
||||
form = DateRangeForm(request.GET)
|
||||
@@ -50,14 +51,14 @@ def report(request):
|
||||
k: {
|
||||
"name": k,
|
||||
"transactions": group,
|
||||
"transactions_sum": sum(map(lambda x: x["transactions__price"], group)),
|
||||
"transactions_units": sum(map(lambda x: x["transactions__amount"], group)),
|
||||
"transactions_sum": sum(x["transactions__price"] for x in group),
|
||||
"transactions_units": sum(x["transactions__amount"] for x in group),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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_products = (
|
||||
@@ -75,8 +76,8 @@ def report(request):
|
||||
product = {
|
||||
"name": k,
|
||||
"orderlines": group,
|
||||
"orderlines_sum": sum(map(lambda x: x["orderlines__price"], group)),
|
||||
"orderlines_units": sum(map(lambda x: x["orderlines__amount"], group)),
|
||||
"orderlines_sum": sum(x["orderlines__price"] for x in group),
|
||||
"orderlines_units": sum(x["orderlines__amount"] for x in group),
|
||||
}
|
||||
if k in inventory_products:
|
||||
# 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]["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
|
||||
|
||||
@@ -110,7 +111,7 @@ def report(request):
|
||||
purchases_diff = purchases_end - purchases_start
|
||||
saldo_diff = saldo_end - saldo_start
|
||||
|
||||
return render(request, "report.html", locals())
|
||||
return render(request, "report.html", locals()) # noqa: DJ003
|
||||
|
||||
|
||||
def inventory(request):
|
||||
@@ -120,7 +121,7 @@ def inventory(request):
|
||||
wholesale_value = sum([p.wholesale_value for p in products_active])
|
||||
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):
|
||||
@@ -133,13 +134,12 @@ def inventory_add(request):
|
||||
itrans.user = request.user
|
||||
itrans.product.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")
|
||||
|
||||
else:
|
||||
messages.error(request, "Feil med skjemaet, se under.")
|
||||
form = InventoryTransactionForm(data=request.POST)
|
||||
else:
|
||||
form = InventoryTransactionForm()
|
||||
|
||||
return render(request, "inventory_add.html", locals())
|
||||
return render(request, "inventory_add.html", locals()) # noqa: DJ003
|
||||
@@ -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")
|
||||
@@ -2,6 +2,6 @@ import os
|
||||
|
||||
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()
|
||||
@@ -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)
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
+16
-6
@@ -1,14 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
PROJECT_PATH=/var/www/neuf.no/bbs
|
||||
PROCESS_NAME="$PROJECT.neuf.no"
|
||||
source "$HOME/.local/bin/env"
|
||||
|
||||
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
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
SRC_DIR="main bbssite"
|
||||
|
||||
black $SRC_DIR
|
||||
isort $SRC_DIR
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
SRC_DIR="main bbssite"
|
||||
|
||||
black --check $SRC_DIR
|
||||
isort --diff $SRC_DIR
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
uvx pre-commit run --all-files
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
python manage.py runserver
|
||||
exec python manage.py runserver 127.0.0.1:3000
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
cd frontend && npm run start
|
||||
#!/bin/bash
|
||||
|
||||
cd frontend && npm run start -- --host 127.0.0.1
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"import/no-unresolved": ["error", { ignore: ["\\?react$"] }],
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v16
|
||||
v20
|
||||
+24
-2
@@ -3,6 +3,28 @@ documents: ./src/**/*.graphql
|
||||
generates:
|
||||
./src/types.ts:
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- typescript:
|
||||
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
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>BBS</title>
|
||||
</head>
|
||||
|
||||
Generated
+5302
-16724
File diff suppressed because it is too large.
Load diff
+35
-44
@@ -7,63 +7,54 @@
|
||||
"check-types": "tsc",
|
||||
"dev": "vite",
|
||||
"format:js": "eslint --fix src",
|
||||
"format:styles": "stylelint --fix src",
|
||||
"generate": "graphql-codegen",
|
||||
"format:styles": "prettier --write \"src/**/*.css\"",
|
||||
"generate": "node scripts/generate-types.mjs",
|
||||
"lint:js": "eslint src",
|
||||
"lint:styles": "stylelint src",
|
||||
"lint:styles": "prettier --check \"src/**/*.css\"",
|
||||
"preview": "vite preview",
|
||||
"start": "vite",
|
||||
"test": "jest"
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.5.8",
|
||||
"@nivo/bar": "^0.79.1",
|
||||
"@nivo/line": "^0.79.1",
|
||||
"@nivo/pie": "^0.79.1",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bootstrap-icons": "^1.8.1",
|
||||
"@apollo/client": "^3.14.1",
|
||||
"@nivo/bar": "^0.99.0",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@nivo/pie": "^0.99.0",
|
||||
"clsx": "^1.1.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"graphql": "^16.3.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.27.1",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.30.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"use-url-search-params": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.6.1",
|
||||
"@graphql-codegen/typed-document-node": "^2.2.3",
|
||||
"@graphql-codegen/typescript-operations": "^2.3.0",
|
||||
"@graphql-codegen/core": "^6.2.2",
|
||||
"@graphql-codegen/typescript": "^6.1.2",
|
||||
"@graphql-codegen/typed-document-node": "^7.2.0",
|
||||
"@graphql-codegen/typescript-operations": "^6.1.10",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@types/bootstrap": "^5.1.9",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.11.0",
|
||||
"@typescript-eslint/parser": "^5.11.0",
|
||||
"@vitejs/plugin-react": "^1.2.0",
|
||||
"@vitejs/plugin-react-refresh": "^1.3.6",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"jest": "^27.5.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"
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.5",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
"npm": ">=7"
|
||||
"node": "20"
|
||||
},
|
||||
"overrides": {
|
||||
"@whatwg-node/promise-helpers": "^1.3.2"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -1,3 +1,5 @@
|
||||
.app {
|
||||
margin: 16px;
|
||||
flex: 1 0 auto;
|
||||
width: min(100% - 2rem, 1220px);
|
||||
margin: 1rem auto;
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
import styles from "./App.module.css";
|
||||
import { Routes } from "./Routes";
|
||||
import client from "./client";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Header } from "./components/Header";
|
||||
import { Toast } from "./components/Toast";
|
||||
import { ToastProvider } from "./components/ToastProvider";
|
||||
import { Wireframe } from "./components/Wireframe";
|
||||
|
||||
const App: React.FC = () => (
|
||||
const App = (): JSX.Element => (
|
||||
<ApolloProvider client={client}>
|
||||
<Router>
|
||||
<ToastProvider>
|
||||
<Header />
|
||||
<div className={styles.app}>
|
||||
<Routes />
|
||||
<Wireframe />
|
||||
</div>
|
||||
<Footer />
|
||||
<Toast />
|
||||
</ToastProvider>
|
||||
</Router>
|
||||
|
||||
+22
-10
@@ -1,26 +1,38 @@
|
||||
import React from "react";
|
||||
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
|
||||
|
||||
import { UserCreate } from "./features/auth/UserCreate";
|
||||
import { Deposit } from "./features/deposit/Deposit";
|
||||
import { Log } from "./features/log/Log";
|
||||
import { Register } from "./features/register/Register";
|
||||
import { OrderStats } from "./features/stats/OrderStats";
|
||||
import { ProductStats } from "./features/stats/ProductStats";
|
||||
import { Stats } from "./features/stats/Stats";
|
||||
import { UserDetail } from "./features/users/UserDetail";
|
||||
import { UserList } from "./features/users/UserList";
|
||||
const Splash = React.lazy(() => import("./components/Splash"));
|
||||
const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
|
||||
const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
|
||||
const Inventory = React.lazy(() => import("./features/inventory/Inventory"));
|
||||
const InventoryAdd = React.lazy(() => import("./features/inventory/InventoryAdd"));
|
||||
const InventoryReport = React.lazy(() => import("./features/inventory/InventoryReport"));
|
||||
const Log = React.lazy(() => import("./features/log/Log"));
|
||||
const Register = React.lazy(() => import("./features/register/Register"));
|
||||
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 => (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<ReactRouterRoutes>
|
||||
<Route path="/deposit" element={<Deposit />} />
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
<Route path="/inventory/add" element={<InventoryAdd />} />
|
||||
<Route path="/report" element={<InventoryReport />} />
|
||||
<Route path="/log" element={<Log />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/stats/orders" element={<OrderStats />} />
|
||||
<Route path="/stats/products" element={<ProductStats />} />
|
||||
<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,7 +1,7 @@
|
||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -1,13 +1,142 @@
|
||||
.logo {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 8px;
|
||||
.header {
|
||||
width: min(100% - 2rem, 1220px);
|
||||
max-width: 1220px;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,60 @@
|
||||
import { ReactComponent as BarChart } from "bootstrap-icons/icons/bar-chart.svg";
|
||||
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 clsx from "clsx";
|
||||
import React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
import { ReactComponent as Logo } from "../assets/brick.svg";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
const ADMIN_URL = import.meta.env.VITE_API_URL
|
||||
? `${import.meta.env.VITE_API_URL}/admin`
|
||||
: "http://localhost:8000/admin/";
|
||||
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/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 (
|
||||
<header>
|
||||
<nav className="navbar navbar-dark bg-dark navbar-expand-md">
|
||||
<NavLink to="/" className="navbar-brand">
|
||||
<span className={styles.logo}>
|
||||
<Logo />
|
||||
</span>
|
||||
Biceps Bar System - BBS
|
||||
<header className={styles.header}>
|
||||
<nav className={styles.nav}>
|
||||
<NavLink to="/" className={styles.logoNav}>
|
||||
<div className={styles.brand}>BBS</div>
|
||||
<div className={styles.subtitle}>Biceps Bar System</div>
|
||||
</NavLink>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span className="navbar-toggler-icon" />
|
||||
</button>
|
||||
<div className="collapse navbar-collapse" id="navbarNav">
|
||||
<ul className="navbar-nav">
|
||||
<li className="nav-item">
|
||||
<ul className={styles.navItems}>
|
||||
<li>
|
||||
<NavLink to="/" className={navLinkStyles} end>
|
||||
<span className={styles.navIcon}>
|
||||
<House />
|
||||
</span>{" "}
|
||||
Kjøp drikke
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<li>
|
||||
<NavLink to="/deposit" className={navLinkStyles}>
|
||||
Sett inn penger
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<li>
|
||||
<NavLink to="/log" className={navLinkStyles}>
|
||||
<span className={styles.navIcon}>
|
||||
<CardList />
|
||||
</span>{" "}
|
||||
Logg
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<li>
|
||||
<NavLink to="/inventory" className={navLinkStyles}>
|
||||
Varebeholdning
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink to="/report" className={navLinkStyles}>
|
||||
Rapport
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink to="/stats" className={navLinkStyles}>
|
||||
<span className={styles.navIcon}>
|
||||
<BarChart />
|
||||
</span>{" "}
|
||||
Statistikk
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
<li className="nav-item">
|
||||
<li>
|
||||
<NavLink to="/users" className={navLinkStyles}>
|
||||
<span className={styles.navIcon}>
|
||||
<PeopleIcon />
|
||||
</span>{" "}
|
||||
Folk
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="navbar-nav">
|
||||
<li className="nav-item">
|
||||
<a href={ADMIN_URL} className="nav-link">
|
||||
<li>
|
||||
<a href={ADMIN_URL} className={styles.navLink}>
|
||||
Admin
|
||||
</a>
|
||||
</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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -2,4 +2,33 @@
|
||||
position: absolute;
|
||||
bottom: 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;
|
||||
}
|
||||
@@ -1,41 +1,18 @@
|
||||
import { Toast as BSToast } from "bootstrap";
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
|
||||
import styles from "./Toast.module.css";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
export const Toast: React.FC = () => {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const { visible, showToast, message, background } = useToast();
|
||||
export const Toast = (): JSX.Element => {
|
||||
const { showToast, message, background, visible } = useToast();
|
||||
|
||||
const backgroundColor = `bg-${background}`;
|
||||
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]);
|
||||
const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classes} role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div className="d-flex">
|
||||
<div className="toast-body">{message}</div>
|
||||
<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 className={classes} role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div>{message}</div>
|
||||
<button type="button" aria-label="Close" onClick={() => showToast(message)} className={styles.close} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,24 @@
|
||||
import React, { ReactNode, useContext, useState } from "react";
|
||||
|
||||
type Background = "primary" | "danger";
|
||||
|
||||
interface ToastProps {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
showToast: (message: string, background?: string) => void;
|
||||
showToast: (message: string, background?: Background) => void;
|
||||
hide: () => void;
|
||||
background: string;
|
||||
background: Background;
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
// FIXME: revert
|
||||
visible: false,
|
||||
message: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
showToast: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
hide: () => {},
|
||||
background: "primary",
|
||||
background: "primary" as Background,
|
||||
};
|
||||
|
||||
const ToastContext = React.createContext<ToastProps>(defaultState);
|
||||
@@ -23,19 +26,18 @@ const ToastContext = React.createContext<ToastProps>(defaultState);
|
||||
type ToastProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
|
||||
const [visible, setVisible] = useState(defaultState.visible);
|
||||
const [message, setMessage] = useState("");
|
||||
const [background, setBackground] = useState("");
|
||||
const [message, setMessage] = useState(defaultState.message);
|
||||
const [background, setBackground] = useState(defaultState.background);
|
||||
|
||||
const showToast = (newMessage: string, background = "primary") => {
|
||||
const showToast = (newMessage: string, background: Background = "primary") => {
|
||||
setMessage(newMessage);
|
||||
setBackground(background);
|
||||
if (!visible) {
|
||||
setVisible(true);
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
setMessage("");
|
||||
}, 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import React from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import { CreateUserDocument } from "../../types";
|
||||
import styles from "./UserCreate.module.css";
|
||||
|
||||
type FormValues = {
|
||||
firstName: string;
|
||||
@@ -10,7 +11,7 @@ type FormValues = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const UserCreate = () => {
|
||||
const UserCreate = () => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -30,20 +31,21 @@ export const UserCreate = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<h2>Opprett ny</h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
<label>Fornavn</label>
|
||||
<input {...register("firstName", { required: true })} />
|
||||
{errors.firstName && <span>This field is required</span>}
|
||||
<br />
|
||||
<label>Etternavn</label>
|
||||
<input {...register("lastName", { required: true })} />
|
||||
{errors.lastName && <span>This field is required</span>}
|
||||
<br />
|
||||
<label>E-post</label>
|
||||
<input type="email" {...register("email", { required: true })} />
|
||||
{errors.email && <span>This field is required</span>}
|
||||
<br />
|
||||
<input type="submit" disabled={creating} />
|
||||
<input type="submit" disabled={creating} className="btn-primary" />
|
||||
</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 {
|
||||
display: grid;
|
||||
height: 400px;
|
||||
grid-template-columns: repeat(12, 100px);
|
||||
max-height: min(62vh, 680px);
|
||||
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;
|
||||
row-gap: 16px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
import { UserCard } from "../../components/UserCard";
|
||||
import { UserCard } from "../users/UserCard";
|
||||
import styles from "./Deposit.module.css";
|
||||
import { useDeposit } from "./useDeposit";
|
||||
|
||||
export const Deposit: React.FC = () => {
|
||||
const Deposit = (): JSX.Element => {
|
||||
const {
|
||||
loading,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
toggleSelectedUser,
|
||||
amount,
|
||||
setAmount,
|
||||
userQuery,
|
||||
setUserQuery,
|
||||
filteredUsers,
|
||||
selectedUserDetails,
|
||||
reset,
|
||||
onSubmit,
|
||||
} = useDeposit();
|
||||
@@ -23,45 +24,68 @@ export const Deposit: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
<h2>Hvem skal sette inn penger?</h2>
|
||||
<div className={styles.deposit}>
|
||||
<h2 className={styles.heading}>Hvem skal sette inn penger?</h2>
|
||||
<div className={styles.search}>
|
||||
<input
|
||||
name="userQuery"
|
||||
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
|
||||
value={userQuery}
|
||||
placeholder="Filtrér på navn"
|
||||
aria-label="Filtrér personer på navn"
|
||||
/>
|
||||
{userQuery !== "" && (
|
||||
<button className="btn btn-link" type="button" onClick={() => setUserQuery("")}>
|
||||
<button type="button" onClick={() => setUserQuery("")}>
|
||||
Vis alle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<section className={styles.amountSection} aria-label="Innskudd">
|
||||
<h2>Hvor mye?</h2>
|
||||
{selectedUserDetails ? (
|
||||
<p className={styles.selectedUser}>
|
||||
Innskudd til{" "}
|
||||
<strong>
|
||||
{selectedUserDetails.firstName} {selectedUserDetails.lastName}
|
||||
</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>
|
||||
</section>
|
||||
<div className={styles.userList}>
|
||||
{filteredUsers.map((user) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
active={selectedUser === user.id}
|
||||
onUser={() => {
|
||||
setSelectedUser(user.id);
|
||||
}}
|
||||
onUser={() => toggleSelectedUser(user.id)}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.amountSection}>
|
||||
<h2>Hvor mye?</h2>
|
||||
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" />
|
||||
</section>
|
||||
<section className={styles.checkout}>
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}>
|
||||
Sett inn
|
||||
</button>{" "}
|
||||
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}>
|
||||
Start på nytt
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Deposit;
|
||||
@@ -10,9 +10,13 @@ export const useDeposit = () => {
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const toggleSelectedUser = (userId: string) => {
|
||||
setSelectedUser((currentUserId) => (currentUserId === userId ? "" : userId));
|
||||
};
|
||||
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 onSubmit = async () => {
|
||||
@@ -30,7 +34,7 @@ export const useDeposit = () => {
|
||||
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
|
||||
const { transaction } = res.data?.createDeposit;
|
||||
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();
|
||||
return;
|
||||
@@ -42,7 +46,7 @@ export const useDeposit = () => {
|
||||
if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") {
|
||||
showToast(
|
||||
`${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 {
|
||||
loading,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
toggleSelectedUser,
|
||||
amount,
|
||||
setAmount,
|
||||
userQuery,
|
||||
setUserQuery,
|
||||
filteredUsers,
|
||||
selectedUserDetails,
|
||||
reset,
|
||||
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
Reference in new issue
Block a user