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