Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a94db2675 | ||
|
|
6b2dc28ea3 |
No files matched your search
@@ -1 +0,0 @@
|
|||||||
VITE_API_URL=http://localhost:3000
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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 }}."
|
|
||||||
+3
-14
@@ -1,17 +1,6 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.pyc
|
*.pyc
|
||||||
local_settings.py
|
settings.py
|
||||||
db.sqlite3
|
dev.db
|
||||||
static/.sass-cache
|
static/gaute/.sass-cache
|
||||||
uploads/[^.]*
|
uploads/[^.]*
|
||||||
venv/
|
|
||||||
node_modules/
|
|
||||||
.idea/
|
|
||||||
CACHE/
|
|
||||||
media/
|
|
||||||
tmp/
|
|
||||||
frontend/dist
|
|
||||||
static/assets
|
|
||||||
static/index.html
|
|
||||||
.python-version
|
|
||||||
.env
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
variables:
|
|
||||||
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
|
|
||||||
|
|
||||||
default:
|
|
||||||
image: nikolaik/python-nodejs:python3.9-nodejs20
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- .cache/pip
|
|
||||||
- .venv
|
|
||||||
|
|
||||||
django-checks:
|
|
||||||
stage: build
|
|
||||||
script:
|
|
||||||
- uv run manage.py migrate
|
|
||||||
- uv run manage.py check
|
|
||||||
|
|
||||||
tests:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- uv run bin/test
|
|
||||||
|
|
||||||
lint:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- uv run bin/pre-commit
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
stage: deploy
|
|
||||||
before_script:
|
|
||||||
# Install ssh if not already
|
|
||||||
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
|
|
||||||
- eval $(ssh-agent -s)
|
|
||||||
# Make SSH key available
|
|
||||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- chmod 700 ~/.ssh
|
|
||||||
# Add known host keys
|
|
||||||
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
|
||||||
- chmod 644 ~/.ssh/known_hosts
|
|
||||||
script:
|
|
||||||
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
|
|
||||||
only:
|
|
||||||
- main
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: 'v0.11.7'
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix, --exit-non-zero-on-fix]
|
|
||||||
- id: ruff-format
|
|
||||||
- repo: https://github.com/adamchainz/django-upgrade
|
|
||||||
rev: "1.24.0"
|
|
||||||
hooks:
|
|
||||||
- id: django-upgrade
|
|
||||||
args: [--target-version, "4.2"]
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
TODO:
|
||||||
|
- Prototype register GUI.
|
||||||
|
- Show picture of buyers and username, with balance > 0.
|
||||||
|
- Submit orders
|
||||||
|
- User profile settings
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Getting started
|
|
||||||
|
|
||||||
Before starting, make sure you have a working python and nodejs setup with pyenv and nvm.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt-get install libmysqlclient-dev
|
|
||||||
pre-commit install
|
|
||||||
uv run manage.py migrate
|
|
||||||
uv run bin/run
|
|
||||||
cp .env-default .env
|
|
||||||
|
|
||||||
# in another terminal
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- improve product listing (light purple background)
|
|
||||||
- Stats: products over time
|
|
||||||
- inventory views
|
|
||||||
- Stats: Implement users tab
|
|
||||||
|
|
||||||
## TODO-old
|
|
||||||
|
|
||||||
- Larger pictures
|
|
||||||
- Compact GUI for max 768 vertical pixels
|
|
||||||
- Search right of in title
|
|
||||||
Regular → Executable
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
This file demonstrates writing tests using the unittest module. These will pass
|
||||||
|
when you run "manage.py test".
|
||||||
|
|
||||||
|
Replace this with more appropriate tests for your application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleTest(TestCase):
|
||||||
|
def test_basic_addition(self):
|
||||||
|
"""
|
||||||
|
Tests that 1 + 1 always equals 2.
|
||||||
|
"""
|
||||||
|
self.assertEqual(1 + 1, 2)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import strawberry
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.interface
|
|
||||||
class Error:
|
|
||||||
message: str
|
|
||||||
|
|
||||||
@strawberry.field()
|
|
||||||
def error(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class FieldError(Error):
|
|
||||||
field: str
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class FormErrors:
|
|
||||||
fields: list[FieldError]
|
|
||||||
message: str = ""
|
|
||||||
error: bool = True
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from typing import Annotated, Union
|
|
||||||
|
|
||||||
import strawberry
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import transaction
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from strawberry import ID
|
|
||||||
|
|
||||||
from bbs import models
|
|
||||||
from bbs.api.errors import Error, FieldError, FormErrors
|
|
||||||
from bbs.api.types.inventory import Transaction
|
|
||||||
from bbs.api.types.shop import Order
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.input
|
|
||||||
class OrderLineInput:
|
|
||||||
product_id: ID
|
|
||||||
amount: int
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class CreateOrderSuccess:
|
|
||||||
order: Order
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class InsufficientFunds(Error): # noqa: N818
|
|
||||||
amount_lacking: float
|
|
||||||
|
|
||||||
|
|
||||||
CreateOrderResponse = Annotated[
|
|
||||||
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.mutation
|
|
||||||
@transaction.atomic
|
|
||||||
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001
|
|
||||||
# clean and validate
|
|
||||||
try:
|
|
||||||
user = UserModel.objects.get(pk=customer_id)
|
|
||||||
except (UserModel.DoesNotExist, ValueError):
|
|
||||||
user = None
|
|
||||||
|
|
||||||
if user is None and not is_external:
|
|
||||||
message = "Du har ikke valgt hvem som skal kjøpe."
|
|
||||||
return FormErrors(
|
|
||||||
message=message,
|
|
||||||
fields=[FieldError(field="customer_id", message=message)],
|
|
||||||
)
|
|
||||||
if not order_lines:
|
|
||||||
message = "Du har ikke valgt hva du skal kjøpe."
|
|
||||||
return FormErrors(
|
|
||||||
message=message,
|
|
||||||
fields=[FieldError(field="order_lines", message=message)],
|
|
||||||
)
|
|
||||||
|
|
||||||
ols = []
|
|
||||||
order_sum = 0
|
|
||||||
for ol in order_lines:
|
|
||||||
product = get_object_or_404(models.Product, pk=ol.product_id)
|
|
||||||
price = product.sale_price_ext if is_external else product.sale_price_int
|
|
||||||
ols.append(models.OrderLine(product=product, amount=ol.amount, unit_price=price))
|
|
||||||
order_sum += ol.amount * price
|
|
||||||
product.inventory_amount -= ol.amount
|
|
||||||
product.save()
|
|
||||||
|
|
||||||
if not is_external and user.profile.balance <= order_sum:
|
|
||||||
return InsufficientFunds(
|
|
||||||
message="Du har ikke nok penger på bok.",
|
|
||||||
amount_lacking=abs(order_sum - user.profile.balance),
|
|
||||||
)
|
|
||||||
|
|
||||||
# create
|
|
||||||
order = models.Order.objects.create(customer=user, order_sum=order_sum)
|
|
||||||
for ol in ols:
|
|
||||||
ol.order = order
|
|
||||||
models.OrderLine.objects.bulk_create(ols)
|
|
||||||
|
|
||||||
# update balance
|
|
||||||
if not is_external:
|
|
||||||
user.profile.balance -= order_sum
|
|
||||||
user.profile.save()
|
|
||||||
|
|
||||||
return CreateOrderSuccess(order=order)
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class CreateDepositSuccess:
|
|
||||||
transaction: Transaction
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class AboveMaxSaldo(Error): # noqa: N818
|
|
||||||
max_saldo: float
|
|
||||||
above: float
|
|
||||||
|
|
||||||
|
|
||||||
CreateDepositResponse = Annotated[
|
|
||||||
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.mutation
|
|
||||||
@transaction.atomic
|
|
||||||
def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
|
|
||||||
# clean and validate
|
|
||||||
try:
|
|
||||||
user = UserModel.objects.get(pk=user_id)
|
|
||||||
except (UserModel.DoesNotExist, ValueError):
|
|
||||||
message = "Du har ikke valgt hvem som sette inn penger."
|
|
||||||
return FormErrors(
|
|
||||||
message=message,
|
|
||||||
fields=[FieldError(field="user_id", message=message)],
|
|
||||||
)
|
|
||||||
if amount <= 0:
|
|
||||||
message = "Du har ikke valgt beløp som skal settes inn."
|
|
||||||
return FormErrors(
|
|
||||||
message=message,
|
|
||||||
fields=[FieldError(field="amount", message=message)],
|
|
||||||
)
|
|
||||||
# 1337 is special <3
|
|
||||||
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: # noqa: PLR2004
|
|
||||||
return AboveMaxSaldo(
|
|
||||||
message="Du prøver å sette inn for mye penger",
|
|
||||||
max_saldo=settings.BBS_SALDO_MAX,
|
|
||||||
above=amount + user.profile.balance - settings.BBS_SALDO_MAX,
|
|
||||||
)
|
|
||||||
|
|
||||||
trans = models.Transaction.objects.create(user=user, amount=amount)
|
|
||||||
profile = trans.user.profile
|
|
||||||
profile.balance += trans.amount
|
|
||||||
profile.save()
|
|
||||||
|
|
||||||
return CreateDepositSuccess(transaction=trans)
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class ShopMutations:
|
|
||||||
create_order = create_order
|
|
||||||
create_deposit = create_deposit
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
from django.contrib.auth.models import User as DjangoUser
|
|
||||||
from django.db import transaction
|
|
||||||
from strawberry import auto
|
|
||||||
|
|
||||||
from bbs import models
|
|
||||||
from bbs.api.types.users import User
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.input(DjangoUser)
|
|
||||||
class UserInput:
|
|
||||||
email: auto
|
|
||||||
first_name: auto
|
|
||||||
last_name: auto
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.mutation
|
|
||||||
@transaction.atomic
|
|
||||||
def register_mutation(input: UserInput) -> User: # noqa: A002
|
|
||||||
user = DjangoUser.objects.create_user(
|
|
||||||
first_name=input.first_name,
|
|
||||||
last_name=input.last_name,
|
|
||||||
email=input.email,
|
|
||||||
username=input.email,
|
|
||||||
)
|
|
||||||
models.UserProfile.objects.create(user=user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class UsersMutations:
|
|
||||||
create_user = register_mutation
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
|
|
||||||
from bbs.api.types.inventory import Transaction, TransactionOrdering
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class InventoryQueries:
|
|
||||||
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
|
|
||||||
from bbs import models
|
|
||||||
from bbs.api.types.shop import (
|
|
||||||
Order,
|
|
||||||
OrderOrdering,
|
|
||||||
OrderStats,
|
|
||||||
OrderStatsByTime,
|
|
||||||
Point,
|
|
||||||
Product,
|
|
||||||
ProductOrderStat,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class ShopQueries:
|
|
||||||
all_products: list[Product] = strawberry_django.field()
|
|
||||||
all_orders: list[Order] = strawberry_django.field()
|
|
||||||
order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering)
|
|
||||||
|
|
||||||
@strawberry.field
|
|
||||||
def order_stats(self) -> OrderStats:
|
|
||||||
return OrderStats(
|
|
||||||
**{
|
|
||||||
period: [
|
|
||||||
OrderStatsByTime(**stat)
|
|
||||||
for stat in models.Order.objects.count_by_created_period(period).order_by("period")
|
|
||||||
]
|
|
||||||
for period in ["yearly", "monthly", "daily", "hourly"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@strawberry.field
|
|
||||||
def product_stats(self) -> list[ProductOrderStat]:
|
|
||||||
return [
|
|
||||||
ProductOrderStat(
|
|
||||||
product_name=stat["product_name"],
|
|
||||||
data=[Point(**p) for p in stat["data"]],
|
|
||||||
)
|
|
||||||
for stat in models.OrderLine.objects.product_order_stats().as_series()
|
|
||||||
]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
from django.contrib.auth.models import User as DjangoUser
|
|
||||||
from strawberry import ID
|
|
||||||
|
|
||||||
from bbs.api.types.users import User
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class UsersQueries:
|
|
||||||
all_users: list[User] = strawberry_django.field()
|
|
||||||
|
|
||||||
@strawberry.field
|
|
||||||
def user(self, user_id: ID) -> User:
|
|
||||||
return DjangoUser.objects.get(pk=user_id)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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])
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,78 +0,0 @@
|
|||||||
from typing import TYPE_CHECKING, Annotated
|
|
||||||
|
|
||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
from strawberry import auto
|
|
||||||
|
|
||||||
from bbs import models
|
|
||||||
from bbs.api.types.utils import JSONScalar
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .users import User
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter_type(models.Product)
|
|
||||||
class ProductFilter:
|
|
||||||
id: auto
|
|
||||||
active: auto
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(models.Product, filters=ProductFilter)
|
|
||||||
class Product:
|
|
||||||
id: auto
|
|
||||||
name: auto
|
|
||||||
image_url: str
|
|
||||||
sale_price_int: auto
|
|
||||||
sale_price_ext: auto
|
|
||||||
# FIXME: stricter types
|
|
||||||
user_counts: JSONScalar
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.order_type(models.Order)
|
|
||||||
class OrderOrdering:
|
|
||||||
created: auto
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(models.Order)
|
|
||||||
class Order:
|
|
||||||
id: auto
|
|
||||||
customer: Annotated["User", strawberry.lazy(".users")]
|
|
||||||
order_sum: auto
|
|
||||||
created: auto
|
|
||||||
orderlines: list["OrderLine"]
|
|
||||||
is_external: bool
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(models.OrderLine)
|
|
||||||
class OrderLine:
|
|
||||||
id: auto
|
|
||||||
order: Order
|
|
||||||
product: Product
|
|
||||||
amount: auto
|
|
||||||
unit_price: auto
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class OrderStatsByTime:
|
|
||||||
period: int
|
|
||||||
count: str
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class OrderStats:
|
|
||||||
yearly: list[OrderStatsByTime]
|
|
||||||
monthly: list[OrderStatsByTime]
|
|
||||||
daily: list[OrderStatsByTime]
|
|
||||||
hourly: list[OrderStatsByTime]
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class Point:
|
|
||||||
x: str
|
|
||||||
y: int
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class ProductOrderStat:
|
|
||||||
product_name: str
|
|
||||||
data: list[Point]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import strawberry
|
|
||||||
import strawberry_django
|
|
||||||
from django.contrib.auth.models import User as DjangoUser
|
|
||||||
from strawberry import auto
|
|
||||||
|
|
||||||
from bbs import models
|
|
||||||
from bbs.api.types.shop import Point, ProductOrderStat
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class TopMonth:
|
|
||||||
period: str
|
|
||||||
count: int
|
|
||||||
sum: int
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(models.UserProfile)
|
|
||||||
class UserProfile:
|
|
||||||
id: auto
|
|
||||||
balance: auto
|
|
||||||
image: auto
|
|
||||||
last_purchase_date: datetime
|
|
||||||
order_sum_total: Decimal
|
|
||||||
|
|
||||||
@strawberry.field()
|
|
||||||
def top_months(self) -> list[TopMonth]:
|
|
||||||
return [TopMonth(**month) for month in self.top_months()]
|
|
||||||
|
|
||||||
@strawberry.field()
|
|
||||||
def product_order_stats(self) -> list[ProductOrderStat]:
|
|
||||||
return [
|
|
||||||
ProductOrderStat(
|
|
||||||
product_name=stat["product_name"],
|
|
||||||
data=[Point(**p) for p in stat["data"]],
|
|
||||||
)
|
|
||||||
for stat in self.product_order_stats().as_series()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(DjangoUser)
|
|
||||||
class User:
|
|
||||||
id: auto
|
|
||||||
first_name: auto
|
|
||||||
last_name: auto
|
|
||||||
profile: UserProfile
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import Any, NewType
|
|
||||||
|
|
||||||
import strawberry
|
|
||||||
|
|
||||||
JSONScalar = strawberry.scalar(
|
|
||||||
NewType("JSONScalar", Any),
|
|
||||||
serialize=lambda v: v,
|
|
||||||
parse_value=lambda v: json.loads(v),
|
|
||||||
description="The GenericScalar scalar type represents a generic GraphQL scalar value that could be: List or Object.",
|
|
||||||
)
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class BBSAppConfig(AppConfig):
|
|
||||||
name = "bbs"
|
|
||||||
verbose_name = "Biceps Bar System"
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
# all models are loaded, could attach signals
|
|
||||||
pass
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
|
|
||||||
from bbs.models import InventoryTransaction, Product
|
|
||||||
|
|
||||||
MAX_DIGITS = 9
|
|
||||||
DECIMAL_PLACES = 1
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryTransactionForm(forms.ModelForm):
|
|
||||||
unit_price = forms.DecimalField(
|
|
||||||
max_digits=MAX_DIGITS,
|
|
||||||
decimal_places=DECIMAL_PLACES,
|
|
||||||
required=True,
|
|
||||||
localize=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
# Only show active products
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields["product"].queryset = Product.objects.filter(active=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = InventoryTransaction
|
|
||||||
fields = ["product", "amount", "unit_price", "comment"]
|
|
||||||
|
|
||||||
|
|
||||||
class DateRangeForm(forms.Form):
|
|
||||||
start_time = forms.DateTimeField(required=False, label="Fra")
|
|
||||||
end_time = forms.DateTimeField(required=False, label="Til")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
__author__ = "Nikolai"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
__author__ = "Nikolai"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management import BaseCommand, CommandParser
|
|
||||||
from strawberry.printer import print_schema
|
|
||||||
|
|
||||||
from bbs.api.schema import schema as schema_symbol
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = " Write graphql schema to file or stdout"
|
|
||||||
|
|
||||||
def add_arguments(self, parser: CommandParser):
|
|
||||||
parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout")
|
|
||||||
|
|
||||||
def handle(self, *args, filename=None, **options): # noqa: ARG002
|
|
||||||
schema_output = print_schema(schema_symbol)
|
|
||||||
if filename != "-":
|
|
||||||
with Path(settings.BASE_DIR, filename).open("w+") as fp:
|
|
||||||
fp.write(schema_output)
|
|
||||||
self.stdout.write(self.style.SUCCESS("OK"))
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.core.management.base import BaseCommand, CommandParser
|
|
||||||
|
|
||||||
from bbs.models import UserProfile
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""Set profile image URL for all users"""
|
|
||||||
|
|
||||||
def add_arguments(self, parser: CommandParser):
|
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
|
||||||
parser.add_argument("--force", action="store_true")
|
|
||||||
|
|
||||||
def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002
|
|
||||||
for profile in UserProfile.objects.all().order_by("id"):
|
|
||||||
if dry_run:
|
|
||||||
pass
|
|
||||||
elif force or not profile.image:
|
|
||||||
profile.image = profile.image_url()
|
|
||||||
profile.save()
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="InventoryTransaction",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("amount", models.FloatField(verbose_name="Antall")),
|
|
||||||
("unit_price", models.FloatField(verbose_name="Enhetspris")),
|
|
||||||
("comment", models.TextField(verbose_name="Kommentar", blank=True)),
|
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Order",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("order_sum", models.FloatField(db_column="sum")),
|
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
|
||||||
(
|
|
||||||
"customer",
|
|
||||||
models.ForeignKey(
|
|
||||||
related_name="orders",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
blank=True,
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
null=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="OrderLine",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("amount", models.IntegerField()),
|
|
||||||
("unit_price", models.FloatField(verbose_name="Enhetspris")),
|
|
||||||
(
|
|
||||||
"order",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="orderlines",
|
|
||||||
to="bbs.Order",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Product",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=64)),
|
|
||||||
("sale_price_int", models.FloatField()),
|
|
||||||
("sale_price_ext", models.FloatField()),
|
|
||||||
("description", models.CharField(max_length=256, blank=True)),
|
|
||||||
("volume_liter", models.FloatField(null=True)),
|
|
||||||
("alcohol_percent", models.FloatField(null=True)),
|
|
||||||
("image", models.ImageField(upload_to="products", blank=True)),
|
|
||||||
("customer_support", models.CharField(max_length=32, blank=True)),
|
|
||||||
("inventory_amount", models.FloatField(default=0)),
|
|
||||||
("active", models.BooleanField(default=True)),
|
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated", models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["name"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Transaction",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("amount", models.FloatField()),
|
|
||||||
("created", models.DateTimeField(auto_now_add=True)),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="transactions",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserProfile",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("balance", models.FloatField(default=0.0)),
|
|
||||||
("image", models.URLField(blank=True)),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.OneToOneField(
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="profile",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="orderline",
|
|
||||||
name="product",
|
|
||||||
field=models.ForeignKey(on_delete=models.CASCADE, related_name="orderlines", to="bbs.Product"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="inventorytransaction",
|
|
||||||
name="product",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="transactions",
|
|
||||||
verbose_name="Produkt",
|
|
||||||
to="bbs.Product",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="inventorytransaction",
|
|
||||||
name="user",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="inventory_transactions",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name="orderline",
|
|
||||||
unique_together={("order", "product")},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
Whitespace-only changes.
-258
@@ -1,258 +0,0 @@
|
|||||||
import hashlib # noqa: EXE002
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
from django.db.models import Count, F, Sum
|
|
||||||
from django.db.models.functions import (
|
|
||||||
Extract,
|
|
||||||
ExtractHour,
|
|
||||||
ExtractIsoWeekDay,
|
|
||||||
ExtractMonth,
|
|
||||||
ExtractYear,
|
|
||||||
TruncDay,
|
|
||||||
TruncMonth,
|
|
||||||
)
|
|
||||||
|
|
||||||
EXTERNAL_USER = "Ekstern"
|
|
||||||
MAX_DIGITS = 9
|
|
||||||
DECIMAL_PLACES = 1
|
|
||||||
|
|
||||||
|
|
||||||
def _format_counts(per_user):
|
|
||||||
per_user = list(per_user)
|
|
||||||
for row in per_user:
|
|
||||||
if row.get("order__customer__first_name") is None:
|
|
||||||
who = EXTERNAL_USER
|
|
||||||
else:
|
|
||||||
first_name = row.pop("order__customer__first_name")
|
|
||||||
last_name = row.pop("order__customer__last_name")
|
|
||||||
last_name_initial = f" {last_name[0]}" if last_name else ""
|
|
||||||
who = f"{first_name}{last_name_initial}"
|
|
||||||
row["name"] = who
|
|
||||||
|
|
||||||
return per_user
|
|
||||||
|
|
||||||
|
|
||||||
class Product(models.Model):
|
|
||||||
name = models.CharField(max_length=64)
|
|
||||||
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
|
||||||
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
|
||||||
description = models.CharField(max_length=256, blank=True)
|
|
||||||
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
|
|
||||||
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
|
|
||||||
image = models.ImageField(upload_to="products", blank=True)
|
|
||||||
customer_support = models.CharField(max_length=32, blank=True)
|
|
||||||
# FIXME: rename to inventory_quantity
|
|
||||||
inventory_amount = models.IntegerField(default=0)
|
|
||||||
active = models.BooleanField(default=True)
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wholesale_unit_price(self):
|
|
||||||
"""
|
|
||||||
This is not accurate.
|
|
||||||
"""
|
|
||||||
if self.inventory_amount <= 0:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
unit_prices = self.transactions.order_by("created").values("amount", "unit_price")
|
|
||||||
sum_units = 0
|
|
||||||
for up in unit_prices:
|
|
||||||
sum_units += up["amount"]
|
|
||||||
if self.inventory_amount <= sum_units:
|
|
||||||
return up["unit_price"]
|
|
||||||
|
|
||||||
if len(unit_prices) > 0:
|
|
||||||
return unit_prices[0]["unit_price"]
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wholesale_value(self):
|
|
||||||
return self.wholesale_unit_price * self.inventory_amount
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image_url(self):
|
|
||||||
return self.image.url if self.image else ""
|
|
||||||
|
|
||||||
def user_counts(self):
|
|
||||||
name_fields = ["order__customer__first_name", "order__customer__last_name"]
|
|
||||||
per_user = OrderLine.objects.filter(product=self)
|
|
||||||
per_user = per_user.values(*name_fields).annotate(count=Sum("amount")).order_by("-count")
|
|
||||||
|
|
||||||
return _format_counts(per_user)
|
|
||||||
|
|
||||||
def __str__(self): # noqa: DJ012
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
class Meta: # noqa: DJ012
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
|
|
||||||
class OrderManager(models.Manager):
|
|
||||||
def count_by_created(self, extract_class: type[Extract]):
|
|
||||||
period = extract_class("created")
|
|
||||||
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
|
|
||||||
|
|
||||||
def count_by_created_period(self, period: str):
|
|
||||||
periods = {
|
|
||||||
"yearly": ExtractYear,
|
|
||||||
"monthly": ExtractMonth,
|
|
||||||
"daily": ExtractIsoWeekDay,
|
|
||||||
"hourly": ExtractHour,
|
|
||||||
}
|
|
||||||
assert period in periods # noqa: S101
|
|
||||||
return self.count_by_created(periods[period])
|
|
||||||
|
|
||||||
|
|
||||||
class Order(models.Model):
|
|
||||||
customer = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="orders",
|
|
||||||
)
|
|
||||||
order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
objects = OrderManager()
|
|
||||||
|
|
||||||
def is_external(self):
|
|
||||||
return self.customer is None
|
|
||||||
|
|
||||||
def reciept(self):
|
|
||||||
return ", ".join([str(ol) for ol in self.orderlines.all()])
|
|
||||||
|
|
||||||
def __str__(self): # noqa: DJ012
|
|
||||||
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
|
|
||||||
|
|
||||||
|
|
||||||
class OrderLineManager(models.QuerySet):
|
|
||||||
def product_order_stats(self):
|
|
||||||
return (
|
|
||||||
self.annotate(date=TruncDay("order__created"))
|
|
||||||
.values("date", product_name=F("product__name"))
|
|
||||||
.annotate(count=Sum("amount"))
|
|
||||||
.order_by("date")
|
|
||||||
)
|
|
||||||
|
|
||||||
def as_series(self):
|
|
||||||
datas = list(self)
|
|
||||||
datas.sort(key=lambda x: x["product_name"])
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"product_name": key,
|
|
||||||
"data": [{"x": item["date"].date().isoformat(), "y": item["count"]} for item in list(group)],
|
|
||||||
}
|
|
||||||
for key, group in groupby(datas, key=lambda x: x["product_name"])
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class OrderLine(models.Model):
|
|
||||||
order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines")
|
|
||||||
product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines")
|
|
||||||
# FIXME: rename to quantity
|
|
||||||
amount = models.IntegerField()
|
|
||||||
# FIXME: rename to price
|
|
||||||
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
|
|
||||||
|
|
||||||
objects = OrderLineManager.as_manager()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price(self):
|
|
||||||
return self.amount * self.unit_price
|
|
||||||
|
|
||||||
def __str__(self): # noqa: DJ012
|
|
||||||
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
|
|
||||||
|
|
||||||
class Meta: # noqa: DJ012
|
|
||||||
unique_together = ("order", "product")
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction(models.Model):
|
|
||||||
"""Money"""
|
|
||||||
|
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
|
|
||||||
amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.id}: {self.user}: {self.amount}"
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryTransaction(models.Model):
|
|
||||||
"""Goods"""
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="inventory_transactions",
|
|
||||||
)
|
|
||||||
product = models.ForeignKey(
|
|
||||||
"bbs.Product",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="transactions",
|
|
||||||
verbose_name="Produkt",
|
|
||||||
)
|
|
||||||
# FIXME: rename to quantity
|
|
||||||
amount = models.IntegerField(verbose_name="Antall")
|
|
||||||
# FIXME: rename to price
|
|
||||||
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
|
|
||||||
comment = models.TextField(verbose_name="Kommentar", blank=True)
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price(self):
|
|
||||||
return self.amount * self.unit_price
|
|
||||||
|
|
||||||
def __str__(self): # noqa: DJ012
|
|
||||||
return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)"
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
|
|
||||||
balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0)
|
|
||||||
image = models.URLField(blank=True)
|
|
||||||
|
|
||||||
def last_purchase(self):
|
|
||||||
return self.user.orders.order_by("-created").first()
|
|
||||||
|
|
||||||
def last_purchase_date(self) -> datetime:
|
|
||||||
last = self.last_purchase()
|
|
||||||
return last.created if last else datetime.min # noqa: DTZ901
|
|
||||||
|
|
||||||
def top_months(self, limit=5):
|
|
||||||
return (
|
|
||||||
Order.objects.filter(customer_id=self.user_id)
|
|
||||||
.annotate(period=TruncMonth("created"))
|
|
||||||
.values("period")
|
|
||||||
.annotate(sum=Sum("order_sum"), count=Count("pk"))
|
|
||||||
.order_by("-sum")[:limit]
|
|
||||||
)
|
|
||||||
|
|
||||||
def product_order_stats(self):
|
|
||||||
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
|
|
||||||
|
|
||||||
def order_sum_total(self) -> Decimal:
|
|
||||||
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
|
|
||||||
|
|
||||||
def image_url(self) -> str:
|
|
||||||
if self.user.email:
|
|
||||||
md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest() # noqa: S324
|
|
||||||
return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def save(self, **kwargs): # noqa: DJ012
|
|
||||||
if not self.image:
|
|
||||||
self.image = self.image_url()
|
|
||||||
|
|
||||||
super().save(**kwargs)
|
|
||||||
|
|
||||||
def __str__(self): # noqa: DJ012
|
|
||||||
return f"{self.user}"
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
# Django settings for bbs project. # noqa: EXE002
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
|
|
||||||
|
|
||||||
DEBUG = True
|
|
||||||
|
|
||||||
ADMINS = (
|
|
||||||
# ('Your Name', 'your_email@example.com'), # noqa: ERA001
|
|
||||||
)
|
|
||||||
MANAGERS = ADMINS
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": "db.sqlite3",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Oslo"
|
|
||||||
LANGUAGE_CODE = "nb"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
SITE_ID = 1
|
|
||||||
|
|
||||||
# Media and uploads
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
|
|
||||||
MEDIA_URL = "/media/"
|
|
||||||
|
|
||||||
# Static files
|
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
|
|
||||||
STATIC_URL = "/static/"
|
|
||||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
|
|
||||||
|
|
||||||
STATICFILES_FINDERS = (
|
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
|
||||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Make this unique, and don't share it with anybody.
|
|
||||||
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" # noqa: S105
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [
|
|
||||||
os.path.join(BASE_DIR, "templates"), # noqa: PTH118
|
|
||||||
],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.i18n",
|
|
||||||
"django.template.context_processors.media",
|
|
||||||
"django.template.context_processors.static",
|
|
||||||
"django.template.context_processors.tz",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "bbs.urls"
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.sites",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django_extensions",
|
|
||||||
"easy_thumbnails",
|
|
||||||
"corsheaders",
|
|
||||||
"django_rename_app",
|
|
||||||
]
|
|
||||||
LOCAL_APPS = [
|
|
||||||
"bbs",
|
|
||||||
]
|
|
||||||
INSTALLED_APPS += LOCAL_APPS
|
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
|
|
||||||
SESSION_COOKIE_AGE = 1209600 # 15 minutes
|
|
||||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
|
||||||
|
|
||||||
BBS_SALDO_MAX = 1000
|
|
||||||
BBS_LIMIT_DEPOSITS = False
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
THUMBNAIL_ALIASES = {
|
|
||||||
"": {
|
|
||||||
"product": {"size": (64, 64), "crop": True},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGING = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"root": {
|
|
||||||
"level": "INFO",
|
|
||||||
"handlers": ["console"],
|
|
||||||
},
|
|
||||||
"formatters": {
|
|
||||||
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"level": "DEBUG",
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "verbose",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"loggers": {
|
|
||||||
"django.db.backends": {
|
|
||||||
"level": "ERROR",
|
|
||||||
"handlers": ["console"],
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
||||||
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
"https://bbs.neuf.no",
|
|
||||||
"http://localhost:8000",
|
|
||||||
"http://localhost:3000",
|
|
||||||
]
|
|
||||||
|
|
||||||
with contextlib.suppress(ImportError):
|
|
||||||
from .local_settings import * # noqa: F403
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import pytest # noqa: INP001
|
|
||||||
from django.test import Client
|
|
||||||
from strawberry.django.test import GraphQLTestClient
|
|
||||||
|
|
||||||
from bbs.models import Order, Product
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="gql_client")
|
|
||||||
def gql_client_fixture(client: Client):
|
|
||||||
return GraphQLTestClient(client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="product")
|
|
||||||
def product_fix(db): # noqa: ARG001
|
|
||||||
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_order(product, gql_client):
|
|
||||||
assert Order.objects.count() == 0
|
|
||||||
variables = {
|
|
||||||
"customerId": "external",
|
|
||||||
"isExternal": True,
|
|
||||||
"orderLines": [{"productId": product.id, "amount": 1}],
|
|
||||||
}
|
|
||||||
query = """
|
|
||||||
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
|
|
||||||
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
|
|
||||||
... on CreateOrderSuccess {
|
|
||||||
order {
|
|
||||||
id
|
|
||||||
orderSum
|
|
||||||
orderlines {
|
|
||||||
id
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
res = gql_client.query(query, variables)
|
|
||||||
assert res
|
|
||||||
assert not res.errors
|
|
||||||
assert res.data
|
|
||||||
order_lines = res.data.get("createOrder", {}).get("order", {}).get("orderlines", [])
|
|
||||||
assert order_lines
|
|
||||||
assert order_lines[0]["product"]["id"] == str(product.id)
|
|
||||||
assert Order.objects.count() == 1
|
|
||||||
o = Order.objects.first()
|
|
||||||
assert o.orderlines.first().product.id == product.id
|
|
||||||
-30
@@ -1,30 +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 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)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
def format_username(firstname: str, lastname: str):
|
|
||||||
first = firstname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
|
|
||||||
last = lastname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
|
|
||||||
|
|
||||||
return f"{first}{last}"
|
|
||||||
Whitespace-only changes.
@@ -1,144 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.shortcuts import redirect, render
|
|
||||||
|
|
||||||
from bbs.forms import DateRangeForm, InventoryTransactionForm
|
|
||||||
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
|
|
||||||
|
|
||||||
|
|
||||||
def report(request):
|
|
||||||
# FIXME: split this up in smaller pieces
|
|
||||||
datetime_now = datetime.now() # noqa: DTZ005
|
|
||||||
start_time = datetime.now() - timedelta(days=30) # noqa: DTZ005
|
|
||||||
# start of day, 1 month
|
|
||||||
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
# end of day
|
|
||||||
end_time = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999) # noqa: DTZ005
|
|
||||||
|
|
||||||
if request.GET.get("start_time", False) or request.GET.get("end_time", False):
|
|
||||||
form = DateRangeForm(request.GET)
|
|
||||||
if form.is_valid():
|
|
||||||
start_time = form.cleaned_data["start_time"]
|
|
||||||
# end of day
|
|
||||||
end_time = form.cleaned_data["end_time"].replace(hour=23, minute=59, second=59, microsecond=999)
|
|
||||||
|
|
||||||
else:
|
|
||||||
form = DateRangeForm(
|
|
||||||
initial={
|
|
||||||
"start_time": start_time.strftime("%Y-%m-%d"),
|
|
||||||
"end_time": end_time.strftime("%Y-%m-%d"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Inventory
|
|
||||||
products = (
|
|
||||||
Product.objects.filter(transactions__created__range=[start_time, end_time])
|
|
||||||
.values("name", "transactions__unit_price", "transactions__amount")
|
|
||||||
.order_by("name")
|
|
||||||
)
|
|
||||||
# group by name and sum
|
|
||||||
inventory_products = {}
|
|
||||||
for k, g in groupby(products, lambda x: x["name"]):
|
|
||||||
group = []
|
|
||||||
for el in g:
|
|
||||||
el["transactions__price"] = el["transactions__amount"] * el["transactions__unit_price"]
|
|
||||||
group.append(el)
|
|
||||||
inventory_products.update(
|
|
||||||
{
|
|
||||||
k: {
|
|
||||||
"name": k,
|
|
||||||
"transactions": group,
|
|
||||||
"transactions_sum": sum(x["transactions__price"] for x in group),
|
|
||||||
"transactions_units": sum(x["transactions__amount"] for x in group),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
|
|
||||||
inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items())
|
|
||||||
|
|
||||||
# out
|
|
||||||
out_products = (
|
|
||||||
Product.objects.filter(orderlines__order__created__range=[start_time, end_time])
|
|
||||||
.values("name", "orderlines__unit_price", "orderlines__amount")
|
|
||||||
.order_by("name")
|
|
||||||
)
|
|
||||||
# group by name and sum
|
|
||||||
for k, g in groupby(out_products, lambda x: x["name"]):
|
|
||||||
group = []
|
|
||||||
for el in g:
|
|
||||||
el["orderlines__price"] = el["orderlines__amount"] * el["orderlines__unit_price"]
|
|
||||||
group.append(el)
|
|
||||||
|
|
||||||
product = {
|
|
||||||
"name": k,
|
|
||||||
"orderlines": group,
|
|
||||||
"orderlines_sum": sum(x["orderlines__price"] for x in group),
|
|
||||||
"orderlines_units": sum(x["orderlines__amount"] for x in group),
|
|
||||||
}
|
|
||||||
if k in inventory_products:
|
|
||||||
# add diff
|
|
||||||
inventory_products[k].update(product)
|
|
||||||
else:
|
|
||||||
inventory_products.update({k: product})
|
|
||||||
|
|
||||||
for p in inventory_products.items():
|
|
||||||
p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0)
|
|
||||||
p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0)
|
|
||||||
|
|
||||||
inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items())
|
|
||||||
|
|
||||||
inv_diff = inv_in - inv_out
|
|
||||||
|
|
||||||
# Deposits and sales
|
|
||||||
saldo_profiles = sum(UserProfile.objects.all().values_list("balance", flat=True))
|
|
||||||
deposits = sum(Transaction.objects.all().values_list("amount", flat=True))
|
|
||||||
purchases = sum(Order.objects.all().values_list("order_sum", flat=True))
|
|
||||||
saldo = deposits - purchases
|
|
||||||
|
|
||||||
deposits_start = sum(Transaction.objects.filter(created__lt=start_time).values_list("amount", flat=True))
|
|
||||||
purchases_start = sum(Order.objects.filter(created__lt=start_time).values_list("order_sum", flat=True))
|
|
||||||
saldo_start = deposits_start - purchases_start
|
|
||||||
|
|
||||||
deposits_end = sum(Transaction.objects.filter(created__lt=end_time).values_list("amount", flat=True))
|
|
||||||
purchases_end = sum(Order.objects.filter(created__lt=end_time).values_list("order_sum", flat=True))
|
|
||||||
saldo_end = deposits_end - purchases_end
|
|
||||||
|
|
||||||
deposits_diff = deposits_end - deposits_start
|
|
||||||
purchases_diff = purchases_end - purchases_start
|
|
||||||
saldo_diff = saldo_end - saldo_start
|
|
||||||
|
|
||||||
return render(request, "report.html", locals()) # noqa: DJ003
|
|
||||||
|
|
||||||
|
|
||||||
def inventory(request):
|
|
||||||
products = Product.objects.all().order_by("-active", "inventory_amount", "name")
|
|
||||||
transactions = InventoryTransaction.objects.all().order_by("-created")
|
|
||||||
products_active = products.filter(active=True)
|
|
||||||
wholesale_value = sum([p.wholesale_value for p in products_active])
|
|
||||||
num_products = sum(products_active.values_list("inventory_amount", flat=True))
|
|
||||||
|
|
||||||
return render(request, "inventory.html", locals()) # noqa: DJ003
|
|
||||||
|
|
||||||
|
|
||||||
def inventory_add(request):
|
|
||||||
if request.method == "POST":
|
|
||||||
form = InventoryTransactionForm(data=request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
itrans = form.save(commit=False)
|
|
||||||
# Add amount and user, then save
|
|
||||||
itrans.product.inventory_amount += itrans.amount
|
|
||||||
itrans.user = request.user
|
|
||||||
itrans.product.save()
|
|
||||||
itrans.save()
|
|
||||||
messages.success(request, f"Satt inn {itrans.amount} stk {itrans.product}.")
|
|
||||||
return redirect("inventory-add")
|
|
||||||
|
|
||||||
messages.error(request, "Feil med skjemaet, se under.")
|
|
||||||
form = InventoryTransactionForm(data=request.POST)
|
|
||||||
else:
|
|
||||||
form = InventoryTransactionForm()
|
|
||||||
|
|
||||||
return render(request, "inventory_add.html", locals()) # noqa: DJ003
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbs.settings")
|
|
||||||
|
|
||||||
application = get_wsgi_application()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
cd frontend
|
|
||||||
npm i
|
|
||||||
npm run build
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
source "$HOME/.local/bin/env"
|
|
||||||
|
|
||||||
export VITE_API_URL="https://bbs.neuf.no/api"
|
|
||||||
PROJECT_PATH=/var/www/neuf.no/bbs
|
|
||||||
PROCESS_NAME="bbs.neuf.no"
|
|
||||||
|
|
||||||
echo "Deploying bbs to ${PROJECT_PATH}"
|
|
||||||
cd "$PROJECT_PATH" || exit 1
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# Backend
|
|
||||||
uv sync
|
|
||||||
umask 022; uv run manage.py collectstatic --noinput # Collect static
|
|
||||||
uv run manage.py migrate # Run DB migrations
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
bin/build-frontend
|
|
||||||
|
|
||||||
# Reload backend
|
|
||||||
echo "Reloading supervisor process ${PROCESS_NAME}"
|
|
||||||
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
python manage.py export_schema
|
|
||||||
cd frontend && npm run generate
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
uvx pre-commit run --all-files
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cd frontend && npm run start
|
|
||||||
|
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from os.path import dirname, join
|
||||||
|
|
||||||
|
def map_path(target_name=''):
|
||||||
|
'''Enables path names to be decided at runtime.'''
|
||||||
|
return join(dirname(__file__), target_name).replace('\\', '/')
|
||||||
|
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'settings'
|
||||||
|
|
||||||
|
path = map_path(".")
|
||||||
|
if path not in sys.path:
|
||||||
|
sys.path.append(path)
|
||||||
|
import django.core.handlers.wsgi
|
||||||
|
application = django.core.handlers.wsgi.WSGIHandler()
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
|
||||||
mysql:
|
|
||||||
image: mysql
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: dev
|
|
||||||
MYSQL_DATABASE: bbs
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
|
||||||
- mydata:/var/lib/mysql
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mydata:
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
src/types.ts
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
},
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"plugin:prettier/recommended",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:import/errors",
|
|
||||||
"plugin:import/warnings",
|
|
||||||
"plugin:import/typescript",
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
"no-console": "off",
|
|
||||||
"prefer-destructuring": "off",
|
|
||||||
"import/no-extraneous-dependencies": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
devDependencies: ["**/*.test.js", "**/*.spec.js", "**/__tests__/**"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"import/prefer-default-export": "off",
|
|
||||||
"import/order": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
groups: ["builtin", "external", "internal", ["parent", "sibling", "index"]],
|
|
||||||
"newlines-between": "always",
|
|
||||||
alphabetize: { order: "asc" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react-hooks/exhaustive-deps": "error",
|
|
||||||
"react-hooks/rules-of-hooks": "error",
|
|
||||||
"react/button-has-type": "error",
|
|
||||||
"react/jsx-filename-extension": ["warn", { extensions: [".js", ".jsx", ".ts", ".tsx"] }],
|
|
||||||
"react/jsx-uses-react": "off",
|
|
||||||
"react/prefer-stateless-function": "warn",
|
|
||||||
"react/prop-types": "off",
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
"react/sort-comp": "off",
|
|
||||||
},
|
|
||||||
plugins: ["prettier", "react", "react-hooks"],
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: "detect",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bbs-schema",
|
|
||||||
"schemaPath": "../schema.graphql",
|
|
||||||
"extensions": {
|
|
||||||
"endpoints": {
|
|
||||||
"default": {
|
|
||||||
"url": "http://localhost:8000/graphql/",
|
|
||||||
"introspect": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
engine-strict=true
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
v20
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 120
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
schema: ../schema.graphql
|
|
||||||
documents: ./src/**/*.graphql
|
|
||||||
generates:
|
|
||||||
./src/types.ts:
|
|
||||||
plugins:
|
|
||||||
- typescript
|
|
||||||
- typescript-operations
|
|
||||||
- typed-document-node
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>BBS</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Generated
-21988
File diff suppressed because it is too large.
Load diff
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bbs",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"check-types": "tsc",
|
|
||||||
"dev": "vite",
|
|
||||||
"format:js": "eslint --fix src",
|
|
||||||
"format:styles": "stylelint --fix src",
|
|
||||||
"generate": "graphql-codegen",
|
|
||||||
"lint:js": "eslint src",
|
|
||||||
"lint:styles": "stylelint src",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"start": "vite",
|
|
||||||
"test": "jest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@apollo/client": "^3.5.10",
|
|
||||||
"@nivo/bar": "^0.79.1",
|
|
||||||
"@nivo/line": "^0.79.1",
|
|
||||||
"@nivo/pie": "^0.79.1",
|
|
||||||
"clsx": "^1.1.1",
|
|
||||||
"date-fns": "^2.28.0",
|
|
||||||
"graphql": "^16.3.0",
|
|
||||||
"react": "^17.0.2",
|
|
||||||
"react-dom": "^17.0.2",
|
|
||||||
"react-hook-form": "^7.30.0",
|
|
||||||
"react-router-dom": "^6.3.0",
|
|
||||||
"use-url-search-params": "^2.5.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@graphql-codegen/cli": "^2.6.2",
|
|
||||||
"@graphql-codegen/typed-document-node": "^2.2.8",
|
|
||||||
"@graphql-codegen/typescript-operations": "^2.3.5",
|
|
||||||
"@graphql-typed-document-node/core": "^3.1.1",
|
|
||||||
"@types/react": "^17.0.44",
|
|
||||||
"@types/react-dom": "^17.0.15",
|
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
|
||||||
"@typescript-eslint/parser": "^5.19.0",
|
|
||||||
"@vitejs/plugin-react": "^1.3.1",
|
|
||||||
"@vitejs/plugin-react-refresh": "^1.3.6",
|
|
||||||
"eslint": "^8.13.0",
|
|
||||||
"eslint-config-prettier": "^8.5.0",
|
|
||||||
"eslint-plugin-import": "^2.26.0",
|
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
|
||||||
"eslint-plugin-react": "^7.29.4",
|
|
||||||
"eslint-plugin-react-hooks": "^4.4.0",
|
|
||||||
"jest": "^27.5.1",
|
|
||||||
"prettier": "^2.6.2",
|
|
||||||
"stylelint": "^14.7.0",
|
|
||||||
"stylelint-config-idiomatic-order": "^8.1.0",
|
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
|
||||||
"stylelint-config-recommended": "^7.0.0",
|
|
||||||
"stylelint-order": "^5.0.0",
|
|
||||||
"stylelint-prettier": "^2.0.0",
|
|
||||||
"typescript": "^4.6.3",
|
|
||||||
"vite": "^2.9.5",
|
|
||||||
"vite-plugin-svgr": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.app {
|
|
||||||
margin: 1rem auto;
|
|
||||||
max-width: 1220px;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ApolloProvider } from "@apollo/client";
|
|
||||||
import React from "react";
|
|
||||||
import { BrowserRouter as Router } from "react-router-dom";
|
|
||||||
|
|
||||||
import styles from "./App.module.css";
|
|
||||||
import { Routes } from "./Routes";
|
|
||||||
import client from "./client";
|
|
||||||
import { Footer } from "./components/Footer";
|
|
||||||
import { Header } from "./components/Header";
|
|
||||||
import { Toast } from "./components/Toast";
|
|
||||||
import { ToastProvider } from "./components/ToastProvider";
|
|
||||||
import { Wireframe } from "./components/Wireframe";
|
|
||||||
|
|
||||||
const App = (): JSX.Element => (
|
|
||||||
<ApolloProvider client={client}>
|
|
||||||
<Router>
|
|
||||||
<ToastProvider>
|
|
||||||
<Header />
|
|
||||||
<div className={styles.app}>
|
|
||||||
<Routes />
|
|
||||||
<Wireframe />
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
<Toast />
|
|
||||||
</ToastProvider>
|
|
||||||
</Router>
|
|
||||||
</ApolloProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
|
|
||||||
|
|
||||||
const Splash = React.lazy(() => import("./components/Splash"));
|
|
||||||
const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
|
|
||||||
const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
|
|
||||||
const Log = React.lazy(() => import("./features/log/Log"));
|
|
||||||
const Register = React.lazy(() => import("./features/register/Register"));
|
|
||||||
const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
|
|
||||||
const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
|
|
||||||
const Stats = React.lazy(() => import("./features/stats/Stats"));
|
|
||||||
const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
|
|
||||||
const UserList = React.lazy(() => import("./features/users/UserList"));
|
|
||||||
|
|
||||||
export const Routes = (): JSX.Element => (
|
|
||||||
<React.Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<ReactRouterRoutes>
|
|
||||||
<Route path="/deposit" element={<Deposit />} />
|
|
||||||
<Route path="/log" element={<Log />} />
|
|
||||||
<Route path="/stats" element={<Stats />} />
|
|
||||||
<Route path="/stats/orders" element={<OrderStats />} />
|
|
||||||
<Route path="/stats/products" element={<ProductStats />} />
|
|
||||||
<Route path="/users" element={<UserList />} />
|
|
||||||
<Route path="/users/create" element={<UserCreate />} />
|
|
||||||
<Route path="/user/:userId" element={<UserDetail />} />
|
|
||||||
<Route path="/" element={<Register />} />
|
|
||||||
<Route path="*" element={<Splash brandText="404" noOverlay />} />
|
|
||||||
</ReactRouterRoutes>
|
|
||||||
</React.Suspense>
|
|
||||||
);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<g id="g5" transform="matrix(1.301558, 0, 0, 1.301558, -16.927717, 11.280891)" style="">
|
|
||||||
<g id="g7">
|
|
||||||
<g id="g9">
|
|
||||||
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect11" style="fill:#fed600"/>
|
|
||||||
</g>
|
|
||||||
<defs id="defs13">
|
|
||||||
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089001" height="27.089001">
|
|
||||||
<feFlood result="back" id="feFlood16"/>
|
|
||||||
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend18"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<mask maskUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089" height="27.089" id="SVGID_1_">
|
|
||||||
<g id="g21" style="filter:url(#Adobe_OpacityMaskFilter)">
|
|
||||||
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,10.3325)" id="image23" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 FX+uRPy5Prky5c/8H//Z"/>
|
|
||||||
</g>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#SVGID_1_)" id="g25" style="opacity:0.46000001">
|
|
||||||
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect27" style="fill:#ffffff"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="g29">
|
|
||||||
<g id="g31">
|
|
||||||
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect33" style="fill:#fed600"/>
|
|
||||||
</g>
|
|
||||||
<defs id="defs35">
|
|
||||||
<filter id="Adobe_OpacityMaskFilter_1_" filterUnits="userSpaceOnUse" x="48.799" y="41.158001" width="27.089001" height="27.089001">
|
|
||||||
<feFlood result="back" id="feFlood38"/>
|
|
||||||
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend40"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<mask maskUnits="userSpaceOnUse" x="48.799" y="41.158" width="27.089" height="27.089" id="SVGID_2_">
|
|
||||||
<g id="g43" style="filter:url(#Adobe_OpacityMaskFilter_1_)">
|
|
||||||
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,39.1582)" id="image45" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 uztZ9R//2Q=="/>
|
|
||||||
</g>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#SVGID_2_)" id="g47" style="opacity:0.46000001">
|
|
||||||
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect49" style="fill:#ffffff"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="g51">
|
|
||||||
<g id="g53">
|
|
||||||
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect55" style="fill:#fed600"/>
|
|
||||||
</g>
|
|
||||||
<defs id="defs57">
|
|
||||||
<filter id="Adobe_OpacityMaskFilter_2_" filterUnits="userSpaceOnUse" x="78.223" y="41.158001" width="27.089001" height="27.089001">
|
|
||||||
<feFlood result="back" id="feFlood60"/>
|
|
||||||
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend62"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<mask maskUnits="userSpaceOnUse" x="78.223" y="41.158" width="27.089" height="27.089" id="SVGID_3_">
|
|
||||||
<g id="g65" style="filter:url(#Adobe_OpacityMaskFilter_2_)">
|
|
||||||
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,76.2231,39.1582)" id="image67" style="overflow:visible" xlink:href="data:image/jpeg;base64,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"/>
|
|
||||||
</g>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#SVGID_3_)" id="g69" style="opacity:0.46000001">
|
|
||||||
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect71" style="fill:#ffffff"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g id="g73">
|
|
||||||
<g id="g75">
|
|
||||||
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect77" style="fill:#fed600"/>
|
|
||||||
</g>
|
|
||||||
<defs id="defs79">
|
|
||||||
<filter id="Adobe_OpacityMaskFilter_3_" filterUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089001">
|
|
||||||
<feFlood result="back" id="feFlood82"/>
|
|
||||||
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend84"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<mask maskUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089" id="SVGID_4_">
|
|
||||||
<g id="g87" style="filter:url(#Adobe_OpacityMaskFilter_3_)">
|
|
||||||
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,17.6567,10.3325)" id="image89" style="overflow:visible" xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAgEBLAEsAAD/7AARRHVja3kAAQAEAAAAHgAA/+4AIUFkb2JlAGTAAAAAAQMA EAMCAwYAAAJ2AAAEbwAABov/2wCEABALCwsMCxAMDBAXDw0PFxsUEBAUGx8XFxcXFx8eFxoaGhoX Hh4jJSclIx4vLzMzLy9AQEBAQEBAQEBAQEBAQEABEQ8PERMRFRISFRQRFBEUGhQWFhQaJhoaHBoa JjAjHh4eHiMwKy4nJycuKzU1MDA1NUBAP0BAQEBAQEBAQEBAQP/CABEIAIUAhQMBIgACEQEDEQH/ xACSAAEAAgMBAQAAAAAAAAAAAAAABAUDBgcCAQEBAAAAAAAAAAAAAAAAAAAAABAAAAQEBQUBAQAA AAAAAAAAAAECBjIDBAUSFTUWRjARIhM0YCERAAECAgUMAgIDAAAAAAAAAAABAjEDkdEyMwUwESES E5Oz03TENYUEFEEiEFEVEgEAAAAAAAAAAAAAAAAAAABg/9oADAMBAAIRAxEAAACkhR8JOe5BETfp BThBThBTvhCTMRgYq43BWivnQbc2y19WhXfbEV6wFesBXLEVULYoxoel9F54XAK+6pbs6LPgzwAA ABiy4zVObdL5qW4K+7pLs6NPgTwAAABjyYzVua9K5qW4K+7pLs6NPgTwAAABjyYzVua9K5qW4K+7 pLs6NPgTwAAABjyYzVua9J5sW4K+5prQ6TZUNuSGP6e3ge3ge3j4ZMKGUHON90EuQV+fBCN8seb+ zpXrmf06W5oOluaDpfzmo6PE0LybJq3rCbADLCAAAAAAAC6B/9oACAECAAEFAPyP/9oACAEDAAEF APyP/9oACAEBAAEFAL06HBIvO7nKN3OUbuco3c5Ru5yjdzlG7nKN3OUbuco3c5Ru5yjdzlG7nKN3 OUbuco3Fe9nODXqWkOeJVhNYS2jME1zG1zG1jG1jG1jG1jG1zG1zBtkyC28aRPtRyiWnCrgTg16w ySWVDQJNMu2o7FbUDLkDLkDLkDLkDLkDLkA7agLtqBU25JFdaQkpqi7T+BODXmynuVulFhSkiLod iFTKI03uWRJrv5U8CcGvNYvG3l4dKeXhfC8K/wCrgTg15rQ2+DpToL7BcPq4E4NeasNvg6U2C+wX D6uBODXmrDb4OlNgvsFw+rgTg15qw2+DpTYL7BcPq4E4NeasNvg6U2C+wXD6uBODXmrDb4OlOgvs Fw+rgTg15rQ28/DpToL6fhX/AFcCcGvNc+ybcrw6VQrsm+K8K/6uBODXm4vCVvqSJMuoSZe1I9qR 7Ej2JHsSPYke1I9qQqekhVVJdrxOI01p96jgTg160VBSipLolJS7ykiK9oGdoGdoGdoGdoGdoGdo B3tAVekCfd0mVwriWVQrFN4E4NeRNWgFXVCQVyqiGZ1QzSrGaVYzSrGaVYzSrGaVYzSrGZ1QO4VJ hVVOUDPufAr0hrnecDSGBpDA0hgaQwNIYGkMDSGBpDA0hgaQwNIYGkMDSGBpDA0hhbuzv//aAAgB AgIGPwAj/9oACAEDAgY/ACP/2gAIAQEBBj8A+fIk/OmslSvkzmS2IqZmtbMcjUTR+EPIzqUqPIzq UqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPIzqUqPI zqUqPvfcmfa/0tjtc6a2z2GvqQhraTE+rn8R38QUgpBSCkFIKQUgpBSCkFIKKK3+j23bGJ9XP4jh BNBAgQIECBAgQIC6B2gch7btjE+rn8Rw0TJqOHntu2MT6ufxHDRMo4ee27YxPq5/EcNEyajh57bt jE+rn8Rw0TJqOHntu2MT6ufxHDRMmo4ee27YxPq5/EcNEyajh57btjE+rn8Rw0TJqOHntu2MT6uf xHDRMmo4ee27YxPq5/EcNEyajh57btjE+rn8Rw0TJqOHntu2MT6ufxHDRNJEiRIkSJEiRIi6Rw9T 23bGJ9XP4jhNIn7FotFotFotFotFotC/sLpHKe27YxPq5/EcfquY0OLRaLRaLRaLRaLRaNLjSpnU 9t2x8/bzfnpO+zO2qMlSVYj9o7W1VdNRc2eBfYjuZHOL7EdzI5xfYjuZHOL7EdzI5xfYjuZHOL7E dzI5xfYjuZHOL7EdzI5xfYjuZHOL7EdzI5xfYjuZHOL7EdzI5xfYjuZHOL7EdzI5xfYjuZHOM21+ Z9L/AErWzlbXa7CGrtNXV1fznz5/wf/Z"/>
|
|
||||||
</g>
|
|
||||||
</mask>
|
|
||||||
<g mask="url(#SVGID_4_)" id="g91" style="opacity:0.46000001">
|
|
||||||
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect93" style="fill:#ffffff"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<rect x="18.633" y="79.66" fill="none" width="615.332" height="68.885" id="rect95"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
|
|||||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
|
||||||
|
|
||||||
const client = new ApolloClient({
|
|
||||||
uri: `${import.meta.env.VITE_API_URL}/graphql/`,
|
|
||||||
cache: new InMemoryCache(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default client;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.errorMessage {
|
|
||||||
font-family: Rocket Rinder, monospace;
|
|
||||||
font-size: 3rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--brand);
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 30vh;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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,94 +0,0 @@
|
|||||||
.header {
|
|
||||||
margin: 1rem auto;
|
|
||||||
max-width: 1220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
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,55 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
import { NavLink } from "react-router-dom";
|
|
||||||
|
|
||||||
import styles from "./Header.module.css";
|
|
||||||
|
|
||||||
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
|
|
||||||
|
|
||||||
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
|
|
||||||
|
|
||||||
export const Header = (): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<header className={styles.header}>
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
<NavLink to="/" className={styles.logoNav}>
|
|
||||||
<div className={styles.brand}>BBS</div>
|
|
||||||
<div className={styles.subtitle}>Biceps Bar System</div>
|
|
||||||
</NavLink>
|
|
||||||
<ul className={styles.navItems}>
|
|
||||||
<li>
|
|
||||||
<NavLink to="/" className={navLinkStyles} end>
|
|
||||||
Kjøp drikke
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NavLink to="/deposit" className={navLinkStyles}>
|
|
||||||
Sett inn penger
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NavLink to="/log" className={navLinkStyles}>
|
|
||||||
Logg
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<NavLink to="/stats" className={navLinkStyles}>
|
|
||||||
Statistikk
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<NavLink to="/users" className={navLinkStyles}>
|
|
||||||
Folk
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href={ADMIN_URL} className={styles.navLink}>
|
|
||||||
Admin
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
.toast {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 32px;
|
|
||||||
left: 32px;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
|
|
||||||
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
|
|
||||||
}
|
|
||||||
.danger {
|
|
||||||
background: var(--brand);
|
|
||||||
box-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import styles from "./Toast.module.css";
|
|
||||||
import { useToast } from "./ToastProvider";
|
|
||||||
|
|
||||||
export const Toast = (): JSX.Element => {
|
|
||||||
const { showToast, message, background, visible } = useToast();
|
|
||||||
|
|
||||||
const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes} role="alert" aria-live="assertive" aria-atomic="true">
|
|
||||||
<div>{message}</div>
|
|
||||||
<button type="button" aria-label="Close" onClick={() => showToast(message)} className={styles.close} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import React, { ReactNode, useContext, useState } from "react";
|
|
||||||
|
|
||||||
type Background = "primary" | "danger";
|
|
||||||
|
|
||||||
interface ToastProps {
|
|
||||||
visible: boolean;
|
|
||||||
message: string;
|
|
||||||
showToast: (message: string, background?: Background) => void;
|
|
||||||
hide: () => void;
|
|
||||||
background: Background;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultState = {
|
|
||||||
// FIXME: revert
|
|
||||||
visible: false,
|
|
||||||
message: "",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
showToast: () => {},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
hide: () => {},
|
|
||||||
background: "primary" as Background,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ToastContext = React.createContext<ToastProps>(defaultState);
|
|
||||||
|
|
||||||
type ToastProviderProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
|
|
||||||
const [visible, setVisible] = useState(defaultState.visible);
|
|
||||||
const [message, setMessage] = useState(defaultState.message);
|
|
||||||
const [background, setBackground] = useState(defaultState.background);
|
|
||||||
|
|
||||||
const showToast = (newMessage: string, background: Background = "primary") => {
|
|
||||||
setMessage(newMessage);
|
|
||||||
setBackground(background);
|
|
||||||
if (!visible) {
|
|
||||||
setVisible(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setVisible(false);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const hide = () => {
|
|
||||||
setVisible(true);
|
|
||||||
setMessage("");
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useToast = (): ToastProps => useContext(ToastContext);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
max-width: 500px;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
|
||||||
import React from "react";
|
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { CreateUserDocument } from "../../types";
|
|
||||||
import styles from "./UserCreate.module.css";
|
|
||||||
|
|
||||||
type FormValues = {
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const UserCreate = () => {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormValues>();
|
|
||||||
const [createUser, { loading: creating }] = useMutation(CreateUserDocument);
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<FormValues> = async (data) => {
|
|
||||||
try {
|
|
||||||
await createUser({ variables: data });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("woops", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2>Opprett ny</h2>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
|
||||||
<label>Fornavn</label>
|
|
||||||
<input {...register("firstName", { required: true })} />
|
|
||||||
{errors.firstName && <span>This field is required</span>}
|
|
||||||
<label>Etternavn</label>
|
|
||||||
<input {...register("lastName", { required: true })} />
|
|
||||||
{errors.lastName && <span>This field is required</span>}
|
|
||||||
<label>E-post</label>
|
|
||||||
<input type="email" {...register("email", { required: true })} />
|
|
||||||
{errors.email && <span>This field is required</span>}
|
|
||||||
<input type="submit" disabled={creating} className="btn-primary" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default UserCreate;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
mutation CreateUser($firstName: String!, $lastName: String!, $email: String!) {
|
|
||||||
createUser(input: {firstName: $firstName, lastName: $lastName, email: $email}) {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
.userList {
|
|
||||||
display: grid;
|
|
||||||
height: 800px;
|
|
||||||
padding-top: 2rem;
|
|
||||||
grid-template-columns: repeat(5, 220px);
|
|
||||||
overflow-y: auto;
|
|
||||||
column-gap: 16px;
|
|
||||||
row-gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amountSection {
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkout {
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import { UserCard } from "../users/UserCard";
|
|
||||||
import styles from "./Deposit.module.css";
|
|
||||||
import { useDeposit } from "./useDeposit";
|
|
||||||
|
|
||||||
const Deposit = (): JSX.Element => {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
selectedUser,
|
|
||||||
setSelectedUser,
|
|
||||||
amount,
|
|
||||||
setAmount,
|
|
||||||
userQuery,
|
|
||||||
setUserQuery,
|
|
||||||
filteredUsers,
|
|
||||||
reset,
|
|
||||||
onSubmit,
|
|
||||||
} = useDeposit();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<section>
|
|
||||||
<h2>Hvem skal sette inn penger?</h2>
|
|
||||||
<input
|
|
||||||
name="userQuery"
|
|
||||||
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
|
|
||||||
value={userQuery}
|
|
||||||
placeholder="Filtrér på navn"
|
|
||||||
/>
|
|
||||||
{userQuery !== "" && (
|
|
||||||
<button type="button" onClick={() => setUserQuery("")}>
|
|
||||||
Vis alle
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className={styles.userList}>
|
|
||||||
{filteredUsers.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.id}
|
|
||||||
active={selectedUser === user.id}
|
|
||||||
onUser={() => {
|
|
||||||
setSelectedUser(user.id);
|
|
||||||
}}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.amountSection}>
|
|
||||||
<h2>Hvor mye?</h2>
|
|
||||||
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" />
|
|
||||||
</section>
|
|
||||||
<section className={styles.checkout}>
|
|
||||||
<button type="button" onClick={onSubmit} className="primary">
|
|
||||||
Sett inn
|
|
||||||
</button>{" "}
|
|
||||||
<button type="button" onClick={reset}>
|
|
||||||
Start på nytt
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Deposit;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
mutation CreateDeposit($userId: ID!, $amount: Int!) {
|
|
||||||
createDeposit(userId: $userId, amount:$amount) {
|
|
||||||
... on CreateDepositSuccess {
|
|
||||||
transaction {
|
|
||||||
id
|
|
||||||
amount
|
|
||||||
user {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
balance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
... on AboveMaxSaldo {
|
|
||||||
error
|
|
||||||
message
|
|
||||||
maxSaldo
|
|
||||||
above
|
|
||||||
}
|
|
||||||
... on FormErrors {
|
|
||||||
error
|
|
||||||
message
|
|
||||||
fields {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { useToast } from "../../components/ToastProvider";
|
|
||||||
import { AllUsersDocument, CreateDepositDocument } from "../../types";
|
|
||||||
|
|
||||||
export const useDeposit = () => {
|
|
||||||
const { data, loading } = useQuery(AllUsersDocument);
|
|
||||||
const [createDeposit] = useMutation(CreateDepositDocument);
|
|
||||||
const [selectedUser, setSelectedUser] = useState("");
|
|
||||||
const [amount, setAmount] = useState("");
|
|
||||||
const [userQuery, setUserQuery] = useState("");
|
|
||||||
const filteredUsers = (data?.allUsers || []).filter((user) =>
|
|
||||||
`${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
const { showToast } = useToast();
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
const amountInt = parseInt(amount, 10) || 0;
|
|
||||||
const variables = { userId: selectedUser, amount: amountInt };
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await createDeposit({ variables });
|
|
||||||
} catch (e) {
|
|
||||||
console.log("woops", e);
|
|
||||||
showToast("Kunne ikke sette inn penger", "danger");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
|
|
||||||
const { transaction } = res.data?.createDeposit;
|
|
||||||
showToast(
|
|
||||||
`${transaction.user.firstName} har satt inn${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`
|
|
||||||
);
|
|
||||||
reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.data?.createDeposit?.__typename === "FormErrors") {
|
|
||||||
showToast(res.data?.createDeposit.message, "danger");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") {
|
|
||||||
showToast(
|
|
||||||
`${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`,
|
|
||||||
"danger"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const reset = () => {
|
|
||||||
setSelectedUser("");
|
|
||||||
setAmount("");
|
|
||||||
setUserQuery("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
selectedUser,
|
|
||||||
setSelectedUser,
|
|
||||||
amount,
|
|
||||||
setAmount,
|
|
||||||
userQuery,
|
|
||||||
setUserQuery,
|
|
||||||
filteredUsers,
|
|
||||||
reset,
|
|
||||||
onSubmit,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
.log {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
.disabled,
|
|
||||||
.disabled a:link {
|
|
||||||
color: rgba(121, 152, 238, 0.5);
|
|
||||||
}
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
gap: 0 1rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination a {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
margin-top: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.pagination a:hover {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled a:hover {
|
|
||||||
color: rgba(121, 152, 238, 0.5);
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
|
||||||
import { useUrlSearchParams } from "use-url-search-params";
|
|
||||||
|
|
||||||
import { OrderListDocument, TransactionListDocument } from "../../types";
|
|
||||||
import { formatTime } from "../../utils/time";
|
|
||||||
import styles from "./Log.module.css";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
|
||||||
|
|
||||||
const Log = () => {
|
|
||||||
const types = {
|
|
||||||
orderOffset: Number,
|
|
||||||
transactionOffset: Number,
|
|
||||||
};
|
|
||||||
const [params, setParams] = useUrlSearchParams({}, types);
|
|
||||||
const orderOffset = Number(params.orderOffset) || 0;
|
|
||||||
const transactionOffset = Number(params.transactionOffset) || 0;
|
|
||||||
const { data: ordersData, loading: ordersLoading } = useQuery(OrderListDocument, {
|
|
||||||
variables: { offset: orderOffset },
|
|
||||||
});
|
|
||||||
const { data: transactionsData, loading: transactionsLoading } = useQuery(TransactionListDocument, {
|
|
||||||
variables: { offset: transactionOffset },
|
|
||||||
});
|
|
||||||
const loading = ordersLoading || transactionsLoading;
|
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
|
||||||
|
|
||||||
const orders = ordersData?.orderList || [];
|
|
||||||
const transactions = transactionsData?.transactionList || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.log}>
|
|
||||||
<div>
|
|
||||||
<h2>Ordre</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Når</th>
|
|
||||||
<th scope="col">Hvem</th>
|
|
||||||
<th scope="col">Hva</th>
|
|
||||||
<th scope="col">Sum</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{orders.map((order) => (
|
|
||||||
<tr key={order.id}>
|
|
||||||
<td>{formatTime(order.created)}</td>
|
|
||||||
<td>{order.isExternal ? "Ekstern" : `${order.customer?.firstName} ${order.customer?.lastName}`}</td>
|
|
||||||
<td>
|
|
||||||
{(order.orderlines ?? []).map((line) => (
|
|
||||||
<div key={line.id}>
|
|
||||||
{Number(line.amount)}x {line.product.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
<td>{Number(order.orderSum)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<nav aria-label="Order pagination">
|
|
||||||
<ul className={styles.pagination}>
|
|
||||||
<li className={clsx({ [styles.disabled]: orderOffset <= 0 })}>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setParams({ orderOffset: Math.max(0, orderOffset - PAGE_SIZE) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⬅
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setParams({ orderOffset: Math.max(0, orderOffset + PAGE_SIZE) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
➡
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>Transaksjoner</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Når</th>
|
|
||||||
<th scope="col">Hvem</th>
|
|
||||||
<th scope="col">Innskudd</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{transactions.map((trans) => (
|
|
||||||
<tr key={trans.id}>
|
|
||||||
<td>{formatTime(trans.created)}</td>
|
|
||||||
<td>
|
|
||||||
{trans.user.firstName} {trans.user.lastName}
|
|
||||||
</td>
|
|
||||||
<td>{Number(trans.amount)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<nav aria-label="Transaction pagination">
|
|
||||||
<ul className={styles.pagination}>
|
|
||||||
<li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setParams({ transactionOffset: Math.max(0, transactionOffset - PAGE_SIZE) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⬅
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setParams({ transactionOffset: Math.max(transactionOffset + PAGE_SIZE) });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
➡
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Log;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
query orderList($limit: Int = 10, $offset: Int = 0) {
|
|
||||||
orderList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
|
|
||||||
id
|
|
||||||
isExternal
|
|
||||||
created
|
|
||||||
orderSum
|
|
||||||
customer {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
balance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
orderlines {
|
|
||||||
id
|
|
||||||
amount
|
|
||||||
unitPrice
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
salePriceExt
|
|
||||||
salePriceInt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
query transactionList($limit: Int = 10, $offset: Int = 0) {
|
|
||||||
transactionList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
|
|
||||||
id
|
|
||||||
created
|
|
||||||
amount
|
|
||||||
user {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
balance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,22 +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 = ({ 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,34 +0,0 @@
|
|||||||
.userList {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 220px);
|
|
||||||
column-gap: 16px;
|
|
||||||
row-gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showAll {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.productList {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 220px);
|
|
||||||
column-gap: 16px;
|
|
||||||
row-gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.productListSection {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkout {
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 200px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin-right: 8px;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import { OrderLineInput, Product } from "../../types";
|
|
||||||
import { UserCard } from "../users/UserCard";
|
|
||||||
import { ProductCard } from "./ProductCard";
|
|
||||||
import styles from "./Register.module.css";
|
|
||||||
import { useRegister } from "./useRegister";
|
|
||||||
|
|
||||||
const USER_EXTERNAL = "external";
|
|
||||||
|
|
||||||
const Register = (): JSX.Element => {
|
|
||||||
const {
|
|
||||||
users,
|
|
||||||
products,
|
|
||||||
loading,
|
|
||||||
showAll,
|
|
||||||
setShowAll,
|
|
||||||
setSelectedUser,
|
|
||||||
selectedUser,
|
|
||||||
addToOrder,
|
|
||||||
order,
|
|
||||||
total,
|
|
||||||
onSubmit,
|
|
||||||
reset,
|
|
||||||
} = useRegister();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<section>
|
|
||||||
<h2>Hvem kjøper?</h2>
|
|
||||||
<div className={styles.userList}>
|
|
||||||
<UserCard
|
|
||||||
key={USER_EXTERNAL}
|
|
||||||
user={null}
|
|
||||||
onUser={() => setSelectedUser(USER_EXTERNAL)}
|
|
||||||
active={selectedUser == USER_EXTERNAL}
|
|
||||||
/>
|
|
||||||
{users.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.id}
|
|
||||||
user={user}
|
|
||||||
onUser={() => setSelectedUser(user.id)}
|
|
||||||
active={selectedUser == user.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => setShowAll(!showAll)} className={styles.showAll}>
|
|
||||||
{showAll ? "Skjul gamliser" : "Vis alle"}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
<section className={styles.productListSection}>
|
|
||||||
<h2>Kjøper hva?</h2>
|
|
||||||
<div className={styles.productList}>
|
|
||||||
{products.map((product: Omit<Product, "userCounts">) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
onProduct={() => addToOrder(product.id)}
|
|
||||||
amount={order.find((orderLine: OrderLineInput) => orderLine.productId === product.id)?.amount || 0}
|
|
||||||
active={order.some((orderLine: OrderLineInput) => orderLine.productId === product.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className={styles.checkout}>
|
|
||||||
<span className={styles.total}>
|
|
||||||
Totalt: <span>{total}</span>
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onSubmit} className="primary">
|
|
||||||
Kjøp
|
|
||||||
</button>{" "}
|
|
||||||
<button type="button" onClick={reset}>
|
|
||||||
Start på nytt
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Register;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
query allProducts($active: Boolean!) {
|
|
||||||
allProducts(filters: {active: $active}) {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
imageUrl
|
|
||||||
salePriceExt
|
|
||||||
salePriceInt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
query allUsers {
|
|
||||||
allUsers {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
balance
|
|
||||||
image
|
|
||||||
lastPurchaseDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
mutation CreateOrder($customerId: ID!, $orderLines:[OrderLineInput!]!, $isExternal: Boolean!) {
|
|
||||||
createOrder(customerId: $customerId, orderLines: $orderLines, isExternal: $isExternal) {
|
|
||||||
... on CreateOrderSuccess {
|
|
||||||
order {
|
|
||||||
id
|
|
||||||
isExternal
|
|
||||||
customer {
|
|
||||||
id
|
|
||||||
firstName
|
|
||||||
lastName
|
|
||||||
profile {
|
|
||||||
id
|
|
||||||
balance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
orderlines {
|
|
||||||
id
|
|
||||||
amount
|
|
||||||
unitPrice
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
salePriceExt
|
|
||||||
salePriceInt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
orderSum
|
|
||||||
}
|
|
||||||
}
|
|
||||||
... on InsufficientFunds {
|
|
||||||
error
|
|
||||||
message
|
|
||||||
amountLacking
|
|
||||||
}
|
|
||||||
... on FormErrors {
|
|
||||||
error
|
|
||||||
message
|
|
||||||
fields {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { useToast } from "../../components/ToastProvider";
|
|
||||||
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types";
|
|
||||||
import { activeUserFilter } from "../../utils/users";
|
|
||||||
import {formatCurrency} from "../../utils/currency";
|
|
||||||
|
|
||||||
const USER_EXTERNAL = "external";
|
|
||||||
|
|
||||||
const sumTotal = (products: Omit<Product, "userCounts">[], order: OrderLineInput[], isExternal: boolean) => {
|
|
||||||
const prices = order.map((orderLine: OrderLineInput) => {
|
|
||||||
const product = products.find((product: Omit<Product, "userCounts">) => product.id === orderLine.productId);
|
|
||||||
if (!product) return 0;
|
|
||||||
return orderLine.amount * (isExternal ? product.salePriceExt : product.salePriceInt);
|
|
||||||
});
|
|
||||||
return prices.reduce((partialSum, a) => partialSum + a, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRegister = () => {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const [selectedUser, setSelectedUser] = useState("");
|
|
||||||
const [order, setOrder] = useState<OrderLineInput[]>([]);
|
|
||||||
const { showToast } = useToast();
|
|
||||||
|
|
||||||
const { data, loading } = useQuery(AllProductsDocument, { variables: { active: true } });
|
|
||||||
const products = data?.allProducts || [];
|
|
||||||
|
|
||||||
const { data: usersData, loading: usersLoading } = useQuery(AllUsersDocument);
|
|
||||||
const allUsers = usersData?.allUsers || [];
|
|
||||||
const users = showAll ? allUsers : allUsers.filter(activeUserFilter);
|
|
||||||
|
|
||||||
const [createOrderMutation] = useMutation(CreateOrderDocument, {
|
|
||||||
variables: { customerId: selectedUser, isExternal: selectedUser === USER_EXTERNAL, orderLines: order },
|
|
||||||
});
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setOrder([]);
|
|
||||||
setSelectedUser("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const addToOrder = (productId: string) => {
|
|
||||||
let added = false;
|
|
||||||
const newOrder = order.map((orderLine: OrderLineInput) => {
|
|
||||||
if (orderLine.productId === productId) {
|
|
||||||
added = true;
|
|
||||||
return { ...orderLine, amount: orderLine.amount + 1 };
|
|
||||||
}
|
|
||||||
return orderLine;
|
|
||||||
});
|
|
||||||
if (!added) {
|
|
||||||
newOrder.push({ productId, amount: 1 });
|
|
||||||
}
|
|
||||||
setOrder(newOrder);
|
|
||||||
};
|
|
||||||
|
|
||||||
const total = formatCurrency(sumTotal(products, order, selectedUser === USER_EXTERNAL));
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await createOrderMutation();
|
|
||||||
} catch (e) {
|
|
||||||
showToast("Whoops");
|
|
||||||
console.log("woops");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { data } = res;
|
|
||||||
const createOrderResponse = data?.createOrder;
|
|
||||||
if (createOrderResponse?.__typename === "CreateOrderSuccess") {
|
|
||||||
const { order } = createOrderResponse;
|
|
||||||
const orderLines = (order?.orderlines || [])
|
|
||||||
.map((orderLine) => `${orderLine.amount} ${orderLine.product.name}`)
|
|
||||||
.join(", ");
|
|
||||||
const who = !order.customer ? "Ekstern" : `${order.customer?.firstName} ${order.customer?.lastName}`;
|
|
||||||
showToast(`${who} kjøpte: ${orderLines}`);
|
|
||||||
reset();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createOrderResponse?.__typename === "FormErrors") {
|
|
||||||
showToast(createOrderResponse.message, "danger");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createOrderResponse?.__typename === "InsufficientFunds") {
|
|
||||||
showToast(`${createOrderResponse.message} Du mangler ${createOrderResponse.amountLacking},-`, "danger");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
loading: loading || usersLoading,
|
|
||||||
users,
|
|
||||||
products,
|
|
||||||
showAll,
|
|
||||||
setShowAll,
|
|
||||||
setSelectedUser,
|
|
||||||
selectedUser,
|
|
||||||
addToOrder,
|
|
||||||
order,
|
|
||||||
total,
|
|
||||||
onSubmit,
|
|
||||||
reset,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
|
||||||
import { Bar, BarDatum } from "@nivo/bar";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { OrderStatsDocument, OrderStatsByTime } from "../../types";
|
|
||||||
import { StatsView } from "./StatsView";
|
|
||||||
|
|
||||||
const days = { 1: "Mandag", 2: "Tirsdag", 3: "Onsdag", 4: "Torsdag", 5: "Fredag", 6: "Lørdag", 7: "Søndag" };
|
|
||||||
|
|
||||||
const months = {
|
|
||||||
1: "Januar",
|
|
||||||
2: "Februar",
|
|
||||||
3: "Mars",
|
|
||||||
4: "April",
|
|
||||||
5: "Mai",
|
|
||||||
6: "Juni",
|
|
||||||
7: "Juli",
|
|
||||||
8: "August",
|
|
||||||
9: "September",
|
|
||||||
10: "Oktober",
|
|
||||||
11: "November",
|
|
||||||
12: "Desember",
|
|
||||||
};
|
|
||||||
|
|
||||||
const OrderBar = ({ data }: { data: BarDatum[] }) => (
|
|
||||||
<Bar
|
|
||||||
data={data}
|
|
||||||
margin={{ left: 60, bottom: 40 }}
|
|
||||||
width={1200}
|
|
||||||
height={400}
|
|
||||||
colorBy="indexValue"
|
|
||||||
enableLabel={false}
|
|
||||||
tooltipLabel={(d) => d.id.toString()}
|
|
||||||
axisLeft={{ legend: "Ordre", legendPosition: "middle", legendOffset: -50 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const OrderStats = () => {
|
|
||||||
const { data, loading } = useQuery(OrderStatsDocument);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatsView loading={loading}>
|
|
||||||
<h3>Ordre per time</h3>
|
|
||||||
<OrderBar
|
|
||||||
data={(data?.orderStats?.hourly || []).map((stat: OrderStatsByTime) => ({
|
|
||||||
id: `${stat.period.toString().padStart(2, "0")}:00`,
|
|
||||||
label: stat.period,
|
|
||||||
value: stat.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<h3>Ordre per ukedag</h3>
|
|
||||||
<OrderBar
|
|
||||||
data={(data?.orderStats?.daily || []).map((stat: OrderStatsByTime) => ({
|
|
||||||
/* @ts-expect-error Here, stat.period can only be a key in days range. */
|
|
||||||
id: days[stat.period],
|
|
||||||
label: stat.period,
|
|
||||||
value: stat.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<h3>Ordre per måned</h3>
|
|
||||||
<OrderBar
|
|
||||||
data={(data?.orderStats?.monthly || []).map((stat: OrderStatsByTime) => ({
|
|
||||||
/* @ts-expect-error Here, stat.period can only be a key in months range. */
|
|
||||||
id: months[stat.period],
|
|
||||||
label: stat.period,
|
|
||||||
value: stat.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<h3>Ordre per år</h3>
|
|
||||||
<OrderBar
|
|
||||||
data={(data?.orderStats?.yearly || []).map((stat: OrderStatsByTime) => ({
|
|
||||||
id: stat.period,
|
|
||||||
label: stat.period,
|
|
||||||
value: stat.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</StatsView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default OrderStats;
|
|
||||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user