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:
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>
|
||||
|
||||
Reference in new issue
Block a user