feat(ui): raw material lots and production inputs

- Pages for raw material lots (beeswax, propolis): list, create in grams
  with optional apiary, detail with stock card (harvested / used in
  production / withdrawn / remaining) and "used in" batches.
- Production batch form takes the raw material used (lot + grams) and the
  batch page shows what it was made from - traceability both ways.
- Sidebar entry "Rohstoffe".
- Removes backend files that were committed into ApiCore-Frontend/src by
  mistake in e52a605 (copies of ApiCore-Backend/src/raw-materials).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:31:07 +02:00
1 parent f9899d5621
commit 569a43d247
22 files changed
+803 -412

No files matched your search

@@ -35,6 +35,7 @@ function stock(remaining: number): BatchStock {
soldInShop: 30,
withdrawn: 10,
corrected: 0,
consumed: 0,
remaining,
},
movements: [
@@ -52,7 +52,13 @@ interface StockCardProps {
* change by hand. The numbers come from the movement ledger; nothing here is
* stored separately, so they cannot drift.
*/
export function StockCard({ path, id, initialLabel, unit, canBook }: StockCardProps) {
export function StockCard({
path,
id,
initialLabel,
unit,
canBook,
}: StockCardProps) {
const stockQuery = useQuery({
queryKey: ["stock", path, id],
queryFn: () => fetchStock(path, id),
@@ -65,8 +71,18 @@ export function StockCard({ path, id, initialLabel, unit, canBook }: StockCardPr
<CardTitle>Bestand</CardTitle>
{canBook && stock ? (
<div className="flex flex-wrap items-center gap-2">
<WithdrawDialog path={path} id={id} unit={unit} remaining={stock.summary.remaining} />
<CorrectDialog path={path} id={id} unit={unit} remaining={stock.summary.remaining} />
<WithdrawDialog
path={path}
id={id}
unit={unit}
remaining={stock.summary.remaining}
/>
<CorrectDialog
path={path}
id={id}
unit={unit}
remaining={stock.summary.remaining}
/>
</div>
) : null}
</CardHeader>
@@ -79,7 +95,11 @@ export function StockCard({ path, id, initialLabel, unit, canBook }: StockCardPr
<p className="text-sm text-muted-foreground">Lädt…</p>
) : (
<>
<SummaryGrid summary={stock.summary} initialLabel={initialLabel} />
<SummaryGrid
summary={stock.summary}
initialLabel={initialLabel}
isRawMaterial={path === "raw-material-lots"}
/>
<MovementList movements={stock.movements} />
</>
)}
@@ -91,14 +111,18 @@ export function StockCard({ path, id, initialLabel, unit, canBook }: StockCardPr
function SummaryGrid({
summary,
initialLabel,
isRawMaterial,
}: {
isRawMaterial: boolean;
summary: StockSummary;
initialLabel: string;
}) {
const needsReview = summary.remaining < 0;
const items = [
{ label: initialLabel, value: summary.initial },
{ label: "Webshop verkauft", value: summary.soldInShop },
isRawMaterial
? { label: "In Herstellung verbraucht", value: summary.consumed }
: { label: "Webshop verkauft", value: summary.soldInShop },
{ label: "Entnommen", value: summary.withdrawn },
];
return (
@@ -120,7 +144,9 @@ function SummaryGrid({
Rest
{needsReview ? <Badge variant="destructive">Prüfen</Badge> : null}
</dt>
<dd className="text-xl font-semibold tabular-nums">{summary.remaining}</dd>
<dd className="text-xl font-semibold tabular-nums">
{summary.remaining}
</dd>
{summary.corrected !== 0 ? (
<p className="text-xs text-muted-foreground">
inkl. Inventur {summary.corrected > 0 ? "+" : ""}
@@ -143,18 +169,26 @@ function MovementList({ movements }: { movements: StockMovement[] }) {
return (
<ul className="flex flex-col divide-y rounded-lg border text-sm">
{movements.map((movement) => (
<li key={movement.id} className="flex flex-wrap items-baseline justify-between gap-2 p-3">
<li
key={movement.id}
className="flex flex-wrap items-baseline justify-between gap-2 p-3"
>
<div className="flex flex-col">
<span className="font-medium">
{MOVEMENT_TYPE_LABELS[movement.type]}
{movement.reason ? ` · ${WITHDRAWAL_REASON_LABELS[movement.reason]}` : ""}
{movement.reason
? ` · ${WITHDRAWAL_REASON_LABELS[movement.reason]}`
: ""}
</span>
<span className="text-xs text-muted-foreground">
{new Date(movement.createdAt).toLocaleString("de-DE")} ·{" "}
{movement.user?.name ?? (movement.shopRequest ? "Webshop" : "System")}
{movement.user?.name ??
(movement.shopRequest ? "Webshop" : "System")}
</span>
{movement.notes ? (
<span className="text-xs text-muted-foreground">{movement.notes}</span>
<span className="text-xs text-muted-foreground">
{movement.notes}
</span>
) : null}
</div>
<span
@@ -214,7 +248,8 @@ function WithdrawDialog({
setNotes("");
},
onError: (mutationError: Error) => {
const message = mutationError.message || "Entnahme konnte nicht gebucht werden";
const message =
mutationError.message || "Entnahme konnte nicht gebucht werden";
setError(message);
toast.error(message);
},
@@ -242,7 +277,8 @@ function WithdrawDialog({
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Noch da: {remaining} {unit}. Die Entnahme mindert auch den Bestand im Webshop.
Noch da: {remaining} {unit}. Die Entnahme mindert auch den Bestand
im Webshop.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="withdraw-quantity">Anzahl ({unit})</Label>
@@ -262,13 +298,17 @@ function WithdrawDialog({
<Label htmlFor="withdraw-reason">Grund</Label>
<Select
value={reason}
onValueChange={(value) => setReason((value ?? reason) as WithdrawalReason)}
onValueChange={(value) =>
setReason((value ?? reason) as WithdrawalReason)
}
>
<SelectTrigger id="withdraw-reason" className="w-full">
<SelectValue>{WITHDRAWAL_REASON_LABELS[reason]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]).map((option) => (
{(
Object.keys(WITHDRAWAL_REASON_LABELS) as WithdrawalReason[]
).map((option) => (
<SelectItem key={option} value={option}>
{WITHDRAWAL_REASON_LABELS[option]}
</SelectItem>
@@ -318,7 +358,10 @@ function CorrectDialog({
const mutation = useMutation({
mutationFn: () =>
correctStock(path, id, { countedQuantity: Number(counted), notes: notes.trim() }),
correctStock(path, id, {
countedQuantity: Number(counted),
notes: notes.trim(),
}),
onSuccess: () => {
done();
toast.success("Inventur gebucht");
@@ -326,7 +369,8 @@ function CorrectDialog({
setNotes("");
},
onError: (mutationError: Error) => {
const message = mutationError.message || "Inventur konnte nicht gebucht werden";
const message =
mutationError.message || "Inventur konnte nicht gebucht werden";
setError(message);
toast.error(message);
},
@@ -360,8 +404,8 @@ function CorrectDialog({
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-sm text-muted-foreground">
Gebucht sind {remaining} {unit}. Trag ein, wie viele du gezählt hast - gebucht wird die
Differenz.
Gebucht sind {remaining} {unit}. Trag ein, wie viele du gezählt hast
- gebucht wird die Differenz.
</p>
<div className="flex flex-col gap-2">
<Label htmlFor="correct-counted">Gezählt ({unit})</Label>
@@ -396,7 +440,10 @@ function CorrectDialog({
{error}
</p>
) : null}
<Button type="submit" disabled={mutation.isPending || difference === 0}>
<Button
type="submit"
disabled={mutation.isPending || difference === 0}
>
{mutation.isPending ? "Bucht…" : "Inventur buchen"}
</Button>
</form>