Migrate inventory and reports to the SPA

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
leanderandCopilot committed 2026-10-22 05:38:49 +02:00
1 parent 1d507d1976
commit 4848dda4a4
26 files changed
+1090 -18

No files matched your search

@@ -0,0 +1,71 @@
.section {
margin-top: 2rem;
}
.tableWrap {
max-width: 100%;
overflow-x: auto;
}
.tableWrap table {
width: 100%;
}
.inactive {
opacity: 0.65;
}
.transactions {
margin-top: 2rem;
}
.pagination {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.form {
display: grid;
max-width: 36rem;
gap: 1rem;
}
.form label {
display: grid;
gap: 0.35rem;
}
.form textarea,
.form select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: rgb(35, 15, 41);
color: var(--text);
font: inherit;
}
.form textarea {
min-height: 6rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
}
.report {
margin-top: 2rem;
}
.error {
color: var(--sun);
}
@media (max-width: 640px) {
.tableWrap table {
min-width: 42rem;
}
}
@@ -0,0 +1,126 @@
import { useQuery } from "@apollo/client";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { InventoryPageDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
import { formatTime } from "../../utils/time";
import styles from "./Inventory.module.css";
const PAGE_SIZE = 20;
const Inventory = () => {
const [offset, setOffset] = useState(0);
const { data, loading, error } = useQuery(InventoryPageDocument, {
variables: { limit: PAGE_SIZE, offset },
});
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const products = [...(data?.allProducts || [])].sort(
(a, b) =>
Number(b.active) - Number(a.active) || a.inventoryAmount - b.inventoryAmount || a.name.localeCompare(b.name),
);
const activeProducts = products.filter((product) => product.active);
const transactions = data?.inventoryTransactionList || [];
return (
<div>
<h2>Varebeholdning</h2>
<div className={styles.summary}>
<div>Antall aktive varer: {activeProducts.reduce((total, product) => total + product.inventoryAmount, 0)}</div>
<div>
Innkjøpsverdi:{" "}
{formatCurrency(activeProducts.reduce((total, product) => total + Number(product.wholesaleValue), 0))} kr
</div>
</div>
<section className={styles.section}>
<div className={styles.summary}>
<h3>Varer</h3>
<Link className="btn-primary" to="/inventory/add">
Registrer varetransaksjon
</Link>
<Link to="/report">Åpne rapport</Link>
</div>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Beholdning</th>
<th scope="col">Innkjøpspris</th>
<th scope="col">Verdi</th>
<th scope="col">I salg</th>
<th scope="col">Sist oppdatert</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id} className={!product.active ? styles.inactive : undefined}>
<td>{product.name}</td>
<td>{product.inventoryAmount}</td>
<td>{formatCurrency(Number(product.wholesaleUnitPrice))} kr</td>
<td>{formatCurrency(Number(product.wholesaleValue))} kr</td>
<td>{product.active ? "Ja" : "Nei"}</td>
<td>{formatTime(product.updated)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.transactions}>
<h3>Varetransaksjoner</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Når</th>
<th scope="col">Vare</th>
<th scope="col">Antall</th>
<th scope="col">Enhetspris</th>
<th scope="col">Hvem</th>
<th scope="col">Kommentar</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<tr key={transaction.id}>
<td>{formatTime(transaction.created)}</td>
<td>{transaction.product.name}</td>
<td>{transaction.amount}</td>
<td>{formatCurrency(Number(transaction.unitPrice))} kr</td>
<td>
{transaction.user.firstName} {transaction.user.lastName}
</td>
<td>{transaction.comment}</td>
</tr>
))}
{!transactions.length && (
<tr>
<td colSpan={6}>Ingen varetransaksjoner ennå.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Forrige
</button>
<button
type="button"
disabled={transactions.length < PAGE_SIZE}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Neste
</button>
</div>
</section>
</div>
);
};
export default Inventory;
@@ -0,0 +1,102 @@
import { useMutation, useQuery } from "@apollo/client";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { GQLError } from "../../components/GQLError";
import { useToast } from "../../components/ToastProvider";
import { CreateInventoryTransactionDocument, InventoryProductsDocument } from "../../types";
import styles from "./Inventory.module.css";
const InventoryAdd = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const { data, loading, error } = useQuery(InventoryProductsDocument);
const [createInventoryTransaction, { loading: saving }] = useMutation(CreateInventoryTransactionDocument);
const [productId, setProductId] = useState("");
const [amount, setAmount] = useState("");
const [unitPrice, setUnitPrice] = useState("");
const [comment, setComment] = useState("");
const products = data?.allProducts || [];
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const result = await createInventoryTransaction({
variables: {
data: {
productId,
amount: Number(amount),
unitPrice,
comment,
},
},
});
const response = result.data?.createInventoryTransaction;
if (response?.__typename === "CreateInventoryTransactionSuccess") {
showToast(`Registrerte ${amount} stk ${response.inventoryTransaction.product.name}.`);
navigate("/inventory");
} else if (response?.__typename === "FormErrors") {
showToast(response.message, "danger");
} else {
showToast("Kunne ikke registrere varetransaksjonen.", "danger");
}
} catch (mutationError) {
showToast(
mutationError instanceof Error ? mutationError.message : "Kunne ikke registrere varetransaksjonen.",
"danger",
);
}
};
return (
<div>
<h2>Registrer varetransaksjon</h2>
<p>
Du må være innlogget for å registrere varer. <a href="/accounts/login/">Logg inn</a>.
</p>
<form className={styles.form} onSubmit={onSubmit}>
<label>
Vare
<select required value={productId} onChange={(event) => setProductId(event.target.value)}>
<option value="">Velg vare</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.name} ({product.inventoryAmount} på lager)
</option>
))}
</select>
</label>
<label>
Antall
<input required type="number" step="1" value={amount} onChange={(event) => setAmount(event.target.value)} />
</label>
<label>
Innkjøpspris per enhet (kr)
<input
required
step="0.1"
type="number"
value={unitPrice}
onChange={(event) => setUnitPrice(event.target.value)}
/>
</label>
<label>
Kommentar
<textarea value={comment} onChange={(event) => setComment(event.target.value)} />
</label>
<div className={styles.summary}>
<button type="submit" className="primary" disabled={saving || !products.length}>
{saving ? "Lagrer..." : "Registrer"}
</button>
<Link to="/inventory">Avbryt</Link>
</div>
</form>
{!products.length && <p>Ingen aktive varer å registrere.</p>}
</div>
);
};
export default InventoryAdd;
@@ -0,0 +1,138 @@
import { useQuery } from "@apollo/client";
import { format, subDays } from "date-fns";
import React, { useState } from "react";
import { GQLError } from "../../components/GQLError";
import { InventoryReportDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
import styles from "./Inventory.module.css";
const today = () => format(new Date(), "yyyy-MM-dd");
const monthAgo = () => format(subDays(new Date(), 30), "yyyy-MM-dd");
const timestampAt = (date: string, endOfDay = false) => `${date}T${endOfDay ? "23:59:59.999999" : "00:00:00"}`;
const InventoryReport = () => {
const [startDate, setStartDate] = useState(monthAgo);
const [endDate, setEndDate] = useState(today);
const [appliedRange, setAppliedRange] = useState({ startDate: monthAgo(), endDate: today() });
const invalidRange = startDate > endDate;
const { data, loading, error } = useQuery(InventoryReportDocument, {
variables: {
startTime: timestampAt(appliedRange.startDate),
endTime: timestampAt(appliedRange.endDate, true),
},
});
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!invalidRange) setAppliedRange({ startDate, endDate });
};
if (error) return <GQLError error={error} />;
const report = data?.inventoryReport;
const balanceChange = report ? Number(report.balanceAtEnd) - Number(report.balanceAtStart) : 0;
const inventoryChange = report ? Number(report.inventoryPurchases) - Number(report.productSales) : 0;
return (
<div>
<h2>Varerapport</h2>
<form className={styles.summary} onSubmit={onSubmit}>
<label>
Fra
<input type="date" required value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</label>
<label>
Til
<input type="date" required value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
<button type="submit" disabled={invalidRange}>
Oppdater
</button>
</form>
{invalidRange && <p className={styles.error}>Startdato må være før eller lik sluttdato.</p>}
{loading && <div>Loading...</div>}
{report && (
<>
<section className={styles.report}>
<h3>Beholdningsendring</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Innkjøp</th>
<th scope="col">Salg</th>
<th scope="col">Endring verdi</th>
</tr>
</thead>
<tbody>
{report.products.map((product) => (
<tr key={product.productId}>
<td>{product.productName}</td>
<td>
{formatCurrency(Number(product.purchaseValue))} kr ({product.purchaseUnits} stk)
</td>
<td>
{formatCurrency(Number(product.salesValue))} kr ({product.salesUnits} stk)
</td>
<td>{formatCurrency(Number(product.purchaseValue) - Number(product.salesValue))} kr</td>
</tr>
))}
{!report.products.length && (
<tr>
<td colSpan={4}>Ingen varetransaksjoner i perioden.</td>
</tr>
)}
<tr>
<th scope="row">Totalt</th>
<td>{formatCurrency(Number(report.inventoryPurchases))} kr</td>
<td>{formatCurrency(Number(report.productSales))} kr</td>
<td>{formatCurrency(inventoryChange)} kr</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className={styles.report}>
<h3>Innskudd og kjøp</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Post</th>
<th scope="col">Saldo</th>
<th scope="col">Innskudd</th>
<th scope="col">Kjøp</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Saldo ved periodestart</th>
<td>{formatCurrency(Number(report.balanceAtStart))} kr</td>
<td />
<td />
</tr>
<tr>
<th scope="row">Endring i perioden</th>
<td>{formatCurrency(balanceChange)} kr</td>
<td>{formatCurrency(Number(report.deposits))} kr</td>
<td>{formatCurrency(Number(report.orderPurchases))} kr</td>
</tr>
<tr>
<th scope="row">Saldo ved periodeslutt</th>
<td>{formatCurrency(Number(report.balanceAtEnd))} kr</td>
<td />
<td />
</tr>
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
};
export default InventoryReport;
@@ -0,0 +1,20 @@
mutation createInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
id
product {
name
inventoryAmount
}
}
}
... on FormErrors {
message
fields {
field
message
}
}
}
}
@@ -0,0 +1,25 @@
query inventoryPage($limit: Int = 20, $offset: Int = 0) {
allProducts {
id
name
active
inventoryAmount
updated
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{ created: DESC }], pagination: { limit: $limit, offset: $offset }) {
id
created
amount
unitPrice
comment
product {
name
}
user {
firstName
lastName
}
}
}
@@ -0,0 +1,7 @@
query inventoryProducts {
allProducts(filters: { active: true }) {
id
name
inventoryAmount
}
}
@@ -0,0 +1,18 @@
query inventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
productName
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
@@ -1,11 +1,11 @@
import clsx from "clsx";
import React, { MouseEventHandler } from "react";
import { SchemaProduct as Product } from "../../types";
import { AllProductsQuery } from "../../types";
import styles from "./ProductCard.module.css";
type ProductCardProps = {
product: Omit<Product, "userCounts">;
product: AllProductsQuery["allProducts"][number];
onProduct: MouseEventHandler;
active: boolean;
amount: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import React from "react";
import { OrderLineInput, SchemaProduct as Product } from "../../types";
import { AllProductsQuery, OrderLineInput } from "../../types";
import { UserCard } from "../users/UserCard";
import { ProductCard } from "./ProductCard";
import styles from "./Register.module.css";
@@ -55,7 +55,7 @@ const Register = (): JSX.Element => {
<section className={styles.productListSection}>
<h2>Kjøper hva?</h2>
<div className={styles.productList}>
{products.map((product: Omit<Product, "userCounts">) => (
{products.map((product: AllProductsQuery["allProducts"][number]) => (
<ProductCard
key={product.id}
product={product}
+11 -3
View File
@@ -2,15 +2,23 @@ import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import { useToast } from "../../components/ToastProvider";
import { AllProductsDocument, AllUsersDocument, CreateOrderDocument, OrderLineInput, SchemaProduct } from "../../types";
import {
AllProductsDocument,
AllProductsQuery,
AllUsersDocument,
CreateOrderDocument,
OrderLineInput,
} from "../../types";
import { formatCurrency } from "../../utils/currency";
import { activeUserFilter } from "../../utils/users";
const USER_EXTERNAL = "external";
const sumTotal = (products: Omit<SchemaProduct, "userCounts">[], order: OrderLineInput[], isExternal: boolean) => {
type Product = AllProductsQuery["allProducts"][number];
const sumTotal = (products: Product[], order: OrderLineInput[], isExternal: boolean) => {
const prices = order.map((orderLine: OrderLineInput) => {
const product = products.find((product: Omit<SchemaProduct, "userCounts">) => product.id === orderLine.productId);
const product = products.find((product) => product.id === orderLine.productId);
if (!product) return 0;
return orderLine.amount * Number(isExternal ? product.salePriceExt : product.salePriceInt);
});