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

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

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

No files matched your search

+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000
+19
View File
@@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."
+4 -1
View File
@@ -11,4 +11,7 @@ CACHE/
media/
tmp/
frontend/dist
static/modern
static/assets
static/index.html
.python-version
.env
+16 -8
View File
@@ -2,33 +2,41 @@ variables:
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
default:
image: nikolaik/python-nodejs:python3.9-nodejs16
image: nikolaik/python-nodejs:python3.9-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:
+11 -17
View File
@@ -1,18 +1,12 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: local
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]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.11.7'
hooks:
- 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, "4.2"]
+6 -9
View File
@@ -4,11 +4,10 @@ Before starting, make sure you have a working python and nodejs setup with pyenv
```bash
apt-get install libmysqlclient-dev
poetry install
poetry shell
pre-commit install
python manage.py migrate
bin/run
uv run manage.py migrate
uv run bin/run
cp .env-default .env
# in another terminal
cd frontend
@@ -18,19 +17,17 @@ npm start
## References
- We use JS code generation to work with queries/mutations in our graphql API: https://github.com/dotansimha/graphql-typed-document-node
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
## TODO
- update emails
- improve product listing (light purple background)
- Stats: products over time
- Light on dark colors
- inventory views
- Stats: Implement users tab
## TODO-old
- Larger pictures
- Compact GUI for max 768 vertical pixels
- Search right of in title
File renamed without changes.
+9 -2
View File
@@ -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)
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,3 +1,5 @@
from typing import Annotated, Union
import strawberry
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.
@@ -1,9 +1,9 @@
import strawberry
import strawberry_django
from main.api.types.inventory import Transaction, TransactionOrdering
from bbs.api.types.inventory import Transaction, TransactionOrdering
@strawberry.type
class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, order=TransactionOrdering)
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
@@ -1,15 +1,23 @@
import strawberry
import strawberry_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()
]
@@ -3,7 +3,7 @@ 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
+34
View File
@@ -0,0 +1,34 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension
from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations
from bbs.api.queries.inventory import InventoryQueries
from bbs.api.queries.shop import ShopQueries
from bbs.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
class HandleNotFound(SchemaExtension):
def on_request_end(self):
result = self.execution_context.result
if result.errors:
for error in result.errors:
if isinstance(error.original_error, ObjectDoesNotExist):
error.extensions = {
"code": "NOT_FOUND",
"model": type(error.original_error).__qualname__.replace(".DoesNotExist", ""),
}
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=[HandleNotFound])
File renamed without changes.
+23
View File
@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import auto
from bbs import models
if TYPE_CHECKING:
from .users import User
@strawberry_django.order_type(models.Transaction)
class TransactionOrdering:
created: auto
@strawberry_django.type(models.Transaction)
class Transaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
amount: auto
created: auto
@@ -1,12 +1,17 @@
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry_django import auto
from strawberry import auto
from main import models
from main.api.types.utils import JSONScalar
from bbs import models
from bbs.api.types.utils import JSONScalar
if TYPE_CHECKING:
from .users import User
@strawberry_django.filters.filter(models.Product)
@strawberry_django.filter_type(models.Product)
class ProductFilter:
id: auto
active: auto
@@ -23,7 +28,7 @@ class Product:
user_counts: JSONScalar
@strawberry_django.ordering.order(models.Order)
@strawberry_django.order_type(models.Order)
class OrderOrdering:
created: auto
@@ -31,7 +36,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")]
order_sum: auto
created: auto
orderlines: list["OrderLine"]
@@ -1,14 +1,13 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.contrib.auth import get_user_model
from strawberry_django import auto
from django.contrib.auth.models import User as DjangoUser
from strawberry import auto
from main import models
from main.api.types.shop import Point, ProductOrderStat
UserModel = get_user_model()
from bbs import models
from bbs.api.types.shop import Point, ProductOrderStat
@strawberry.type
@@ -24,7 +23,7 @@ class UserProfile:
balance: auto
image: auto
last_purchase_date: datetime
order_sum_total: float
order_sum_total: Decimal
@strawberry.field()
def top_months(self) -> list[TopMonth]:
@@ -33,12 +32,15 @@ class UserProfile:
@strawberry.field()
def product_order_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 self.product_order_stats().as_series()
]
@strawberry_django.type(UserModel)
@strawberry_django.type(DjangoUser)
class User:
id: auto
first_name: auto
File renamed without changes.
+1 -1
View File
@@ -2,7 +2,7 @@ from django.apps import AppConfig
class BBSAppConfig(AppConfig):
name = "main"
name = "bbs"
verbose_name = "Biceps Bar System"
def ready(self):
+11 -3
View File
@@ -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.
@@ -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
@@ -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:
profile.image = profile.image_url()
profile.save()
pass
elif force or not profile.image:
profile.image = profile.image_url()
profile.save()
@@ -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.
+43 -32
View File
@@ -1,7 +1,7 @@
import hashlib
import hashlib # noqa: EXE002
from datetime import 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,11 +178,11 @@ 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):
return f"{self.id}: { self.user}: {self.amount}"
return f"{self.id}: {self.user}: {self.amount}"
class InventoryTransaction(models.Model):
@@ -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 # noqa: DTZ901
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}"
+16 -16
View File
@@ -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
@@ -1,23 +1,27 @@
import pytest
import pytest # noqa: INP001
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from main.models import Order, Product
from bbs.models import Order, Product
@pytest.fixture(name="gql_client")
def gql_client_fixture(client: Client):
yield GraphQLTestClient(client)
return GraphQLTestClient(client)
@pytest.fixture(name="product")
def product_fix(db):
def product_fix(db): # noqa: ARG001
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
def test_create_order(product, gql_client):
assert Order.objects.count() == 0
variables = dict(customerId="external", isExternal=True, orderLines=[{"productId": product.id, "amount": 1}])
variables = {
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
+30
View File
@@ -0,0 +1,30 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from strawberry.django.views import GraphQLView
from bbs.api.schema import schema
from bbs.views.reports import inventory, inventory_add, report
admin.autodiscover()
urlpatterns = [
# TODO: migrate reports to SPA
path("inventory/", inventory, name="inventory"),
path("inventory/add/", inventory_add, name="inventory-add"),
path("report", report, name="report"),
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path(
"accounts/logout/",
LogoutView.as_view(),
name="django.contrib.auth.views.logout",
),
path("admin/", admin.site.urls),
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
]
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
View File
File renamed without changes.
File renamed without changes.
+17 -18
View File
@@ -4,18 +4,18 @@ from itertools import groupby
from django.contrib import messages
from django.shortcuts import redirect, render
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 = datetime.now() # noqa: DTZ005
start_time = datetime.now() - timedelta(days=30) # noqa: DTZ005
# 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 = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999) # noqa: DTZ005
if request.GET.get("start_time", False) or request.GET.get("end_time", False):
form = DateRangeForm(request.GET)
@@ -50,14 +50,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 +75,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 +88,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 +110,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 +120,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 +133,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)
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
+1 -1
View File
@@ -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()
-22
View File
@@ -1,22 +0,0 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import include, path, re_path
from strawberry.django.views import GraphQLView
from main import urls as main_urls
from main.api.schema import schema
admin.autodiscover()
urlpatterns = [
re_path(r"", include(main_urls)),
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path("accounts/logout/", LogoutView.as_view(), name="django.contrib.auth.views.logout"),
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
]
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -eu
cd frontend
npm i
npm run build
+16 -6
View File
@@ -1,14 +1,24 @@
#!/bin/bash
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
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
SRC_DIR="main bbssite"
black $SRC_DIR
isort $SRC_DIR
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
SRC_DIR="main bbssite"
black --check $SRC_DIR
isort --diff $SRC_DIR
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
uvx pre-commit run --all-files
+3
View File
@@ -1 +1,4 @@
#!/bin/bash
cd frontend && npm run start
+1 -1
View File
@@ -1 +1 @@
v16
v20
+4 -1
View File
@@ -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>
+953 -989
View File
File diff suppressed because it is too large. Load diff
+22 -26
View File
@@ -16,54 +16,50 @@
"test": "jest"
},
"dependencies": {
"@apollo/client": "^3.5.8",
"@apollo/client": "^3.5.10",
"@nivo/bar": "^0.79.1",
"@nivo/line": "^0.79.1",
"@nivo/pie": "^0.79.1",
"bootstrap": "^5.1.3",
"bootstrap-icons": "^1.8.1",
"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-hook-form": "^7.30.0",
"react-router-dom": "^6.3.0",
"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/cli": "^2.6.2",
"@graphql-codegen/typed-document-node": "^2.2.8",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@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": "^17.0.44",
"@types/react-dom": "^17.0.15",
"@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",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@typescript-eslint/parser": "^5.19.0",
"@vitejs/plugin-react": "^1.3.1",
"@vitejs/plugin-react-refresh": "^1.3.6",
"eslint": "^8.9.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.4",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"jest": "^27.5.1",
"prettier": "^2.5.1",
"stylelint": "^14.5.0",
"prettier": "^2.6.2",
"stylelint": "^14.7.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"
"typescript": "^4.6.3",
"vite": "^2.9.5",
"vite-plugin-svgr": "^1.1.0"
},
"engines": {
"node": ">=16",
"npm": ">=7"
"node": "20"
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
.app {
margin: 16px;
margin: 1rem auto;
max-width: 1220px;
}
+5 -2
View File
@@ -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>
+25 -21
View File
@@ -1,26 +1,30 @@
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 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 UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes: React.FC = () => (
<ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} />
<Route path="/log" element={<Log />} />
<Route path="/stats" element={<Stats />} />
<Route path="/stats/orders" element={<OrderStats />} />
<Route path="/stats/products" element={<ProductStats />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} />
</ReactRouterRoutes>
export const Routes = (): JSX.Element => (
<React.Suspense fallback={<div>Loading...</div>}>
<ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} />
<Route path="/log" element={<Log />} />
<Route path="/stats" element={<Stats />} />
<Route path="/stats/orders" element={<OrderStats />} />
<Route path="/stats/products" element={<ProductStats />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} />
<Route path="*" element={<Splash brandText="404" noOverlay />} />
</ReactRouterRoutes>
</React.Suspense>
);
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
uri: import.meta.env.VITE_API_URL || "http://localhost:8000/graphql/",
uri: `${import.meta.env.VITE_API_URL}/graphql/`,
cache: new InMemoryCache(),
});
+22
View File
@@ -0,0 +1,22 @@
.footer {
position: absolute;
right: 0;
bottom: 1rem;
left: 0;
height: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
text-align: center;
}
.heart {
color: var(--brand);
}
.brick {
position: relative;
top: 7px;
display: inline-block;
width: 20px;
height: 20px;
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import { ReactComponent as Brick } from "../assets/brick.svg";
import styles from "./Footer.module.css";
export const Footer = (): JSX.Element => {
return (
<footer className={styles.footer}>
Made with <span className={styles.heart}>❤</span> by{" "}
<span title="EDB-gjengen">
<Brick className={styles.brick} />
</span>
</footer>
);
};
@@ -0,0 +1,9 @@
.errorMessage {
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
color: var(--brand);
text-align: center;
width: 100%;
margin-top: 30vh;
}
+23
View File
@@ -0,0 +1,23 @@
import { ApolloError } from "@apollo/client";
import React from "react";
import styles from "./GQLError.module.css";
const formatModel = (text: string) => {
const withSpaces = text
.replace(/([A-Z])/g, " $1")
.toLocaleLowerCase()
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
};
export const GQLError = ({ error }: { error: ApolloError }) => {
if (error?.graphQLErrors?.length == 1) {
const single = error?.graphQLErrors[0];
if (single?.extensions?.code === "NOT_FOUND") {
const modelPretty = formatModel(single?.extensions?.model as string);
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
}
}
return <div className={styles.errorMessage}>Error...</div>;
};
+90 -9
View File
@@ -1,13 +1,94 @@
.logo {
position: relative;
top: -1px;
display: inline-block;
width: 24px;
height: 24px;
margin: 0 8px;
.header {
margin: 1rem auto;
max-width: 1220px;
}
.navIcon {
.nav {
display: flex;
height: 5rem;
align-items: center;
text-transform: uppercase;
}
.navLink:after {
position: absolute;
right: 12px;
left: 14px;
display: block;
height: 1px;
background-color: rgba(243, 33, 61, 0.4);
box-shadow: 0 0 4px rgba(243, 33, 61, 0.4), 0 0 11px rgba(243, 33, 61, 0.4), 0 0 19px rgba(243, 33, 61, 0.4);
content: "";
transform: rotate(-2.5deg);
}
.navLink:hover:after {
background-color: var(--brand);
box-shadow: 0 0 4px var(--brand), 0 0 11px var(--brand), 0 0 19px var(--brand), 0 0 30px var(--brand);
}
.logoNav {
margin-left: 8px;
}
.navItems {
display: flex;
align-items: center;
padding: 0;
margin: 0;
list-style: none;
}
.navLink {
position: relative;
top: -2px;
padding: 0.4rem 0.8rem;
border: 1px solid transparent;
transition: color 0.1s ease-in-out;
}
.navLink:hover {
color: white;
}
.active.active {
color: white;
}
/* FIXME: Duplicates Splash styles */
.brand {
padding-left: 1rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
left: -8px;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: 10px;
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+37 -72
View File
@@ -1,85 +1,50 @@
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">
<NavLink to="/" className={navLinkStyles} end>
<span className={styles.navIcon}>
<House />
</span>{" "}
Kjøp drikke
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/log" className={navLinkStyles}>
<span className={styles.navIcon}>
<CardList />
</span>{" "}
Logg
</NavLink>
</li>
<li className="nav-item">
<NavLink to="/stats" className={navLinkStyles}>
<span className={styles.navIcon}>
<BarChart />
</span>{" "}
Statistikk
</NavLink>
</li>
<ul className={styles.navItems}>
<li>
<NavLink to="/" className={navLinkStyles} end>
Kjøp drikke
</NavLink>
</li>
<li>
<NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger
</NavLink>
</li>
<li>
<NavLink to="/log" className={navLinkStyles}>
Logg
</NavLink>
</li>
<li>
<NavLink to="/stats" className={navLinkStyles}>
Statistikk
</NavLink>
</li>
<li className="nav-item">
<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>
<NavLink to="/users" className={navLinkStyles}>
Folk
</NavLink>
</li>
<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;
}
-26
View File
@@ -1,26 +0,0 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { Product } from "../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
onProduct: MouseEventHandler;
active: boolean;
amount: number;
};
export const ProductCard: React.FC<ProductCardProps> = ({ product, onProduct, amount, active = false }) => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct}>
<div className={styles.wrap}>
<span className={styles.amount}>{amount}</span>
{product.imageUrl && <img src={product.imageUrl} className={styles.productImage} alt={product.name} />}
{!product.imageUrl && <div className={styles.noProductImage} />}
<span className={styles.productText}>{product.name}</span>
</div>
</button>
);
};
+68
View File
@@ -0,0 +1,68 @@
.splash {
top: 0;
right: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
text-align: center;
}
.overlay {
position: fixed;
}
.wrap {
position: relative;
}
.sun {
width: clamp(100px, 65vw, 600px);
height: clamp(100px, 65vw, 600px);
margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
border-radius: 50% 50%;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun), 0 0 30px var(--sun);
}
.brand {
position: relative;
left: -0.6rem;
padding-top: 3rem;
color: var(--brand);
font-family: Rocket Rinder, monospace;
font-size: clamp(20px, 20vw, 200px);
font-style: italic;
letter-spacing: 1rem;
line-height: 1;
text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
bottom: 0.5rem;
left: 0rem;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family: Rocket Clouds DEMO, serif;
font-size: clamp(20px, 5vw, 50px);
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+20
View File
@@ -0,0 +1,20 @@
import clsx from "clsx";
import React from "react";
import styles from "./Splash.module.css";
interface SplashProps {
brandText?: string;
noOverlay?: boolean;
}
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
<div className={clsx(styles.splash, { [styles.overlay]: !noOverlay })}>
<div className={styles.wrap}>
<div className={styles.sun}></div>
<div className={styles.brand}>{brandText}</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</div>
</div>
);
export default Splash;
+23
View File
@@ -2,4 +2,27 @@
position: absolute;
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;
}
+7 -30
View File
@@ -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>
);
};
+10 -8
View File
@@ -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;
}
-31
View File
@@ -1,31 +0,0 @@
import { ReactComponent as PersonFill } from "bootstrap-icons/icons/person-fill.svg";
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../types";
import styles from "./UserCard.module.css";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
onUser: MouseEventHandler;
active: boolean;
};
/** If user is null, then it's the special external user */
export const UserCard: React.FC<UserCardProps> = ({ user, onUser, active = false }) => {
const classes = clsx(styles.user, { [styles.active]: active });
return (
<button className={classes} onClick={onUser} type="button">
{user?.profile?.image && (
<img src={user.profile?.image} alt={`Profile for ${user.firstName}`} className={styles.avatar} />
)}
{!user?.profile?.image && (
<span className={styles.avatar}>
<PersonFill />
</span>
)}
<span className={styles.name}>{user?.firstName || "Ekstern"}</span>
<span className={styles.balance}>{user?.profile?.balance || ""}</span>
</button>
);
};
@@ -0,0 +1,38 @@
.wireframe {
position: absolute;
bottom: calc(22% - 512px);
left: calc(50% - 512px);
width: 1024px;
height: 1024px;
display: grid;
grid-template-columns: repeat(8, 128px);
animation-iteration-count: infinite;
animation-name: isometric-rotate;
animation-duration: 10s;
animation-timing-function: linear;
pointer-events: none;
z-index: -1;
}
.cell {
border-bottom: 2px solid var(--sun);
border-right: 2px solid var(--sun);
}
.cell:nth-child(8n) {
border-right: none;
}
.cell:nth-child(n+57) {
border-bottom: none;
}
@keyframes isometric-rotate {
0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
}
100% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-405deg);
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import styles from "./Wireframe.module.css";
export const Wireframe = () => (
<div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} />
))}
</div>
);
@@ -0,0 +1,5 @@
.form {
display: grid;
max-width: 500px;
gap: 1rem;
}
+18 -16
View File
@@ -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)}>
<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} />
</form>
<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>}
<label>Etternavn</label>
<input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>}
<label>E-post</label>
<input type="email" {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
<input type="submit" disabled={creating} className="btn-primary" />
</form>
</div>
);
};
export default UserCreate;
@@ -1,13 +1,17 @@
.userList {
display: grid;
height: 400px;
grid-template-columns: repeat(12, 100px);
height: 800px;
padding-top: 2rem;
grid-template-columns: repeat(5, 220px);
overflow-y: auto;
row-gap: 16px;
column-gap: 16px;
row-gap: 24px;
}
.amountSection {
margin-top: 32px;
}
.checkout {
margin-top: 32px;
}
+6 -5
View File
@@ -1,10 +1,10 @@
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,
@@ -33,7 +33,7 @@ export const Deposit: React.FC = () => {
placeholder="Filtrér på navn"
/>
{userQuery !== "" && (
<button className="btn btn-link" type="button" onClick={() => setUserQuery("")}>
<button type="button" onClick={() => setUserQuery("")}>
Vis alle
</button>
)}
@@ -55,13 +55,14 @@ export const Deposit: React.FC = () => {
<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}>
<button type="button" onClick={onSubmit} className="primary">
Sett inn
</button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}>
<button type="button" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
export default Deposit;
+27
View File
@@ -3,3 +3,30 @@
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
.disabled,
.disabled a:link {
color: rgba(121, 152, 238, 0.5);
}
.pagination {
display: flex;
align-items: flex-end;
padding: 0;
margin: 0;
margin-top: 1.5rem;
gap: 0 1rem;
list-style: none;
}
.pagination a {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
margin-top: 1rem;
border-radius: 4px;
}
.pagination a:hover {
color: white;
}
.disabled a:hover {
color: rgba(121, 152, 238, 0.5);
}
+21 -24
View File
@@ -9,7 +9,7 @@ import styles from "./Log.module.css";
const PAGE_SIZE = 10;
export const Log = () => {
const Log = () => {
const types = {
orderOffset: Number,
transactionOffset: Number,
@@ -34,7 +34,7 @@ export const Log = () => {
<div className={styles.log}>
<div>
<h2>Ordre</h2>
<table className="table">
<table>
<thead>
<tr>
<th scope="col">Når</th>
@@ -51,39 +51,37 @@ export const Log = () => {
<td>
{(order.orderlines ?? []).map((line) => (
<div key={line.id}>
{line.amount}x {line.product.name}
{Number(line.amount)}x {line.product.name}
</div>
))}
</td>
<td>{order.orderSum}</td>
<td>{Number(order.orderSum)}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Order pagination">
<ul className="pagination">
<li className={clsx("page-item", { disabled: orderOffset <= 0 })}>
<ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: orderOffset <= 0 })}>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: orderOffset - PAGE_SIZE });
setParams({ orderOffset: Math.max(0, orderOffset - PAGE_SIZE) });
}}
>
Previous
⬅
</a>
</li>
<li className="page-item">
<li>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ orderOffset: orderOffset + PAGE_SIZE });
setParams({ orderOffset: Math.max(0, orderOffset + PAGE_SIZE) });
}}
>
Next
➡
</a>
</li>
</ul>
@@ -91,7 +89,7 @@ export const Log = () => {
</div>
<div>
<h2>Transaksjoner</h2>
<table className="table">
<table>
<thead>
<tr>
<th scope="col">Når</th>
@@ -106,35 +104,33 @@ export const Log = () => {
<td>
{trans.user.firstName} {trans.user.lastName}
</td>
<td>{trans.amount}</td>
<td>{Number(trans.amount)}</td>
</tr>
))}
</tbody>
</table>
<nav aria-label="Transaction pagination">
<ul className="pagination">
<li className={clsx("page-item", { disabled: transactionOffset <= 0 })}>
<ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: transactionOffset - PAGE_SIZE });
setParams({ transactionOffset: Math.max(0, transactionOffset - PAGE_SIZE) });
}}
>
Previous
⬅
</a>
</li>
<li className="page-item">
<li>
<a
href="#"
className="page-link"
onClick={(e) => {
e.preventDefault();
setParams({ transactionOffset: transactionOffset + PAGE_SIZE });
setParams({ transactionOffset: Math.max(transactionOffset + PAGE_SIZE) });
}}
>
Next
➡
</a>
</li>
</ul>
@@ -143,3 +139,4 @@ export const Log = () => {
</div>
);
};
export default Log;
@@ -0,0 +1,51 @@
.product {
display: flex;
border: none;
background: rgb(35, 15, 41);
border-radius: 4px;
transition: background-color 0.1s ease-out, border 0.1s ease-out;
position: relative;
align-items: flex-start;
padding: 1rem;
}
.product:hover,
.active.product {
background-color: var(--background);
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
}
.productText {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.2rem;
text-shadow: none;
color: white;
}
.amount {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
right: 0;
padding: 12px 12px 8px 8px;
border-left: 1px solid transparent;
color: transparent;
font-size: 2rem;
font-weight: bold;
line-height: 1;
background-color: transparent;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
display: none;
}
.active .amount {
display: block;
color: var(--background);
text-shadow: none;
border-left-color: rgb(35, 15, 41);
background-color: var(--sun-end);
}
@@ -0,0 +1,22 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { Product } from "../../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
onProduct: MouseEventHandler;
active: boolean;
amount: number;
};
export const ProductCard = ({ product, onProduct, amount, active = false }: ProductCardProps): JSX.Element => {
const classes = clsx(styles.product, { [styles.active]: active });
return (
<button className={classes} type="button" onClick={onProduct} title={product.name}>
<span className={styles.amount}>{amount}</span>
<span className={styles.productText}>{product.name}</span>
</button>
);
};
@@ -1,14 +1,21 @@
.userList {
display: grid;
grid-template-columns: repeat(12, 128px);
row-gap: 16px;
grid-template-columns: repeat(5, 220px);
column-gap: 16px;
row-gap: 24px;
}
.showAll {
margin-top: 1rem;
}
.productList {
display: grid;
grid-template-columns: repeat(8, 150px);
row-gap: 16px;
grid-template-columns: repeat(5, 220px);
column-gap: 16px;
row-gap: 24px;
}
.productListSection {
margin-top: 2rem;
}
@@ -22,7 +29,6 @@
min-width: 200px;
padding: 4px 8px;
margin-right: 8px;
background: black;
color: white;
font-size: 1.8rem;
}
+7 -6
View File
@@ -1,14 +1,14 @@
import React from "react";
import { ProductCard } from "../../components/ProductCard";
import { UserCard } from "../../components/UserCard";
import { OrderLineInput, Product } from "../../types";
import { UserCard } from "../users/UserCard";
import { ProductCard } from "./ProductCard";
import styles from "./Register.module.css";
import { useRegister } from "./useRegister";
const USER_EXTERNAL = "external";
export const Register: React.FC = () => {
const Register = (): JSX.Element => {
const {
users,
products,
@@ -48,7 +48,7 @@ export const Register: React.FC = () => {
/>
))}
</div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}>
<button type="button" onClick={() => setShowAll(!showAll)} className={styles.showAll}>
{showAll ? "Skjul gamliser" : "Vis alle"}
</button>
</section>
@@ -70,13 +70,14 @@ export const Register: React.FC = () => {
<span className={styles.total}>
Totalt: <span>{total}</span>
</span>
<button type="button" className="btn btn-primary btn-lg" onClick={onSubmit}>
<button type="button" onClick={onSubmit} className="primary">
Kjøp
</button>{" "}
<button type="button" className="btn btn-outline-secondary btn-lg" onClick={reset}>
<button type="button" onClick={reset}>
Start på nytt
</button>
</section>
</div>
);
};
export default Register;
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useToast } from "../../components/ToastProvider";
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types";
import { activeUserFilter } from "../../utils/users";
import {formatCurrency} from "../../utils/currency";
const USER_EXTERNAL = "external";
@@ -53,7 +54,7 @@ export const useRegister = () => {
setOrder(newOrder);
};
const total = sumTotal(products, order, selectedUser === USER_EXTERNAL);
const total = formatCurrency(sumTotal(products, order, selectedUser === USER_EXTERNAL));
const onSubmit = async () => {
let res;
+2 -1
View File
@@ -35,7 +35,7 @@ const OrderBar = ({ data }: { data: BarDatum[] }) => (
/>
);
export const OrderStats = () => {
const OrderStats = () => {
const { data, loading } = useQuery(OrderStatsDocument);
return (
@@ -77,3 +77,4 @@ export const OrderStats = () => {
</StatsView>
);
};
export default OrderStats;
+2 -1
View File
@@ -11,7 +11,7 @@ type UserCount = {
count: number;
};
export const ProductStats = () => {
const ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true },
});
@@ -38,3 +38,4 @@ export const ProductStats = () => {
</StatsView>
);
};
export default ProductStats;
@@ -4,6 +4,26 @@
gap: 1rem;
grid-template-columns: repeat(3, 1fr);
}
.statsNav {
margin-bottom: 1rem;
}
.statsNav ul {
display: flex;
padding: 0;
margin: 0;
gap: 0 1rem;
list-style: none;
}
.statsNav a {
padding: 0.4rem 0.8rem;
border: 1px solid transparent;
}
.active.active {
color: white;
}
.statsNav a:hover {
border: 1px solid var(--border);
border-radius: 4px;
}
+2 -1
View File
@@ -5,7 +5,7 @@ import React from "react";
import { ProductStatsDocument } from "../../types";
import { StatsView } from "./StatsView";
export const Stats = () => {
const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument);
const series: any[] = [];
@@ -68,3 +68,4 @@ export const Stats = () => {
</StatsView>
);
};
export default Stats;
+11 -9
View File
@@ -1,34 +1,36 @@
import clsx from "clsx";
import React from "react";
import { NavLink } from "react-router-dom";
import styles from "./Stats.module.css";
const navLinkStyles = ({ isActive }: { isActive: boolean }) => (isActive ? "nav-link active" : "nav-link");
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const StatsNav = () => (
<div className={styles.statsNav}>
<ul className="nav nav-tabs">
<li className="nav-item">
<nav className={styles.statsNav}>
<ul>
<li>
<NavLink className={navLinkStyles} to="/stats" end>
Oversikt
</NavLink>
</li>
<li className="nav-item">
<li>
<NavLink className={navLinkStyles} to="/stats/products">
Produkter
</NavLink>
</li>
<li className="nav-item">
<li>
<NavLink className={navLinkStyles} to="/stats/orders">
Ordre
</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link disabled" to="/stats/users" title="TODO">
<li>
<NavLink className="disabled" to="/stats/users" title="TODO" hidden={true}>
Folk
</NavLink>
</li>
</ul>
</div>
</nav>
);
export const StatsView = ({ children, loading }: { children: React.ReactNode; loading: boolean }) => (
@@ -0,0 +1,61 @@
.user {
display: flex;
align-items: center;
padding: 0;
border: none;
border-left: 4px solid transparent;
background: rgb(35, 15, 41);
border-radius: 4px;
color: var(--text);
transition: background-color 0.1s ease-out, border 0.1s ease-out;
max-height: 82px;
}
.user:hover {
cursor: pointer;
}
.user:hover,
.active.user {
background-color: var(--background);
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
}
.active.user {
border-left: 4px solid var(--sun)
}
.avatar {
margin-left: 1rem;
width: 50px;
height: 50px;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
}
.avatarWrap {
filter: drop-shadow(0 0 4px var(--background)) drop-shadow(0 0 11px var(--background))
drop-shadow(0 0 19px var(--background));
}
.noAvatar {
margin-left: 1rem;
display: block;
width: 50px;
height: 50px;
background-color: black;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
content: "";
}
.meta {
display: flex;
padding: 1rem;
flex-direction: column;
align-items: flex-start;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 1.2rem;
text-shadow: none;
}
.balance {
color: var(--sun)
}
+36
View File
@@ -0,0 +1,36 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../../types";
import styles from "./UserCard.module.css";
import {formatCurrency} from "../../utils/currency";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
onUser: MouseEventHandler;
active: boolean;
};
/** If user is null, then it's the special external user */
export const UserCard = ({ user, onUser, active = false }: UserCardProps): JSX.Element => {
const classes = clsx(styles.user, { [styles.active]: active });
const userBalanceString = formatCurrency(Number(user?.profile?.balance));
return (
<button className={classes} onClick={onUser} type="button" title={user?.firstName || "Ekstern"}>
<div className={styles.avatarWrap}>
{user?.profile?.image && (
<img src={user.profile?.image} alt={`Profile for ${user.firstName}`} className={styles.avatar} />
)}
{!user?.profile?.image && (
<span className={styles.avatar}>
<span className={styles.noAvatar} />
</span>
)}
</div>
<div className={styles.meta}>
{user?.firstName || "Ekstern"}
<span className={styles.balance}>{userBalanceString}</span>
</div>
</button>
);
};
+16 -12
View File
@@ -3,16 +3,19 @@ import { Line } from "@nivo/line";
import React from "react";
import { useParams } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { User, UserDetailDocument, UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time";
import styles from "./UserDetail.module.css";
export const UserDetail = () => {
const UserDetail = () => {
const { userId } = useParams();
const { data, loading } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
const { data, loading, error } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const user = data?.user as User;
const profile = user?.profile as UserProfile;
// FIXME: types
@@ -30,26 +33,26 @@ export const UserDetail = () => {
<h1>
{profile.image && <img src={profile.image} alt={`${user.firstName}s profile picture`} />} {user.firstName}
</h1>
<div className="card">
<div className="card-header">Stats</div>
<ul className="list-group list-group-flush">
<li className="list-group-item">
<div>
<div>Stats</div>
<ul>
<li>
Saldo: <strong>{profile.balance.toLocaleString("no")} kr</strong>
</li>
<li className="list-group-item">
<li>
Siste kjøp: <strong>{formatTime(profile.lastPurchaseDate)}</strong>
</li>
<li className="list-group-item">
<li>
Kjøp totalt: <strong>{profile.orderSumTotal.toLocaleString("no")} kr</strong>
</li>
</ul>
</div>
<br />
<div className="card">
<div className="card-header">Top 5 måneder</div>
<ul className="list-group list-group-flush">
<div>
<div>Top 5 måneder</div>
<ul>
{profile.topMonths.map((topMonth) => (
<li key={topMonth.period} className="list-group-item">
<li key={topMonth.period}>
{formatMonth(topMonth.period)}: <strong>{topMonth.count}</strong> kjøp på totalt{" "}
<strong>{topMonth.sum} kr</strong>
</li>
@@ -108,3 +111,4 @@ export const UserDetail = () => {
</div>
);
};
export default UserDetail;
+5 -3
View File
@@ -5,7 +5,7 @@ import { Link } from "react-router-dom";
import { AllUsersDocument } from "../../types";
import { activeUserFilter } from "../../utils/users";
export const UserList = () => {
const UserList = () => {
const { data, loading } = useQuery(AllUsersDocument);
const [showAll, setShowAll] = useState(false);
@@ -15,7 +15,8 @@ export const UserList = () => {
const users = showAll ? allUsers : allUsers.filter((u) => activeUserFilter(u));
return (
<div>
<button type="button" className="btn btn-link" onClick={() => setShowAll(!showAll)}>
<h2>Folk</h2>
<button type="button" onClick={() => setShowAll(!showAll)}>
{showAll ? "Skjul gamliser" : "Vis alle"}
</button>
<ul>
@@ -28,9 +29,10 @@ export const UserList = () => {
))}
</ul>
<br />
<Link to="/users/create" className="btn btn-primary">
<Link to="/users/create" className="btn-primary">
Opprett ny
</Link>
</div>
);
};
export default UserList;
+145
View File
@@ -0,0 +1,145 @@
/* Custom props */
:root {
--brand: rgb(243, 33, 61);
--background: rgb(70, 30, 82);
--text: rgb(255, 255, 255);
--sun: rgb(221, 81, 127);
--sun-end: rgb(230, 142, 54);
--border: rgb(85, 109, 200);
--interlace: rgb(121, 152, 238);
--font-family: "IBM Plex Mono", monospace;
}
/* Resets */
html,
body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
/* Body and interitable styles */
body {
background: linear-gradient(rgba(0, 0, 0, 0.8) 60%, var(--background) 100%);
background-color: var(--background);
color: var(--text);
font-family: var(--font-family);
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
/* Links */
a:link {
color: var(--interlace);
text-decoration: none;
}
a:visited {
color: var(--interlace);
}
a:active {
color: var(--interlace);
}
a:hover {
color: var(--text);
}
h1,
h2,
h3,
h4,
h5 {
color: var(--sun);
}
a.btn-primary {
padding: 0.4rem 1rem;
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
border-radius: 4px;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
/* Buttons */
button {
padding: 0.4rem 1rem;
border: 1px solid var(--border);
background: transparent;
border-radius: 4px;
color: var(--border);
font-family: var(--font-family);
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
button:hover {
color: var(--text);
cursor: pointer;
}
button.primary {
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
/* Inputs */
input {
padding: 0.4rem 0.4rem;
border: 1px solid var(--border);
background: transparent;
border-radius: 4px;
color: var(--text);
font-family: var(--font-family);
font-size: 1.5rem;
text-shadow: 0 0 4px currentColor, 0 0 11px currentColor, 0 0 19px currentColor;
}
input:focus {
background: rgba(85, 109, 200, 0.1);
outline: none;
}
input.btn-primary {
padding: 0.4rem 1rem;
border: 1px solid transparent;
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
border-radius: 4px;
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
color: var(--text);
}
::placeholder {
color: var(--border);
}
/* Tables */
table {
border: 1px solid var(--border);
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0.5rem 0.8rem;
border: 1px solid var(--border);
}
/* Fonts*/
@font-face {
font-family: "Rocket Clouds DEMO";
font-style: normal;
font-weight: normal;
src: url("assets/rocket-clouds.ttf") format("truetype");
}
@font-face {
font-family: "Rocket Rinder";
font-style: normal;
font-weight: normal;
src: url("assets/rocket-rinder.ttf") format("truetype");
}
+2
View File
@@ -3,6 +3,8 @@ import ReactDOM from "react-dom";
import App from "./App";
import "./global.css";
ReactDOM.render(
<React.StrictMode>
<App />
+7
View File
@@ -0,0 +1,7 @@
export function formatCurrency(value: number) {
if (isNaN(value)) {
return "";
} else {
return value.toFixed(2).replace(/\.00$/, '');
}
}
+1 -2
View File
@@ -4,11 +4,10 @@ import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.NODE_ENV !== "production" ? "/" : "/static/modern/",
plugins: [svgr()],
define: {
__DEV__: process.env.NODE_ENV !== "production",
},
envDir: "..",
build: { outDir: "../static/modern", emptyOutDir: true },
build: { outDir: "../static", emptyOutDir: false },
});
-20
View File
@@ -1,20 +0,0 @@
import strawberry
from main.api.mutations.shop import ShopMutations
from main.api.mutations.users import UsersMutations
from main.api.queries.inventory import InventoryQueries
from main.api.queries.shop import ShopQueries
from main.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(ShopMutations, UsersMutations):
pass
schema = strawberry.Schema(query=Query, mutation=Mutation)
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more