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

@@ -9,6 +9,12 @@ export interface ProductionBatch {
createdAt: string;
remainingCount: number;
product: { id: string; name: string };
/** Raw material this batch was made from (grams). */
inputs?: {
id: string;
quantity: number;
rawMaterialLot: { id: string; lotNumber: string; kind: "BEESWAX" | "PROPOLIS" };
}[];
}
export interface CreateProductionBatchInput {
@@ -18,6 +24,7 @@ export interface CreateProductionBatchInput {
quantity: number;
bestBeforeDate?: string;
notes?: string;
inputs?: { rawMaterialLotId: string; quantity: number }[];
}
export interface UpdateProductionBatchInput {
@@ -0,0 +1,33 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateRawMaterialLotInput,
RawMaterialKind,
RawMaterialLot,
} from "./types";
export function fetchRawMaterialLots(
kind?: RawMaterialKind,
): Promise<RawMaterialLot[]> {
return apiFetch<RawMaterialLot[]>(
`/raw-material-lots${kind ? `?kind=${kind}` : ""}`,
);
}
export function fetchRawMaterialLot(id: string): Promise<RawMaterialLot> {
return apiFetch<RawMaterialLot>(`/raw-material-lots/${id}`);
}
export function createRawMaterialLot(
input: CreateRawMaterialLotInput,
): Promise<RawMaterialLot> {
return apiFetch<RawMaterialLot>("/raw-material-lots", {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteRawMaterialLot(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/raw-material-lots/${id}`, {
method: "DELETE",
});
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { formatGrams } from "./labels";
describe("formatGrams", () => {
it("shows kilograms from 1000 g on", () => {
expect(formatGrams(4200)).toBe("4,2 kg");
expect(formatGrams(1000)).toBe("1 kg");
});
it("keeps grams below a kilogram", () => {
expect(formatGrams(180)).toBe("180 g");
});
});
@@ -0,0 +1,14 @@
import { RawMaterialKind } from "./types";
export const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: "Bienenwachs",
PROPOLIS: "Propolis",
};
/** 4200 -> "4,2 kg", 180 -> "180 g". */
export function formatGrams(grams: number): string {
if (Math.abs(grams) >= 1000) {
return `${(grams / 1000).toLocaleString("de-DE", { maximumFractionDigits: 2 })} kg`;
}
return `${grams.toLocaleString("de-DE")} g`;
}
@@ -0,0 +1,36 @@
export type RawMaterialKind = "BEESWAX" | "PROPOLIS";
export interface RawMaterialLot {
id: string;
kind: RawMaterialKind;
lotNumber: string;
date: string;
/** Gewonnene Menge in Gramm. */
quantity: number;
apiaryId: string | null;
harvestId: string | null;
notes: string | null;
createdAt: string;
/** Rest in Gramm (Ausgangsmenge plus alle Bewegungen). */
remainingCount: number;
apiary: { id: string; name: string } | null;
harvest: { id: string; date: string } | null;
usedIn: {
id: string;
quantity: number;
productionBatch: {
id: string;
lotNumber: string;
product: { id: string; name: string };
};
}[];
}
export interface CreateRawMaterialLotInput {
kind: RawMaterialKind;
lotNumber: string;
date: string;
quantity: number;
apiaryId?: string;
notes?: string;
}
+4 -1
View File
@@ -6,7 +6,10 @@ import {
WithdrawInput,
} from "./types";
export function fetchStock(path: StockBatchPath, id: string): Promise<BatchStock> {
export function fetchStock(
path: StockBatchPath,
id: string,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/stock`);
}
+1
View File
@@ -13,4 +13,5 @@ export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
SHOP_RETURN: "Webshop-Rückbuchung",
WITHDRAWAL: "Entnahme",
CORRECTION: "Inventur",
CONSUMPTION: "Verbrauch in Herstellung",
};
+6 -10
View File
@@ -1,18 +1,12 @@
/** Collection path of a batch whose stock is tracked in the movement ledger. */
export type StockBatchPath = "bottling-batches" | "production-batches";
export type StockBatchPath =
"bottling-batches" | "production-batches" | "raw-material-lots";
export type WithdrawalReason =
| "HOFLADEN"
| "EIGENBEDARF"
| "BRUCH"
| "PROBE"
| "SONSTIGES";
"HOFLADEN" | "EIGENBEDARF" | "BRUCH" | "PROBE" | "SONSTIGES";
export type MovementType =
| "SHOP_SALE"
| "SHOP_RETURN"
| "WITHDRAWAL"
| "CORRECTION";
"SHOP_SALE" | "SHOP_RETURN" | "WITHDRAWAL" | "CORRECTION" | "CONSUMPTION";
export interface StockSummary {
/** Bottled or produced. */
@@ -22,6 +16,8 @@ export interface StockSummary {
withdrawn: number;
/** Net inventory corrections. */
corrected: number;
/** Raw material used in production (net of give-backs). */
consumed: number;
remaining: number;
}