feat(automation): number formats for raw material and production batches, raw material minimum

- RAW_MATERIAL_LOT_NUMBER_FORMAT / PRODUCTION_BATCH_NUMBER_FORMAT fill in the
  lot number when it is left empty, like honey batches and bottlings.
- RAW_MATERIAL_LOW: task when the total of a raw material kind drops below
  config.minGrams after consumption, withdrawal or inventory count.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-07 16:01:33 +02:00
1 parent fc887d8431
commit 5408711b2d
18 files changed
+311 -26

No files matched your search

@@ -70,7 +70,7 @@ export default function NewProductionBatchPage() {
mutationFn: () =>
createProductionBatch({
productId,
lotNumber: lotNumber.trim(),
lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(),
quantity: Number(quantity),
bestBeforeDate: bestBeforeDate
@@ -150,7 +150,7 @@ export default function NewProductionBatchPage() {
<Label htmlFor="pb-lot">Chargennummer</Label>
<Input
id="pb-lot"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
@@ -46,7 +46,7 @@ export default function NewRawMaterialPage() {
mutationFn: () =>
createRawMaterialLot({
kind,
lotNumber: lotNumber.trim(),
lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(),
quantity: Number(quantity),
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
@@ -110,7 +110,7 @@ export default function NewRawMaterialPage() {
<Label htmlFor="raw-lot">Chargennummer</Label>
<Input
id="raw-lot"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
@@ -28,6 +28,9 @@ const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung",
FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung",
RAW_MATERIAL_LOT_NUMBER_FORMAT: "Chargennummer-Format (Rohstoffe)",
PRODUCTION_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Herstellung)",
RAW_MATERIAL_LOW: "Rohstoff unter Mindestmenge",
};
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
@@ -51,13 +54,51 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
"Nach jeder erfassten Behandlung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach Behandlungsende. Die passende Frist hängt vom Mittel ab (Packungsbeilage) – deshalb ab Werk aus und ohne voreingestellte Frist.",
FEEDING_FOLLOW_UP:
"Nach jeder erfassten Fütterung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach der Fütterung. Ab Werk aus und ohne voreingestellte Frist.",
RAW_MATERIAL_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Rohstoff-Chargen (Wachs, Propolis) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
PRODUCTION_BATCH_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Herstellungschargen (Kerzen, Tinkturen, …) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
RAW_MATERIAL_LOW:
"Fällt der Gesamtbestand einer Rohstoff-Art nach Verbrauch, Entnahme oder Inventur unter die Mindestmenge, dann Aufgabe zum Nachschub. Leere Mindestmenge = diese Art wird nicht überwacht.",
};
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
"HONEY_BATCH_NUMBER_FORMAT",
"BOTTLING_LOT_NUMBER_FORMAT",
"RAW_MATERIAL_LOT_NUMBER_FORMAT",
"PRODUCTION_BATCH_NUMBER_FORMAT",
];
const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [
{ kind: "BEESWAX", label: "Mindestmenge Bienenwachs (g)" },
{ kind: "PROPOLIS", label: "Mindestmenge Propolis (g)" },
];
type MinGrams = Partial<Record<"BEESWAX" | "PROPOLIS", string>>;
function ruleMinGrams(rule: AutomationRule): MinGrams {
const config = rule.config as { minGrams?: Record<string, unknown> } | null;
const result: MinGrams = {};
for (const { kind } of MIN_GRAMS_FIELDS) {
const value = config?.minGrams?.[kind];
result[kind] = typeof value === "number" ? String(value) : "";
}
return result;
}
/** Empty fields drop out; anything else must be whole grams >= 0. */
function parseMinGrams(input: MinGrams): Record<string, number> | null {
const result: Record<string, number> = {};
for (const { kind } of MIN_GRAMS_FIELDS) {
const raw = (input[kind] ?? "").trim();
if (raw === "") continue;
const grams = Number(raw);
if (!Number.isInteger(grams) || grams < 0) return null;
result[kind] = grams;
}
return result;
}
function isNumberFormatRule(rule: AutomationRule): boolean {
return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType);
}
@@ -83,6 +124,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [format, setFormat] = useState(ruleFormat(rule));
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
const numberFormatRule = isNumberFormatRule(rule);
const mutation = useMutation({
@@ -119,6 +161,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
if (rule.triggerType === "VARROA_THRESHOLD") {
config.thresholdMitesPerDay = Number(threshold) || 10;
}
if (rule.triggerType === "RAW_MATERIAL_LOW") {
const parsed = parseMinGrams(minGrams);
if (!parsed) {
toast.error("Mindestmenge bitte als ganze Gramm angeben – oder leer lassen.");
return;
}
config.minGrams = parsed;
}
mutation.mutate({ actionTemplate: actionTemplate || undefined, config });
}
@@ -194,6 +244,25 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
/>
</div>
) : null}
{rule.triggerType === "RAW_MATERIAL_LOW"
? MIN_GRAMS_FIELDS.map(({ kind, label }) => (
<div key={kind} className="flex flex-col gap-2">
<Label htmlFor={`min-${kind}-${rule.id}`}>{label}</Label>
<Input
id={`min-${kind}-${rule.id}`}
type="number"
inputMode="numeric"
min={0}
step={1}
placeholder="leer = nicht überwachen"
value={minGrams[kind] ?? ""}
onChange={(event) =>
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
}
/>
</div>
))
: null}
<div className="flex justify-between gap-2">
<Button
type="button"
+4 -1
View File
@@ -8,7 +8,10 @@ export type AutomationTriggerType =
| "HONEY_BATCH_NUMBER_FORMAT"
| "BOTTLING_LOT_NUMBER_FORMAT"
| "TREATMENT_FOLLOW_UP"
| "FEEDING_FOLLOW_UP";
| "FEEDING_FOLLOW_UP"
| "RAW_MATERIAL_LOT_NUMBER_FORMAT"
| "PRODUCTION_BATCH_NUMBER_FORMAT"
| "RAW_MATERIAL_LOW";
export interface AutomationRule {
id: string;
@@ -23,7 +23,8 @@ export interface ProductionBatch {
export interface CreateProductionBatchInput {
productId: string;
lotNumber: string;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string;
quantity: number;
bestBeforeDate?: string;
@@ -28,7 +28,8 @@ export interface RawMaterialLot {
export interface CreateRawMaterialLotInput {
kind: RawMaterialKind;
lotNumber: string;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string;
quantity: number;
apiaryId?: string;