feat(ui): stock card with withdraw and inventory count, production batches

- Bottling detail shows Abgefüllt / Webshop verkauft / Entnommen / Rest
  (negative rest flagged "Prüfen") and the full movement list; buttons
  "Entnehmen" (reason, note) and "Inventur korrigieren" (counted
  quantity, difference booked).
- Rest column in the bottling list.
- New pages for production batches (list, create, detail with the same
  stock card), sidebar entry with its own permission.
- Products get "Art" (honey vs. manufactured goods) and the webshop
  category; bottlings only offer honey articles.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-02 06:44:21 +02:00
1 parent 04f93e954a
commit f57db97578
21 files changed
+1151 -5

No files matched your search

@@ -9,6 +9,8 @@ export interface BottlingBatch {
bestBeforeDate: string;
lotNumber: string;
createdAt: string;
/** jarCount plus all stock movements (shop sales, withdrawals, ...). */
remainingCount: number;
honeyBatch: { id: string; batchNumber: string };
product: { id: string; name: string } | null;
}
@@ -0,0 +1,36 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
CreateProductionBatchInput,
ProductionBatch,
ProductionBatchFilter,
} from "./types";
export function fetchProductionBatches(
filter: ProductionBatchFilter = {},
): Promise<ProductionBatch[]> {
const params = new URLSearchParams();
if (filter.productId) params.set("productId", filter.productId);
const query = params.toString();
return apiFetch<ProductionBatch[]>(
`/production-batches${query ? `?${query}` : ""}`,
);
}
export function fetchProductionBatch(id: string): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>(`/production-batches/${id}`);
}
export function createProductionBatch(
input: CreateProductionBatchInput,
): Promise<ProductionBatch> {
return apiFetch<ProductionBatch>("/production-batches", {
method: "POST",
body: JSON.stringify(input),
});
}
export function deleteProductionBatch(id: string): Promise<{ id: string }> {
return apiFetch<{ id: string }>(`/production-batches/${id}`, {
method: "DELETE",
});
}
@@ -0,0 +1,25 @@
export interface ProductionBatch {
id: string;
productId: string;
lotNumber: string;
date: string;
quantity: number;
bestBeforeDate: string | null;
notes: string | null;
createdAt: string;
remainingCount: number;
product: { id: string; name: string };
}
export interface CreateProductionBatchInput {
productId: string;
lotNumber: string;
date: string;
quantity: number;
bestBeforeDate?: string;
notes?: string;
}
export interface ProductionBatchFilter {
productId?: string;
}
+13 -1
View File
@@ -1,6 +1,18 @@
import { ProductStatus } from "./types";
import { ProductKind, ProductStatus, ShopCategory } from "./types";
export const PRODUCT_STATUS_LABELS: Record<ProductStatus, string> = {
ACTIVE: "Aktiv",
DISCONTINUED: "Eingestellt",
};
export const PRODUCT_KIND_LABELS: Record<ProductKind, string> = {
BOTTLED_HONEY: "Honig (Bestand je Abfüllung)",
MANUFACTURED: "Hergestellte Ware (Bestand je Herstellungscharge)",
};
export const SHOP_CATEGORY_LABELS: Record<ShopCategory, string> = {
HONIG: "Honig",
KERZEN: "Kerzen",
PROPOLIS: "Propolis",
SONSTIGES: "Sonstiges",
};
@@ -1,5 +1,10 @@
export type ProductStatus = "ACTIVE" | "DISCONTINUED";
/** Honey: stock per bottling. Everything else: stock per production batch. */
export type ProductKind = "BOTTLED_HONEY" | "MANUFACTURED";
export type ShopCategory = "HONIG" | "KERZEN" | "PROPOLIS" | "SONSTIGES";
export interface Product {
id: string;
name: string;
@@ -9,6 +14,8 @@ export interface Product {
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
kind: ProductKind;
shopCategory: ShopCategory;
createdAt: string;
updatedAt: string;
}
@@ -20,6 +27,8 @@ export interface CreateProductInput {
packagingSize?: number;
weight?: number;
salesUnit?: string;
kind?: ProductKind;
shopCategory?: ShopCategory;
}
export interface UpdateProductInput extends Partial<CreateProductInput> {
+33
View File
@@ -0,0 +1,33 @@
import { apiFetch } from "@/lib/auth/api-client";
import {
BatchStock,
CorrectInput,
StockBatchPath,
WithdrawInput,
} from "./types";
export function fetchStock(path: StockBatchPath, id: string): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/stock`);
}
export function withdrawStock(
path: StockBatchPath,
id: string,
input: WithdrawInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/withdrawals`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function correctStock(
path: StockBatchPath,
id: string,
input: CorrectInput,
): Promise<BatchStock> {
return apiFetch<BatchStock>(`/${path}/${id}/corrections`, {
method: "POST",
body: JSON.stringify(input),
});
}
+16
View File
@@ -0,0 +1,16 @@
import { MovementType, WithdrawalReason } from "./types";
export const WITHDRAWAL_REASON_LABELS: Record<WithdrawalReason, string> = {
HOFLADEN: "Hofladen",
EIGENBEDARF: "Eigenbedarf / Geschenk",
BRUCH: "Bruch / Verlust",
PROBE: "Probe / Muster",
SONSTIGES: "Sonstiges",
};
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
SHOP_SALE: "Webshop-Verkauf",
SHOP_RETURN: "Webshop-Rückbuchung",
WITHDRAWAL: "Entnahme",
CORRECTION: "Inventur",
};
+55
View File
@@ -0,0 +1,55 @@
/** Collection path of a batch whose stock is tracked in the movement ledger. */
export type StockBatchPath = "bottling-batches" | "production-batches";
export type WithdrawalReason =
| "HOFLADEN"
| "EIGENBEDARF"
| "BRUCH"
| "PROBE"
| "SONSTIGES";
export type MovementType =
| "SHOP_SALE"
| "SHOP_RETURN"
| "WITHDRAWAL"
| "CORRECTION";
export interface StockSummary {
/** Bottled or produced. */
initial: number;
/** Net shop sales (sales minus shop returns). */
soldInShop: number;
withdrawn: number;
/** Net inventory corrections. */
corrected: number;
remaining: number;
}
export interface StockMovement {
id: string;
type: MovementType;
/** Signed: negative takes stock out. */
quantity: number;
reason: WithdrawalReason | null;
notes: string | null;
apiKeyId: string | null;
createdAt: string;
user: { id: string; name: string } | null;
shopRequest: { externalId: string; kind: "SALE" | "RETURN" } | null;
}
export interface BatchStock {
summary: StockSummary;
movements: StockMovement[];
}
export interface WithdrawInput {
quantity: number;
reason: WithdrawalReason;
notes?: string;
}
export interface CorrectInput {
countedQuantity: number;
notes: string;
}