Author SHA1 Message Date
Emma Eilefsen Glenna d5de4597c3 Fix: "Insufficient Funds" when user has exact amount 2024-02-15 19:36:04 +00:00
alve ff09985944 🥷 Hide users navigation tab 2024-02-15 19:29:13 +00:00
Emma Eilefsen Glenna d76c911d4b fix rendering of register total 2024-01-25 21:45:17 +01:00
Emma Eilefsen Glenna e60473e7ed fix rendering of user balance 2024-01-25 21:44:03 +01:00
Emma Eilefsen Glenna d596c31353 extract function 2024-01-25 21:43:32 +01:00
Emma Eilefsen Glenna 90558a3a28 add comment 2024-01-25 19:01:45 +00:00
alve 57a6c412d5 💄 Hide trailing decimal zeros 2024-01-25 19:01:45 +00:00
alve 19f58211e7 🗃 Make order sum a decimal field 2024-01-25 19:01:45 +00:00
nikolark 0726754678 💡 add some FIXMEs 2024-01-25 19:01:45 +00:00
nikolark 480fba870a 🗃 Replace float fields with int or decimals 2024-01-25 19:01:45 +00:00
alve ed08c60380 💄Fix favicon path 2023-07-19 19:46:01 +02:00
nikolark 79fe5cac0f 🔒 Audit 2022-04-17 21:29:02 +02:00
nikolark 95d7d13f38 ✨ Handle user not found 2022-04-17 21:13:22 +02:00
nikolark ce2cdfd409 ⚡ Code split routes 2022-04-17 16:59:00 +02:00
nikolark b56d103ae7 Merge branch 'feature/synthwave' into 'main'
✨ BBS 2222 synthwave theme

See merge request edb/bbs!3
2022-04-17 14:39:44 +00:00
29 changed files with 228 additions and 64 deletions

No files matched your search

+2 -1
View File
@@ -11,6 +11,7 @@ CACHE/
media/
tmp/
frontend/dist
static/modern
static/assets
static/index.html
.python-version
.env
+1
View File
@@ -26,6 +26,7 @@ npm start
- improve product listing (light purple background)
- Stats: products over time
- inventory views
- Stats: Implement users tab
## TODO-old
+1 -1
View File
@@ -66,7 +66,7 @@ def create_order(customer_id: ID, order_lines: List[OrderLineInput], is_external
product.inventory_amount -= ol.amount
product.save()
if not is_external and user.profile.balance <= order_sum:
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)
)
+15 -1
View File
@@ -1,4 +1,6 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import Extension
from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations
@@ -17,4 +19,16 @@ class Mutation(ShopMutations, UsersMutations):
pass
schema = strawberry.Schema(query=Query, mutation=Mutation)
class HandleNotFound(Extension):
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])
+2 -1
View File
@@ -1,5 +1,6 @@
from datetime import datetime
from typing import List
from decimal import Decimal
import strawberry
import strawberry_django
@@ -25,7 +26,7 @@ class UserProfile:
balance: auto
image: auto
last_purchase_date: datetime
order_sum_total: float
order_sum_total: Decimal
@strawberry.field()
def top_months(self) -> List[TopMonth]:
+3 -1
View File
@@ -2,9 +2,11 @@ from django import forms
from bbs.models import InventoryTransaction, Product
MAX_DIGITS = 9
DECIMAL_PLACES = 1
class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.FloatField(required=True, localize=True)
unit_price = forms.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, required=True, localize=True)
def __init__(self, *args, **kwargs):
# Only show active products
@@ -0,0 +1,63 @@
# Generated by Django 4.0.2 on 2022-03-05 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="inventorytransaction",
name="amount",
field=models.IntegerField(verbose_name="Antall"),
),
migrations.AlterField(
model_name="inventorytransaction",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="orderline",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="product",
name="alcohol_percent",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="product",
name="inventory_amount",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="product",
name="sale_price_ext",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="sale_price_int",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="volume_liter",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="userprofile",
name="balance",
field=models.DecimalField(decimal_places=1, default=0, max_digits=9),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
+20 -12
View File
@@ -2,6 +2,7 @@ import hashlib
from datetime import datetime
from itertools import groupby
from typing import Type
from decimal import Decimal
from django.conf import settings
from django.db import models
@@ -17,6 +18,8 @@ from django.db.models.functions import (
)
EXTERNAL_USER = "Ekstern"
MAX_DIGITS = 9
DECIMAL_PLACES = 1
def _format_counts(per_user):
@@ -36,14 +39,15 @@ def _format_counts(per_user):
class Product(models.Model):
name = models.CharField(max_length=64)
sale_price_int = models.FloatField()
sale_price_ext = models.FloatField()
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
description = models.CharField(max_length=256, blank=True)
volume_liter = models.FloatField(null=True)
alcohol_percent = models.FloatField(null=True)
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
image = models.ImageField(upload_to="products", blank=True)
customer_support = models.CharField(max_length=32, blank=True)
inventory_amount = models.FloatField(default=0)
# FIXME: rename to inventory_quantity
inventory_amount = models.IntegerField(default=0)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@@ -109,7 +113,7 @@ class Order(models.Model):
blank=True,
related_name="orders",
)
order_sum = models.FloatField(db_column="sum")
order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
objects = OrderManager()
@@ -148,8 +152,10 @@ class OrderLineManager(models.QuerySet):
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()
unit_price = models.FloatField(verbose_name="Enhetspris")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
objects = OrderLineManager.as_manager()
@@ -168,7 +174,7 @@ class Transaction(models.Model):
"""Money"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
amount = models.FloatField()
amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
@@ -189,8 +195,10 @@ class InventoryTransaction(models.Model):
related_name="transactions",
verbose_name="Produkt",
)
amount = models.FloatField(verbose_name="Antall")
unit_price = models.FloatField(verbose_name="Enhetspris")
# FIXME: rename to quantity
amount = models.IntegerField(verbose_name="Antall")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
comment = models.TextField(verbose_name="Kommentar", blank=True)
created = models.DateTimeField(auto_now_add=True)
@@ -204,7 +212,7 @@ class InventoryTransaction(models.Model):
class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
balance = models.FloatField(default=0.0)
balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0)
image = models.URLField(blank=True)
def last_purchase(self):
@@ -227,7 +235,7 @@ class UserProfile(models.Model):
def product_order_stats(self):
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
def order_sum_total(self) -> float:
def order_sum_total(self) -> Decimal:
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
def image_url(self) -> str:
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
+6 -6
View File
@@ -8776,9 +8776,9 @@
}
},
"node_modules/log-update/node_modules/ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
"integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
"dev": true,
"engines": {
"node": ">=4"
@@ -19172,9 +19172,9 @@
"dev": true
},
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
"integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
"dev": true
},
"cli-cursor": {
-1
View File
@@ -7,7 +7,6 @@ import { Routes } from "./Routes";
import client from "./client";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header";
import { Splash as Splash } from "./components/Splash";
import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider";
import { Wireframe } from "./components/Wireframe";
+24 -22
View File
@@ -1,28 +1,30 @@
import React from "react";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
import { Splash } from "./components/Splash";
import { UserCreate } from "./features/auth/UserCreate";
import { Deposit } from "./features/deposit/Deposit";
import { Log } from "./features/log/Log";
import { Register } from "./features/register/Register";
import { OrderStats } from "./features/stats/OrderStats";
import { ProductStats } from "./features/stats/ProductStats";
import { Stats } from "./features/stats/Stats";
import { UserDetail } from "./features/users/UserDetail";
import { UserList } from "./features/users/UserList";
const Splash = React.lazy(() => import("./components/Splash"));
const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
const Log = React.lazy(() => import("./features/log/Log"));
const Register = React.lazy(() => import("./features/register/Register"));
const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
const Stats = React.lazy(() => import("./features/stats/Stats"));
const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes = (): JSX.Element => (
<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 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>
);
@@ -0,0 +1,9 @@
.errorMessage {
font-family: Rocket Rinder, monospace;
font-size: 3rem;
font-style: italic;
color: var(--brand);
text-align: center;
width: 100%;
margin-top: 30vh;
}
+23
View File
@@ -0,0 +1,23 @@
import { ApolloError } from "@apollo/client";
import React from "react";
import styles from "./GQLError.module.css";
const formatModel = (text: string) => {
const withSpaces = text
.replace(/([A-Z])/g, " $1")
.toLocaleLowerCase()
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
};
export const GQLError = ({ error }: { error: ApolloError }) => {
if (error?.graphQLErrors?.length == 1) {
const single = error?.graphQLErrors[0];
if (single?.extensions?.code === "NOT_FOUND") {
const modelPretty = formatModel(single?.extensions?.model as string);
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
}
}
return <div className={styles.errorMessage}>Error...</div>;
};
+2 -1
View File
@@ -8,7 +8,7 @@ interface SplashProps {
noOverlay?: boolean;
}
export const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
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>
@@ -17,3 +17,4 @@ export const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): J
</div>
</div>
);
export default Splash;
+2 -1
View File
@@ -11,7 +11,7 @@ type FormValues = {
email: string;
};
export const UserCreate = () => {
const UserCreate = () => {
const {
register,
handleSubmit,
@@ -48,3 +48,4 @@ export const UserCreate = () => {
</div>
);
};
export default UserCreate;
+2 -1
View File
@@ -4,7 +4,7 @@ import { UserCard } from "../users/UserCard";
import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit";
export const Deposit = (): JSX.Element => {
const Deposit = (): JSX.Element => {
const {
loading,
selectedUser,
@@ -65,3 +65,4 @@ export const Deposit = (): JSX.Element => {
</div>
);
};
export default Deposit;
+5 -4
View File
@@ -9,7 +9,7 @@ import styles from "./Log.module.css";
const PAGE_SIZE = 10;
export const Log = () => {
const Log = () => {
const types = {
orderOffset: Number,
transactionOffset: Number,
@@ -51,11 +51,11 @@ export const Log = () => {
<td>
{(order.orderlines ?? []).map((line) => (
<div key={line.id}>
{line.amount}x {line.product.name}
{Number(line.amount)}x {line.product.name}
</div>
))}
</td>
<td>{order.orderSum}</td>
<td>{Number(order.orderSum)}</td>
</tr>
))}
</tbody>
@@ -104,7 +104,7 @@ export const Log = () => {
<td>
{trans.user.firstName} {trans.user.lastName}
</td>
<td>{trans.amount}</td>
<td>{Number(trans.amount)}</td>
</tr>
))}
</tbody>
@@ -139,3 +139,4 @@ export const Log = () => {
</div>
);
};
export default Log;
+2 -1
View File
@@ -8,7 +8,7 @@ import { useRegister } from "./useRegister";
const USER_EXTERNAL = "external";
export const Register = (): JSX.Element => {
const Register = (): JSX.Element => {
const {
users,
products,
@@ -80,3 +80,4 @@ export const Register = (): JSX.Element => {
</div>
);
};
export default Register;
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useToast } from "../../components/ToastProvider";
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, Product } from "../../types";
import { activeUserFilter } from "../../utils/users";
import {formatCurrency} from "../../utils/currency";
const USER_EXTERNAL = "external";
@@ -53,7 +54,7 @@ export const useRegister = () => {
setOrder(newOrder);
};
const total = sumTotal(products, order, selectedUser === USER_EXTERNAL);
const total = formatCurrency(sumTotal(products, order, selectedUser === USER_EXTERNAL));
const onSubmit = async () => {
let res;
+2 -1
View File
@@ -35,7 +35,7 @@ const OrderBar = ({ data }: { data: BarDatum[] }) => (
/>
);
export const OrderStats = () => {
const OrderStats = () => {
const { data, loading } = useQuery(OrderStatsDocument);
return (
@@ -77,3 +77,4 @@ export const OrderStats = () => {
</StatsView>
);
};
export default OrderStats;
+2 -1
View File
@@ -11,7 +11,7 @@ type UserCount = {
count: number;
};
export const ProductStats = () => {
const ProductStats = () => {
const { data, loading } = useQuery(AllProductStatsDocument, {
variables: { active: true },
});
@@ -38,3 +38,4 @@ export const ProductStats = () => {
</StatsView>
);
};
export default ProductStats;
+2 -1
View File
@@ -5,7 +5,7 @@ import React from "react";
import { ProductStatsDocument } from "../../types";
import { StatsView } from "./StatsView";
export const Stats = () => {
const Stats = () => {
const { data, loading } = useQuery(ProductStatsDocument);
const series: any[] = [];
@@ -68,3 +68,4 @@ export const Stats = () => {
</StatsView>
);
};
export default Stats;
+1 -1
View File
@@ -25,7 +25,7 @@ export const StatsNav = () => (
</NavLink>
</li>
<li>
<NavLink className="disabled" to="/stats/users" title="TODO">
<NavLink className="disabled" to="/stats/users" title="TODO" hidden={true}>
Folk
</NavLink>
</li>
+3 -1
View File
@@ -3,6 +3,7 @@ import React, { MouseEventHandler } from "react";
import { AllUsersQuery } from "../../types";
import styles from "./UserCard.module.css";
import {formatCurrency} from "../../utils/currency";
type UserCardProps = {
user: AllUsersQuery["allUsers"][0] | null;
@@ -13,6 +14,7 @@ type UserCardProps = {
/** If user is null, then it's the special external user */
export const UserCard = ({ user, onUser, active = false }: UserCardProps): JSX.Element => {
const classes = clsx(styles.user, { [styles.active]: active });
const userBalanceString = formatCurrency(Number(user?.profile?.balance));
return (
<button className={classes} onClick={onUser} type="button" title={user?.firstName || "Ekstern"}>
<div className={styles.avatarWrap}>
@@ -27,7 +29,7 @@ export const UserCard = ({ user, onUser, active = false }: UserCardProps): JSX.E
</div>
<div className={styles.meta}>
{user?.firstName || "Ekstern"}
<span className={styles.balance}>{user?.profile?.balance || ""}</span>
<span className={styles.balance}>{userBalanceString}</span>
</div>
</button>
);
+6 -2
View File
@@ -3,16 +3,19 @@ import { Line } from "@nivo/line";
import React from "react";
import { useParams } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { User, UserDetailDocument, UserProfile } from "../../types";
import { formatMonth, formatTime } from "../../utils/time";
import styles from "./UserDetail.module.css";
export const UserDetail = () => {
const UserDetail = () => {
const { userId } = useParams();
const { data, loading } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
const { data, loading, error } = useQuery(UserDetailDocument, { variables: { userId: userId || "" }, skip: !userId });
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const user = data?.user as User;
const profile = user?.profile as UserProfile;
// FIXME: types
@@ -108,3 +111,4 @@ export const UserDetail = () => {
</div>
);
};
export default UserDetail;
+2 -1
View File
@@ -5,7 +5,7 @@ import { Link } from "react-router-dom";
import { AllUsersDocument } from "../../types";
import { activeUserFilter } from "../../utils/users";
export const UserList = () => {
const UserList = () => {
const { data, loading } = useQuery(AllUsersDocument);
const [showAll, setShowAll] = useState(false);
@@ -35,3 +35,4 @@ export const UserList = () => {
</div>
);
};
export default UserList;
+7
View File
@@ -0,0 +1,7 @@
export function formatCurrency(value: number) {
if (isNaN(value)) {
return "";
} else {
return value.toFixed(2).replace(/\.00$/, '');
}
}