feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -0,0 +1,263 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AutomationRulesTab } from "./automation-rules-tab";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import { ApiError } from "@/lib/auth/api-client";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/automation/api", () => ({
fetchAutomationRules: vi.fn(),
updateAutomationRule: vi.fn(),
resetAutomationRuleSequence: vi.fn(),
}));
function rule(
triggerType: AutomationTriggerType,
config: Record<string, unknown> | null,
active = true,
): AutomationRule {
return {
id: `rule-${triggerType}`,
triggerType,
active,
config,
actionTemplate: null,
sequenceValue: 0,
createdAt: "2026-10-01T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
}
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
async (id, input) =>
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
</QueryClientProvider>,
);
}
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
type LooseConfig = ReturnType<typeof JSON.parse>;
function savedConfig(): LooseConfig {
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
}
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
const RED_LABEL = /Rot ab mehr als/;
/** The red limit of the natural mite fall, found by the group it sits in. */
function naturalFallRed(view: ReturnType<typeof within>) {
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
return within(group).getByLabelText(RED_LABEL);
}
beforeEach(() => vi.clearAllMocks());
describe("varroa rule form", () => {
it("the varroa form saves bands and shows „LWG Veitshöchheim“ next to the values", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
const view = await card(VARROA_TITLE);
expect(view.getAllByText(/LWG Veitshöchheim/).length).toBeGreaterThan(0);
// The old single threshold shows up as the red limit of the natural fall.
const red = naturalFallRed(view);
expect(red).toHaveValue("10");
await user.clear(red);
await user.type(red, "12,5");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][0]).toBe("rule-VARROA_THRESHOLD");
const config = savedConfig();
expect(config.naturalFall).toMatchObject({ redAbove: 12.5, yellowFrom: 5, winterRedAbove: 0.5 });
expect(config.naturalFall.seasonMonths).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY", "NUCLEUS_COLONY", "MATING_NUCLEUS"]);
expect(config.notify).toBe(true);
});
it("a 400 issue appears under its field", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "Die Einstellung ist ungültig.", {
message: "Die Einstellung ist ungültig.",
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
const message = await view.findByText("Gelb darf nicht über Rot liegen.");
const field = naturalFallRed(view);
expect(field.getAttribute("aria-describedby")).toContain(message.id);
});
it("does not send a value that is not a number", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
const view = await card(VARROA_TITLE);
const red = naturalFallRed(view);
await user.clear(red);
await user.type(red, "abc");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte eine Zahl angeben.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
});
describe("season autopilot form", () => {
it("autopilot rules A1–A10 switch individually", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", { honeySuperMinFrames: 9 })]);
const view = await card("Saison-Autopilot");
const switches = view.getAllByRole("checkbox", { name: /^A\d+ / });
expect(switches).toHaveLength(10);
switches.forEach((box) => expect(box).toBeChecked());
await user.click(view.getByRole("checkbox", { name: /^A3 / }));
expect(view.getByRole("checkbox", { name: /^A3 / })).not.toBeChecked();
expect(view.getByRole("checkbox", { name: /^A4 / })).toBeChecked();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.rules.A3).toBe(false);
expect(Object.values(config.rules).filter(Boolean)).toHaveLength(9);
expect(config.honeySuperMinFrames).toBe(9);
expect(config.winterVarroaWindow).toEqual({ from: "11-15", to: "12-20" });
});
it("shows server issues of the MM-DD fields under them", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "winterVarroaWindow.from", message: "Bitte Monat und Tag als MM-TT angeben." }],
}),
);
const view = await card("Saison-Autopilot");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte Monat und Tag als MM-TT angeben.")).toBeInTheDocument();
});
});
describe("other rule forms", () => {
it("phenology saves calendar fallbacks as MM-DD", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
const view = await card("Pflanzenentwicklung");
const salweide = view.getByLabelText(/^Salweide blüht/);
expect(salweide).toHaveValue("03-20");
await user.clear(salweide);
await user.type(salweide, "03-25");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.calendarFallback.SALWEIDE).toBe("03-25");
expect(config.calendarFallback.WINTER).toBe("11-15");
expect(config.gtsThresholds).toEqual({ SALWEIDE: 200 });
expect(config.swarmSeason).toEqual({ from: "KIRSCHE", until: "LINDE" });
});
it("inspection intervals save per section", async () => {
const user = userEvent.setup();
renderTab([rule("INSPECTION_INTERVAL", null)]);
const view = await card("Durchsicht-Rhythmus");
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig().intervalDays).toEqual({
FRUEHJAHR: 14,
SCHWARMZEIT: 8,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
});
});
it("a chain rule saves the due days and the notify switch", async () => {
const user = userEvent.setup();
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
const view = await card("Weiselzellen: Schwarmkontrolle");
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({ dueInDays: 7, notify: true });
});
it("the split schedule keeps its steps and saves the changed day", async () => {
const user = userEvent.setup();
renderTab([rule("SPLIT_SCHEDULE", null)]);
const view = await card("Ableger-Fahrplan");
const day = view.getByLabelText(/^Begattung prüfen(?!:)/);
expect(day).toHaveValue("21");
await user.clear(day);
await user.type(day, "22");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.RAISE_OWN[1]).toEqual({ step: "MATING_CHECK", offsetDays: 22, windowDays: 3 });
expect(config.QUEEN).toEqual([{ step: "ACCEPTANCE_CHECK", offsetDays: 7 }]);
expect(config.notify).toBe(false);
});
it("the water content form shows both sources and keeps the honey types", async () => {
const user = userEvent.setup();
renderTab([
rule("HONEY_WATER_CONTENT", {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
}),
]);
const view = await card("Wassergehalt Honig");
expect(view.getAllByText(/D\.I\.B\./).length).toBeGreaterThan(0);
expect(view.getAllByText(/HonigV Anlage 2/).length).toBeGreaterThan(0);
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
});
});
it("withdrawal ended offers only the notify switch", async () => {
const user = userEvent.setup();
renderTab([rule("WITHDRAWAL_ENDED", { notify: true })]);
const view = await card("Wartezeit zu Ende");
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ config: { notify: false } });
});
it("an older rule keeps its notify setting when saved", async () => {
const user = userEvent.setup();
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
const view = await card("Keine Königin gesehen und keine Stifte");
expect(view.getByRole("checkbox", { name: "Zusätzlich melden" })).toBeChecked();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 });
});
});
@@ -8,64 +8,28 @@ import {
resetAutomationRuleSequence,
updateAutomationRule,
} from "@/lib/automation/api";
import {
RULE_DESCRIPTIONS,
RULE_LABELS,
SIMPLE_RULE_NOTIFY_DEFAULTS,
issuesByField,
} from "@/lib/automation/rule-configs";
import {
AutomationRule,
AutomationTriggerType,
UpdateAutomationRuleInput,
} from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)",
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> = {
QUEENLESS_NO_EGGS:
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
VARROA_THRESHOLD:
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.",
FEED_STATUS_CRITICAL:
"Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.",
SWARM_MOOD_HIGH:
"Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.",
COLONY_HEALTH_CRITICAL:
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
STOCK_ITEM_LOW:
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
HONEY_BATCH_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
BOTTLING_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
TREATMENT_FOLLOW_UP:
"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.",
};
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
"HONEY_BATCH_NUMBER_FORMAT",
@@ -124,15 +88,23 @@ function ruleDueInDays(rule: AutomationRule): string {
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
}
function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? "");
function ruleNotify(rule: AutomationRule): boolean {
const config = rule.config as { notify?: unknown } | null;
return typeof config?.notify === "boolean"
? config.notify
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
}
function RuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [notify, setNotify] = useState(ruleNotify(rule));
const [issues, setIssues] = useState<Record<string, string>>({});
// A server message goes away as soon as its field is edited.
const clearIssues = (prefix: string) =>
setIssues((current) =>
Object.fromEntries(Object.entries(current).filter(([field]) => !field.startsWith(prefix))),
);
const [format, setFormat] = useState(ruleFormat(rule));
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
@@ -147,10 +119,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
mutationFn: (input: UpdateAutomationRuleInput) =>
updateAutomationRule(rule.id, input),
onSuccess: () => {
setIssues({});
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
toast.success("Regel gespeichert");
},
onError: () => toast.error("Regel konnte nicht gespeichert werden"),
onError: (error) => {
setIssues(issuesByField(error));
toast.error(errorMessage(error, "Regel konnte nicht gespeichert werden"));
},
});
const resetMutation = useMutation({
@@ -173,10 +149,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
return;
}
const config: Record<string, unknown> = { dueInDays: days };
if (rule.triggerType === "VARROA_THRESHOLD") {
config.thresholdMitesPerDay = Number(threshold) || 10;
}
const config: Record<string, unknown> = { notify, dueInDays: days };
if (rule.triggerType === "RAW_MATERIAL_LOW") {
const parsed = parseMinGrams(minGrams);
if (!parsed) {
@@ -191,13 +164,13 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
{numberFormatRule ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`format-${rule.id}`}>Format</Label>
@@ -205,7 +178,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
id={`format-${rule.id}`}
value={format}
placeholder="WH-{YYYY}-{SEQ:4}"
onChange={(event) => setFormat(event.target.value)}
onChange={(event) => {
clearIssues("format");
setFormat(event.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
@@ -269,19 +245,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
max={365}
placeholder="leer = ohne Termin"
value={dueInDays}
onChange={(event) => setDueInDays(event.target.value)}
/>
</div>
) : null}
{rule.triggerType === "VARROA_THRESHOLD" ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
<Input
id={`threshold-${rule.id}`}
type="number"
inputMode="decimal"
value={threshold}
onChange={(event) => setThreshold(event.target.value)}
onChange={(event) => {
clearIssues("dueInDays");
setDueInDays(event.target.value);
}}
/>
</div>
) : null}
@@ -297,13 +264,27 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
step={1}
placeholder="leer = nicht überwachen"
value={minGrams[kind] ?? ""}
onChange={(event) =>
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
}
onChange={(event) => {
clearIssues("minGrams");
setMinGrams((current) => ({ ...current, [kind]: event.target.value }));
}}
/>
</div>
))
: null}
{Object.entries(issues).map(([field, message]) => (
<p key={field} role="alert" className="text-sm font-medium text-destructive">
{message}
</p>
))}
{!numberFormatRule ? (
<ToggleChip label="Zusätzlich melden" checked={notify}
onChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
/>
) : null}
<div className="flex justify-between gap-2">
<Button
type="button"
@@ -350,9 +331,13 @@ export function AutomationRulesTab() {
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) => (
<RuleCard key={rule.id} rule={rule} />
))}
{rules?.map((rule) =>
hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} />
) : (
<RuleCard key={rule.id} rule={rule} />
),
)}
</div>
</div>
);
@@ -0,0 +1,342 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AutomationRulesTab } from "./automation-rules-tab";
import { SpecialRuleCard } from "./special-rule-card";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import { ApiError } from "@/lib/auth/api-client";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/automation/api", () => ({
fetchAutomationRules: vi.fn(),
updateAutomationRule: vi.fn(),
resetAutomationRuleSequence: vi.fn(),
}));
function rule(
triggerType: AutomationTriggerType,
config: Record<string, unknown> | null,
active = true,
): AutomationRule {
return {
id: `rule-${triggerType}`,
triggerType,
active,
config,
actionTemplate: null,
sequenceValue: 0,
createdAt: "2026-10-01T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
}
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
async (id, input) =>
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
</QueryClientProvider>,
);
}
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
type LooseConfig = ReturnType<typeof JSON.parse>;
function savedConfig(): LooseConfig {
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
}
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
function naturalFallRed(view: ReturnType<typeof within>) {
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
return within(group).getByLabelText(/Rot ab mehr als/);
}
beforeEach(() => vi.clearAllMocks());
describe("what a save keeps", () => {
it("keeps non-default month lists and colony types and drops the legacy threshold", async () => {
const user = userEvent.setup();
renderTab([
rule("VARROA_THRESHOLD", {
thresholdMitesPerDay: 8,
naturalFall: { seasonMonths: [4, 5, 6], yellowFrom: 3, redAbove: 8, winterRedAbove: 0.4 },
infestationPercent: { summerMonths: [6], autumnMonths: [11, 12, 1] },
youngColonyTypes: ["YOUNG_COLONY"],
}),
]);
const view = await card(VARROA_TITLE);
// The texts follow the stored months, not fixed words.
expect(view.getByText("Milben pro Tag. April bis Juni.")).toBeInTheDocument();
expect(view.getByText(/^Juli bis März\./)).toBeInTheDocument();
expect(view.getByText("Puderzucker und Auswaschung, November bis Januar")).toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config).not.toHaveProperty("thresholdMitesPerDay");
expect(config.naturalFall).toEqual({
seasonMonths: [4, 5, 6],
yellowFrom: 3,
redAbove: 8,
winterRedAbove: 0.4,
});
expect(config.infestationPercent.summerMonths).toEqual([6]);
expect(config.infestationPercent.autumnMonths).toEqual([11, 12, 1]);
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY"]);
});
it("keeps calibrated phases, other thresholds and unknown keys of the phenology rule", async () => {
const user = userEvent.setup();
renderTab([
rule("PHENOLOGY", {
gtsThresholds: { SALWEIDE: 150, KIRSCHE: 300 },
calibratePhases: ["KIRSCHE"],
futureKey: { a: 1 },
}),
]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.gtsThresholds).toEqual({ SALWEIDE: 150, KIRSCHE: 300 });
expect(config.calibratePhases).toEqual(["KIRSCHE"]);
expect(config.futureKey).toEqual({ a: 1 });
});
it("clearing the Salweide threshold sends an empty threshold group", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
const view = await card("Pflanzenentwicklung");
await user.clear(view.getByLabelText(/^Salweide: Wärmesumme ab/));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig().gtsThresholds).toEqual({});
});
it("switches a rule on without a config", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null, false)]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Aktivieren" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0]).toEqual([
"rule-PHENOLOGY",
{ active: true },
]);
});
it("switches a rule off without a config", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null, true)]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ active: false });
});
});
describe("server issues", () => {
it("a server issue disappears once its field is edited", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Gelb darf nicht über Rot liegen.")).toBeInTheDocument();
await user.type(naturalFallRed(view), "1");
expect(view.queryByText("Gelb darf nicht über Rot liegen.")).not.toBeInTheDocument();
});
it("shows issues of values without a field in an alert list", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [
{ field: "naturalFall.seasonMonths", message: "Monate nur einmal angeben." },
{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." },
],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Monate nur einmal angeben.")).toHaveAttribute("role", "alert");
expect(view.getByText("Unbekannter Volkstyp.")).toHaveAttribute("role", "alert");
});
it("shows a calendar order issue of the phenology rule", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "calendarFallback.APFEL", message: "Das Datum darf nicht vor dem der vorherigen Phase liegen." }],
}),
);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(
await view.findByText("Das Datum darf nicht vor dem der vorherigen Phase liegen."),
).toBeInTheDocument();
});
it("clears the messages when the rule is switched", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValueOnce(
new ApiError(400, "x", {
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
});
it("ties the hint and the message of a switch to it", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", { issues: [{ field: "rules.A9", message: "Ungültig." }] }),
);
const view = await card("Saison-Autopilot");
const described = view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby");
expect(document.getElementById(String(described))).toHaveTextContent("Annahme");
await user.click(view.getByRole("button", { name: "Speichern" }));
const message = await view.findByText("Ungültig.");
expect(view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby")).toContain(
message.id,
);
});
});
describe("number checks", () => {
it("asks for whole days where days are meant", async () => {
const user = userEvent.setup();
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
const view = await card("Weiselzellen: Schwarmkontrolle");
const days = view.getByLabelText(/^Fällig nach/);
for (const text of ["1,5", "-2", "x"]) {
await user.clear(days);
await user.type(days, text);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte ganze Tage angeben.")).toBeInTheDocument();
}
expect(updateAutomationRule).not.toHaveBeenCalled();
});
it("asks for a whole number for the frames", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
const view = await card("Saison-Autopilot");
const frames = view.getByLabelText(/^Honigraum ab besetzten Waben/);
await user.clear(frames);
await user.type(frames, "8,5");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte eine ganze Zahl angeben.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
it("does not clamp an inspection interval outside its bounds", async () => {
const user = userEvent.setup();
renderTab([rule("INSPECTION_INTERVAL", null)]);
const view = await card("Durchsicht-Rhythmus");
for (let i = 0; i < 3; i += 1) {
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
}
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte 7 bis 9 Tage wählen.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
});
describe("a refreshed rule", () => {
it("starts the form again when the rule was updated with a new shape", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
</QueryClientProvider>
);
const first = rule("PHENOLOGY", null);
const view = render(wrap(first));
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("200");
view.rerender(
wrap({
...first,
updatedAt: "2026-10-02T00:00:00Z",
config: { gtsThresholds: { SALWEIDE: 150 } },
}),
);
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("150");
});
});
describe("fix round 2", () => {
it("keeps unsaved edits when the rule is switched", async () => {
const user = userEvent.setup();
const phenology = rule("PHENOLOGY", null);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
</QueryClientProvider>
);
const view = render(wrap(phenology));
const field = screen.getByLabelText(/^Salweide blüht/);
await user.clear(field);
await user.type(field, "03-25");
view.rerender(wrap({ ...phenology, active: false, updatedAt: "2026-10-03T00:00:00Z" }));
expect(screen.getByLabelText(/^Salweide blüht/)).toHaveValue("03-25");
});
it("hides issues without a field as soon as the user edits anything", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
await user.type(naturalFallRed(view), "1");
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
});
it("clears the message of an older rule when its field changes", async () => {
const user = userEvent.setup();
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", { issues: [{ field: "dueInDays", message: "Zu viele Tage." }] }),
);
const view = await card("Keine Königin gesehen und keine Stifte");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Zu viele Tage.")).toBeInTheDocument();
await user.type(view.getByLabelText(/Fällig nach/), "0");
expect(view.queryByText("Zu viele Tage.")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,31 @@
"use client";
import { NotifyToggle } from "./notify-form";
import {
RuleFormProps,
RuleFormShell,
RuleInputField,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
const FIELDS = { dueInDays: "int", notify: "bool" } as const;
/** Follow-up task after an event (Weiselzellen gesehen, Königin zugesetzt). */
export function ChainForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues });
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<RuleInputField
trigger={rule.triggerType}
draft={draft}
path="dueInDays"
label="Fällig nach"
unit="Tagen"
inputMode="numeric"
hint="Ganze Tage, 0 bis 365."
/>
<NotifyToggle trigger={rule.triggerType} draft={draft} />
</RuleFormShell>
);
}
@@ -0,0 +1,78 @@
"use client";
import { Field } from "@/components/form/field";
import { NumberStepper } from "@/components/form/number-stepper";
import {
RuleFormProps,
RuleFormShell,
hintWithSource,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
export const SEGMENT_LABELS = {
FRUEHJAHR: "Frühjahr",
SCHWARMZEIT: "Schwarmzeit",
SOMMER: "Sommer",
SPAETSOMMER: "Spätsommer",
HERBST: "Herbst",
} as const;
type Segment = keyof typeof SEGMENT_LABELS;
const SEGMENTS = Object.keys(SEGMENT_LABELS) as Segment[];
const MIN_DAYS = 7;
const MAX_SWARM_DAYS = 9;
const MAX_DAYS = 35;
const RANGES = Object.fromEntries(
SEGMENTS.map((segment) => {
const max = segment === "SCHWARMZEIT" ? MAX_SWARM_DAYS : MAX_DAYS;
return [
`intervalDays.${segment}`,
{ min: MIN_DAYS, max, message: `Bitte ${MIN_DAYS} bis ${max} Tage wählen.` },
];
}),
);
const FIELDS = Object.fromEntries(
SEGMENTS.map((segment) => [`intervalDays.${segment}`, "number"]),
) as Record<`intervalDays.${Segment}`, "number">;
/** Days between two inspections per part of the season. */
export function InspectionIntervalForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues, ranges: RANGES });
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
{SEGMENTS.map((segment) => {
const path = `intervalDays.${segment}`;
const label = SEGMENT_LABELS[segment];
const hint =
segment === "SCHWARMZEIT" ? `${MIN_DAYS} bis ${MAX_SWARM_DAYS} Tage.` : `${MIN_DAYS} bis ${MAX_DAYS} Tage.`;
return (
<Field
key={segment}
id={`inspection_interval-${path}`}
label={label}
required
hint={hintWithSource(rule.triggerType, path, hint)}
error={draft.issue(path) ?? null}
>
{({ id, describedBy, invalid }) => (
<NumberStepper
id={id}
value={String(draft.values[path] ?? "")}
onChange={(value) => draft.set(path, value)}
min={7}
unitLabel="Tage"
decrementLabel={`${label} einen Tag weniger`}
incrementLabel={`${label} einen Tag mehr`}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
})}
</RuleFormShell>
);
}
@@ -0,0 +1,57 @@
const MONTH_NAMES = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
function validMonths(months: unknown): number[] {
if (!Array.isArray(months)) return [];
const valid = months.filter(
(month): month is number => Number.isInteger(month) && month >= 1 && month <= 12,
);
return [...new Set(valid)].sort((a, b) => a - b);
}
/** Runs of neighbouring months; December and January join into one run. */
function runs(months: number[]): number[][] {
const result: number[][] = [];
for (const month of months) {
const last = result[result.length - 1];
if (last && last[last.length - 1] === month - 1) last.push(month);
else result.push([month]);
}
const first = result[0];
const last = result[result.length - 1];
if (result.length > 1 && first[0] === 1 && last[last.length - 1] === 12) {
result.pop();
result[0] = [...last, ...first];
}
return result;
}
function describeRun(run: number[]): string {
const first = MONTH_NAMES[run[0] - 1];
if (run.length === 1) return first;
const last = MONTH_NAMES[run[run.length - 1] - 1];
return run.length === 2 ? `${first} und ${last}` : `${first} bis ${last}`;
}
/** "März bis Oktober", "Mai bis Juli", "November bis Februar"; "" for no month. */
export function describeMonths(months: unknown): string {
return runs(validMonths(months)).map(describeRun).join(", ");
}
/** The months of the year that are not in the list. */
export function otherMonths(months: unknown): number[] {
const used = new Set(validMonths(months));
return Array.from({ length: 12 }, (_, index) => index + 1).filter((month) => !used.has(month));
}
@@ -0,0 +1,39 @@
"use client";
import {
RuleFormProps,
RuleFormShell,
RuleToggle,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
import { AutomationTriggerType } from "@/lib/automation/types";
import { RuleDraft } from "./use-rule-draft";
export const NOTIFY_LABEL = "Zusätzlich melden";
export const NOTIFY_HINT = "Du bekommst dann zusätzlich eine Meldung.";
/** The switch every rule with a notification has. */
export function NotifyToggle({
trigger,
draft,
}: {
trigger: AutomationTriggerType;
draft: RuleDraft;
}) {
return (
<RuleToggle trigger={trigger} draft={draft} path="notify" label={NOTIFY_LABEL} hint={NOTIFY_HINT} />
);
}
const FIELDS = { notify: "bool" } as const;
/** Rules that only decide whether they also notify (Wartezeit zu Ende). */
export function NotifyForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues });
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<NotifyToggle trigger={rule.triggerType} draft={draft} />
</RuleFormShell>
);
}
@@ -0,0 +1,120 @@
"use client";
import { EnumSelect } from "@/components/form/enum-select";
import { Field } from "@/components/form/field";
import { FormSection } from "@/components/form/form-section";
import {
hintWithSource,
RuleFormProps,
RuleFormShell,
RuleInputField,
RuleToggle,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
export const PHASE_LABELS = {
SALWEIDE: "Salweide blüht",
KIRSCHE: "Kirsche blüht",
APFEL: "Apfel blüht",
HOLUNDER: "Holunder blüht",
LINDE: "Linde blüht",
TRACHTENDE: "Tracht vorbei",
HERBST: "Efeu blüht",
WINTER: "Winterruhe",
} as const;
type Phase = keyof typeof PHASE_LABELS;
const PHASES = Object.keys(PHASE_LABELS) as Phase[];
const FIELDS = {
useGts: "bool",
"gtsThresholds.SALWEIDE": "optionalNumber",
calibrateFromReports: "bool",
...Object.fromEntries(PHASES.map((phase) => [`calendarFallback.${phase}`, "text"])),
"swarmSeason.from": "text",
"swarmSeason.until": "text",
maxGapShare: "number",
} as const;
/** How the season is found: report, temperature sum (GTS) or calendar. */
export function PhenologyForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues });
const shared = { trigger: rule.triggerType, draft } as const;
const trigger = rule.triggerType;
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<FormSection title="Wärmesumme (GTS)" description="Aus den Tagestemperaturen ab 1. Januar.">
<RuleToggle
trigger={trigger}
draft={draft}
path="useGts"
label="Wärmesumme nutzen"
/>
<RuleInputField
{...shared}
path="gtsThresholds.SALWEIDE"
label="Salweide: Wärmesumme ab"
unit="°C"
required={false}
hint="Leer lassen = keine Schätzung für die Salweide."
/>
<RuleToggle
trigger={trigger}
draft={draft}
path="calibrateFromReports"
label="Aus meinen Meldungen lernen"
hint="Kirsche und Apfel: Die Schwelle kommt aus den Meldungen der Vorjahre."
/>
<RuleInputField
{...shared}
path="maxGapShare"
label="Höchstens so viele Wetterlücken"
hint="Anteil der Tage ohne Wetterdaten, 0 bis 0,5. Mehr Lücken: die Wärmesumme zählt nicht."
/>
</FormSection>
<FormSection
title="Kalender als Rückfall"
description="Gilt, wenn weder Meldung noch Wärmesumme da sind. Monat und Tag, zum Beispiel 03-20."
>
{PHASES.map((phase) => (
<RuleInputField
key={phase}
{...shared}
path={`calendarFallback.${phase}`}
label={PHASE_LABELS[phase]}
inputMode="text"
placeholder="MM-TT"
/>
))}
</FormSection>
<FormSection title="Schwarmzeit" description="Von dieser Phase bis zu dieser Phase.">
{(["from", "until"] as const).map((edge) => {
const path = `swarmSeason.${edge}`;
return (
<Field
key={edge}
id={`phenology-${path}`}
label={edge === "from" ? "Beginn" : "Ende"}
required
hint={hintWithSource(trigger, path)}
error={draft.issue(path) ?? null}
>
{({ id, describedBy, invalid }) => (
<EnumSelect
id={id}
labels={PHASE_LABELS}
value={String(draft.values[path]) as Phase}
onChange={(value) => draft.set(path, value)}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
})}
</FormSection>
</RuleFormShell>
);
}
@@ -0,0 +1,202 @@
"use client";
import { FormEvent, ReactNode, useId, useMemo } from "react";
import { Field } from "@/components/form/field";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
RULE_FORM_DEFAULTS,
RULE_VALUE_SOURCES,
ValueSource,
} from "@/lib/automation/rule-configs";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import {
FieldSpecs,
RuleConfigValue,
RuleDraft,
RuleDraftOptions,
mergeConfig,
useRuleDraft,
} from "./use-rule-draft";
/** What every rule form gets from its card. */
export interface RuleFormProps {
rule: AutomationRule;
/** Field messages of a rejected save (400 `issues`). */
serverIssues: Record<string, string>;
pending: boolean;
onSave: (config: RuleConfigValue) => void;
}
export function sourceText(source: ValueSource): string {
return source.kind === "SOURCE" ? `Quelle: ${source.label}` : source.label;
}
/** The source as a hint line, so it is text on the page and not only a tooltip. */
export function hintWithSource(
trigger: AutomationTriggerType,
path: string,
hint?: string,
): string | undefined {
const source = RULE_VALUE_SOURCES[trigger]?.[path];
return [hint, source ? sourceText(source) : null].filter(Boolean).join(" · ") || undefined;
}
interface FieldContext {
trigger: AutomationTriggerType;
draft: RuleDraft;
}
interface RuleInputFieldProps extends FieldContext {
path: string;
label: string;
unit?: string;
hint?: string;
placeholder?: string;
required?: boolean;
/** `decimal` shows the number keypad with a comma. */
inputMode?: "decimal" | "numeric" | "text";
}
/** One text or number setting with its source and the server's message under it. */
export function RuleInputField({
trigger,
draft,
path,
label,
unit,
hint,
placeholder,
required = true,
inputMode = "decimal",
}: RuleInputFieldProps) {
const error = draft.issue(path) ?? null;
return (
<Field
id={`${trigger.toLowerCase()}-${path}`}
label={label}
required={required}
hint={hintWithSource(trigger, path, hint)}
error={error}
>
{({ id, describedBy, invalid }) => (
<div className="flex items-center gap-2">
<Input
id={id}
type="text"
inputMode={inputMode}
autoComplete="off"
placeholder={placeholder}
className="w-32"
value={String(draft.values[path] ?? "")}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
onChange={(event) => draft.set(path, event.target.value)}
/>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
)}
</Field>
);
}
interface RuleToggleProps {
trigger: AutomationTriggerType;
draft: RuleDraft;
path: string;
label: string;
hint?: string;
}
/** A yes/no setting as a large tile (tick and word, not only colour). */
export function RuleToggle({ trigger, draft, path, label, hint }: RuleToggleProps) {
const baseId = useId();
const hintText = hintWithSource(trigger, path, hint);
const error = draft.issue(path);
const hintId = hintText ? `${baseId}-hint` : undefined;
const errorId = error ? `${baseId}-error` : undefined;
return (
<div className="flex flex-col gap-1">
<ToggleChip
label={label}
checked={draft.values[path] === true}
onChange={(checked) => draft.set(path, checked)}
describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined}
/>
{hintText ? (
<p id={hintId} className="text-xs text-muted-foreground">
{hintText}
</p>
) : null}
{error ? (
<p id={errorId} role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : null}
</div>
);
}
interface RuleFormShellProps {
draft: RuleDraft;
pending: boolean;
/** Called on submit; builds the config and hands it to `onSave`. */
onSubmit: () => void;
children: ReactNode;
}
/** The fields of a rule and its one main button. */
export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormShellProps) {
const unmatched = draft.unmatchedIssues();
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit();
}
return (
<form noValidate onSubmit={handleSubmit} className="flex flex-col gap-4">
{children}
{unmatched.length > 0 ? (
<ul className="flex flex-col gap-1">
{unmatched.map((message, index) => (
<li key={`${index}-${message}`} role="alert" className="text-sm font-medium text-destructive">
{message}
</li>
))}
</ul>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
{pending ? "Speichert…" : "Speichern"}
</Button>
</div>
</form>
);
}
/** Submits the draft: nothing is sent while a field is not usable. */
export function submitDraft(draft: RuleDraft, onSave: (config: RuleConfigValue) => void): void {
const config = draft.build();
if (config) onSave(config);
}
/**
* Draft of a rule from its stored config on top of the built-in values.
* `normalize` may fix up older stored shapes before the form reads them.
*/
export function useRuleFormDraft(
rule: AutomationRule,
fields: FieldSpecs,
options: RuleDraftOptions & {
normalize?: (merged: RuleConfigValue, stored: unknown) => RuleConfigValue;
} = {},
): RuleDraft {
const { normalize, ...draftOptions } = options;
const base = useMemo(() => {
const defaults =
(RULE_FORM_DEFAULTS as Partial<Record<AutomationTriggerType, object>>)[rule.triggerType] ?? {};
const merged = mergeConfig(defaults as RuleConfigValue, rule.config);
return normalize ? normalize(merged, rule.config) : merged;
}, [rule.triggerType, rule.config, normalize]);
return useRuleDraft(base, fields, draftOptions);
}
@@ -0,0 +1,116 @@
"use client";
import { FormSection } from "@/components/form/form-section";
import {
RuleFormProps,
RuleFormShell,
RuleInputField,
RuleToggle,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
export const AUTOPILOT_RULES = [
{ id: "A1", label: "Durchsicht fällig" },
{ id: "A2", label: "Varroa messen" },
{ id: "A3", label: "Behandlungserfolg messen" },
{ id: "A4", label: "Sommerbehandlung planen" },
{ id: "A5", label: "Einfüttern" },
{ id: "A6", label: "Füttern bei wenig Futter" },
{ id: "A7", label: "Honigraum prüfen" },
{ id: "A8", label: "Umweiseln planen" },
{ id: "A9", label: "Einwinterung erfassen" },
{ id: "A10", label: "Auswinterung erfassen" },
] as const;
const FIELDS = {
honeySuperMinFrames: "count",
varroaMeasureIntervalDays: "int",
"winterVarroaWindow.from": "text",
"winterVarroaWindow.to": "text",
queenMaxAgeYears: "count",
suggestionTtlDays: "int",
maxStepsPerColony: "count",
...Object.fromEntries(AUTOPILOT_RULES.map(({ id }) => [`rules.${id}`, "bool"])),
} as const;
/** Numbers and dates of the season autopilot, and one switch per rule A1 to A10. */
export function SeasonAutopilotForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues });
const shared = {
trigger: rule.triggerType,
draft,
serverIssues,
inputMode: "numeric",
} as const;
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<FormSection title="Welche Vorschläge?" description="Jede Regel schaltest du einzeln.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{AUTOPILOT_RULES.map(({ id, label }) => (
<RuleToggle
key={id}
trigger={rule.triggerType}
draft={draft}
path={`rules.${id}`}
label={`${id} ${label}`}
/>
))}
</div>
</FormSection>
<FormSection title="Werte">
<RuleInputField
{...shared}
path="honeySuperMinFrames"
label="Honigraum ab besetzten Waben"
unit="Waben"
hint="1 bis 40."
/>
<RuleInputField
{...shared}
path="varroaMeasureIntervalDays"
label="Varroa messen alle"
unit="Tage"
hint="7 bis 90."
/>
<RuleInputField
{...shared}
path="winterVarroaWindow.from"
label="Winter-Messung ab"
inputMode="text"
placeholder="MM-TT"
hint="Monat und Tag, zum Beispiel 11-15."
/>
<RuleInputField
{...shared}
path="winterVarroaWindow.to"
label="Winter-Messung bis"
inputMode="text"
placeholder="MM-TT"
hint="Monat und Tag, zum Beispiel 12-20."
/>
<RuleInputField
{...shared}
path="queenMaxAgeYears"
label="Königin gilt als alt ab"
unit="Jahren"
hint="1 bis 10."
/>
<RuleInputField
{...shared}
path="suggestionTtlDays"
label="Vorschlag läuft ab nach"
unit="Tagen"
hint="Nach dem Fälligkeitstag. 1 bis 365."
/>
<RuleInputField
{...shared}
path="maxStepsPerColony"
label="Höchstens so viele Schritte je Volk"
unit="Schritte"
hint="1 bis 3."
/>
</FormSection>
</RuleFormShell>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useMemo } from "react";
import { FormSection } from "@/components/form/form-section";
import { RULE_FORM_DEFAULTS } from "@/lib/automation/rule-configs";
import { NotifyToggle } from "./notify-form";
import {
RuleFormProps,
RuleFormShell,
RuleInputField,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
import { FieldSpecs, RuleConfigValue, getPath, mergeConfig } from "./use-rule-draft";
const VARIANTS = [
{ key: "RAISE_OWN", title: "Ableger mit eigener Nachzucht" },
{ key: "QUEEN_CELL", title: "Ableger mit Weiselzelle" },
{ key: "QUEEN", title: "Ableger mit zugesetzter Königin" },
] as const;
export const STEP_LABELS: Record<string, string> = {
RAISE_CELLS_CHECK: "Weiselzellen prüfen",
MATING_CHECK: "Begattung prüfen",
EGGS_CHECK: "Stifte prüfen",
ACCEPTANCE_CHECK: "Annahme prüfen",
};
interface StepRow {
path: string;
windowPath: string | null;
label: string;
}
function stepRows(config: unknown): StepRow[][] {
return VARIANTS.map(({ key }) => {
const steps = getPath(config, key);
return (Array.isArray(steps) ? steps : []).map((entry, index) => {
const name = String((entry as { step?: unknown }).step ?? "");
const hasWindow = (entry as { windowDays?: unknown }).windowDays !== undefined;
return {
path: `${key}[${index}].offsetDays`,
windowPath: hasWindow ? `${key}[${index}].windowDays` : null,
label: STEP_LABELS[name] ?? name,
};
});
});
}
/** Follow-up checks after a split, in days from the day it was made. */
export function SplitScheduleForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const merged = useMemo(
() => mergeConfig(RULE_FORM_DEFAULTS.SPLIT_SCHEDULE as unknown as RuleConfigValue, rule.config),
[rule.config],
);
const rows = useMemo(() => stepRows(merged), [merged]);
const fields = useMemo<FieldSpecs>(() => {
const specs: Record<string, "int" | "bool"> = { notify: "bool" };
for (const row of rows.flat()) {
specs[row.path] = "int";
if (row.windowPath) specs[row.windowPath] = "int";
}
return specs;
}, [rows]);
const draft = useRuleFormDraft(rule, fields, { serverIssues });
const shared = { trigger: rule.triggerType, draft, inputMode: "numeric" } as const;
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
{VARIANTS.map(({ key, title }, variantIndex) => (
<FormSection key={key} title={title} description="Tage ab dem Tag des Ablegers.">
{rows[variantIndex].map((row) => (
<div key={row.path} className="flex flex-col gap-3">
<RuleInputField
{...shared}
path={row.path}
label={row.label}
unit="Tage danach"
hint="0 bis 120."
/>
{row.windowPath ? (
<RuleInputField
{...shared}
path={row.windowPath}
label={`${row.label}: Zeitfenster`}
unit="Tage"
hint="Bis so viele Tage später ist die Kontrolle noch sinnvoll. 0 bis 30."
/>
) : null}
</div>
))}
</FormSection>
))}
<NotifyToggle trigger={rule.triggerType} draft={draft} />
</RuleFormShell>
);
}
@@ -0,0 +1,229 @@
import { useCallback, useMemo, useState } from "react";
export type RuleConfigValue = Record<string, unknown>;
/**
* `number` takes decimals; `int` whole days (no decimals, no negatives);
* `count` a whole number; `optionalNumber` may be left empty.
*/
export type FieldType = "number" | "optionalNumber" | "int" | "count" | "text" | "bool";
export type FieldSpecs = Readonly<Record<string, FieldType>>;
export type DraftValue = string | boolean;
const NOT_A_NUMBER_MESSAGE = "Bitte eine Zahl angeben.";
const NUMBER_PATTERN = /^-?\d+(\.\d+)?$/;
const WHOLE_PATTERN = /^\d+$/;
const WHOLE_MESSAGES = {
int: "Bitte ganze Tage angeben.",
count: "Bitte eine ganze Zahl angeben.",
} as const;
/** Keys whose object is a list of entries: the stored object replaces the default as a whole. */
const WHOLE_OBJECT_KEYS = new Set(["byHoneyType", "gtsThresholds"]);
function isRecord(value: unknown): value is RuleConfigValue {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** The stored config on top of the defaults; arrays and listed objects come over whole. */
export function mergeConfig(defaults: RuleConfigValue, stored: unknown): RuleConfigValue {
const result: RuleConfigValue = structuredClone(defaults);
if (!isRecord(stored)) return result;
for (const [key, value] of Object.entries(stored)) {
const base = result[key];
result[key] =
isRecord(value) && isRecord(base) && !WHOLE_OBJECT_KEYS.has(key)
? mergeConfig(base, value)
: structuredClone(value);
}
return result;
}
/** `a.b[1].c` -> ["a", "b", 1, "c"], the same notation the server uses for field paths. */
function tokens(path: string): (string | number)[] {
return path
.split(/\.|\[(\d+)\]/)
.filter((part) => part !== undefined && part !== "")
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
}
export function getPath(source: unknown, path: string): unknown {
let current: unknown = source;
for (const token of tokens(path)) {
if (current === null || typeof current !== "object") return undefined;
current = (current as Record<string | number, unknown>)[token];
}
return current;
}
function setPath(target: RuleConfigValue, path: string, value: unknown): void {
const parts = tokens(path);
let current = target as Record<string | number, unknown>;
parts.slice(0, -1).forEach((token) => {
current = current[token] as Record<string | number, unknown>;
});
current[parts[parts.length - 1]] = value;
}
function deletePath(target: RuleConfigValue, path: string): void {
const parts = tokens(path);
let current: unknown = target;
for (const token of parts.slice(0, -1)) {
if (current === null || typeof current !== "object") return;
current = (current as Record<string | number, unknown>)[token];
}
if (current !== null && typeof current === "object") {
delete (current as Record<string | number, unknown>)[parts[parts.length - 1]];
}
}
/** Decimal comma or point; anything else is not a number. */
export function parseNumber(text: string): number | null {
const normalized = text.trim().replace(",", ".");
return NUMBER_PATTERN.test(normalized) ? Number(normalized) : null;
}
function initialValue(base: RuleConfigValue, path: string, type: FieldType): DraftValue {
const value = getPath(base, path);
if (type === "bool") return value === true;
if (typeof value === "number") return String(value).replace(".", ",");
return typeof value === "string" ? value : "";
}
export interface FieldRange {
min: number;
max: number;
message: string;
}
export interface RuleDraftOptions {
/** Field messages of a rejected save; one disappears once its field is edited. */
serverIssues?: Record<string, string>;
/** Local bounds checked on submit, shown as a field error. */
ranges?: Readonly<Record<string, FieldRange>>;
}
export interface RuleDraft {
/** The stored config on top of the defaults, as the form started from it. */
base: RuleConfigValue;
values: Record<string, DraftValue>;
/** Message of a field: a local mistake first, then the server's. */
issue: (path: string) => string | undefined;
/** Server messages of paths that have no field in the form. */
unmatchedIssues: () => string[];
set: (path: string, value: DraftValue) => void;
/** The config to send, or `null` when a field is not usable (errors are then set). */
build: () => RuleConfigValue | null;
}
const NO_ISSUES: Record<string, string> = {};
function readNumber(
type: FieldType,
text: string,
): { value: number } | { error: string } | "empty" {
if (text.trim() === "") return type === "optionalNumber" ? "empty" : { error: errorFor(type) };
if (type === "int" || type === "count") {
const normalized = text.trim();
return WHOLE_PATTERN.test(normalized)
? { value: Number(normalized) }
: { error: WHOLE_MESSAGES[type] };
}
const parsed = parseNumber(text);
return parsed === null ? { error: NOT_A_NUMBER_MESSAGE } : { value: parsed };
}
function errorFor(type: FieldType): string {
return type === "int" || type === "count" ? WHOLE_MESSAGES[type] : NOT_A_NUMBER_MESSAGE;
}
/**
* Form state for one rule: the fields as text (so half-typed numbers stay),
* everything else of the config (month lists, colony types, honey types...)
* passes through unchanged.
*/
export function useRuleDraft(
base: RuleConfigValue,
fields: FieldSpecs,
options: RuleDraftOptions = {},
): RuleDraft {
const serverIssues = options.serverIssues ?? NO_ISSUES;
const ranges = options.ranges;
const [values, setValues] = useState<Record<string, DraftValue>>(() =>
Object.fromEntries(
Object.entries(fields).map(([path, type]) => [path, initialValue(base, path, type)]),
),
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [seenIssues, setSeenIssues] = useState(serverIssues);
const [edited, setEdited] = useState<ReadonlySet<string>>(new Set());
const [acted, setActed] = useState(false);
// A new answer of the server shows all its messages again.
if (seenIssues !== serverIssues) {
setSeenIssues(serverIssues);
setEdited(new Set());
setActed(false);
}
const set = useCallback((path: string, value: DraftValue) => {
setValues((current) => ({ ...current, [path]: value }));
setEdited((current) => new Set(current).add(path));
setActed(true);
setErrors((current) => {
if (!(path in current)) return current;
const rest = { ...current };
delete rest[path];
return rest;
});
}, []);
const build = useCallback((): RuleConfigValue | null => {
const config = structuredClone(base);
const found: Record<string, string> = {};
for (const [path, type] of Object.entries(fields)) {
const value = values[path];
if (type === "bool") {
setPath(config, path, value === true);
continue;
}
if (type === "text") {
setPath(config, path, String(value).trim());
continue;
}
const read = readNumber(type, String(value));
if (read === "empty") {
deletePath(config, path);
continue;
}
if ("error" in read) {
found[path] = read.error;
continue;
}
const range = ranges?.[path];
if (range && (read.value < range.min || read.value > range.max)) {
found[path] = range.message;
continue;
}
setPath(config, path, read.value);
}
setErrors(found);
return Object.keys(found).length > 0 ? null : config;
}, [base, fields, ranges, values]);
const issue = useCallback(
(path: string) => errors[path] ?? (edited.has(path) ? undefined : serverIssues[path]),
[errors, edited, serverIssues],
);
const unmatchedIssues = useCallback(
() =>
Object.entries(serverIssues)
.filter(([path]) => !acted && !(path in fields))
.map(([, message]) => message),
[serverIssues, fields, acted],
);
return useMemo(
() => ({ base, values, issue, unmatchedIssues, set, build }),
[base, values, issue, unmatchedIssues, set, build],
);
}
@@ -0,0 +1,97 @@
"use client";
import { FormSection } from "@/components/form/form-section";
import { NotifyToggle } from "./notify-form";
import {
RuleFormProps,
RuleFormShell,
RuleInputField,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
import { describeMonths, otherMonths } from "./month-ranges";
import { RuleConfigValue, getPath } from "./use-rule-draft";
const FIELDS = {
"naturalFall.yellowFrom": "number",
"naturalFall.redAbove": "number",
"naturalFall.winterRedAbove": "number",
"infestationPercent.summer.yellowFrom": "number",
"infestationPercent.summer.redAbove": "number",
"infestationPercent.autumn.yellowFrom": "number",
"infestationPercent.autumn.redAbove": "number",
youngColonyFactor: "number",
recheckAfterDays: "int",
notify: "bool",
} as const;
/** The single threshold of before is the red limit of the natural mite fall. */
function fromLegacyThreshold(merged: RuleConfigValue, stored: unknown): RuleConfigValue {
const legacy = (stored as { thresholdMitesPerDay?: unknown } | null)?.thresholdMitesPerDay;
const rest = { ...merged };
delete rest.thresholdMitesPerDay;
const hasBands = Boolean(
(stored as { naturalFall?: { redAbove?: unknown } } | null)?.naturalFall?.redAbove,
);
if (typeof legacy === "number" && !hasBands) {
return { ...rest, naturalFall: { ...(rest.naturalFall as object), redAbove: legacy } };
}
return rest;
}
/** Colour bands of the varroa traffic light per method and season. */
export function VarroaThresholdForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues, normalize: fromLegacyThreshold });
const shared = { trigger: rule.triggerType, draft } as const;
const seasonMonths = getPath(draft.base, "naturalFall.seasonMonths");
const seasonText = describeMonths(seasonMonths);
const winterText = describeMonths(otherMonths(seasonMonths));
const summerText = describeMonths(getPath(draft.base, "infestationPercent.summerMonths"));
const autumnText = describeMonths(getPath(draft.base, "infestationPercent.autumnMonths"));
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<FormSection
title="Natürlicher Milbenfall"
description={`Milben pro Tag. ${seasonText}.`}
>
<RuleInputField {...shared} path="naturalFall.yellowFrom" label="Gelb ab" unit="Milben/Tag" />
<RuleInputField {...shared} path="naturalFall.redAbove" label="Rot ab mehr als" unit="Milben/Tag" />
<RuleInputField
{...shared}
path="naturalFall.winterRedAbove"
label="Rot im Winter ab mehr als"
unit="Milben/Tag"
hint={`${winterText}. Dort gibt es kein Gelb.`}
/>
</FormSection>
<FormSection
title={`Puderzucker und Auswaschung, ${summerText}`}
description="Milben je 100 Bienen."
>
<RuleInputField {...shared} path="infestationPercent.summer.yellowFrom" label="Gelb ab" unit="%" />
<RuleInputField {...shared} path="infestationPercent.summer.redAbove" label="Rot ab mehr als" unit="%" />
</FormSection>
<FormSection title={`Puderzucker und Auswaschung, ${autumnText}`}>
<RuleInputField {...shared} path="infestationPercent.autumn.yellowFrom" label="Gelb ab" unit="%" />
<RuleInputField {...shared} path="infestationPercent.autumn.redAbove" label="Rot ab mehr als" unit="%" />
</FormSection>
<FormSection title="Jungvölker und Nachkontrolle">
<RuleInputField
{...shared}
path="youngColonyFactor"
label="Faktor für Jungvölker"
hint="Mal diesen Wert gelten die Grenzen des Milbenfalls, nur dort (Annahme). Größer als 0, höchstens 1."
/>
<RuleInputField
{...shared}
path="recheckAfterDays"
label="Nachkontrolle nach der Behandlung ab"
unit="Tagen"
inputMode="numeric"
hint="So lange zeigt die Ampel nach einer Behandlung nichts an. 0 bis 60."
/>
</FormSection>
<NotifyToggle trigger={rule.triggerType} draft={draft} />
</RuleFormShell>
);
}
@@ -0,0 +1,41 @@
"use client";
import {
RuleFormProps,
RuleFormShell,
RuleInputField,
submitDraft,
useRuleFormDraft,
} from "./rule-form-parts";
const FIELDS = { warnAbovePercent: "number", confirmAbovePercent: "number" } as const;
/** Thresholds for the water content of honey. Sorts with their own limit stay as they are. */
export function WaterContentForm({ rule, serverIssues, pending, onSave }: RuleFormProps) {
const draft = useRuleFormDraft(rule, FIELDS, { serverIssues });
const sorts = Object.keys(
((rule.config as { byHoneyType?: Record<string, unknown> } | null)?.byHoneyType ?? {}),
);
const shared = { trigger: rule.triggerType, draft, unit: "%" } as const;
return (
<RuleFormShell draft={draft} pending={pending} onSubmit={() => submitDraft(draft, onSave)}>
<RuleInputField
{...shared}
path="warnAbovePercent"
label="Warnung ab mehr als"
hint="Zwischen 10 und 30. Nicht über der Bestätigung."
/>
<RuleInputField
{...shared}
path="confirmAbovePercent"
label="Bestätigung ab mehr als"
hint="Beim Abfüllen muss man dann bestätigen."
/>
{sorts.length > 0 ? (
<p className="text-sm text-muted-foreground">
Eigene Grenzen für: {sorts.join(", ")}. Sie bleiben unverändert.
</p>
) : null}
</RuleFormShell>
);
}
@@ -0,0 +1,98 @@
"use client";
import { ComponentType, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateAutomationRule } from "@/lib/automation/api";
import { RULE_DESCRIPTIONS, RULE_LABELS, issuesByField } from "@/lib/automation/rule-configs";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChainForm } from "./rule-forms/chain-form";
import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form";
import { NotifyForm } from "./rule-forms/notify-form";
import { PhenologyForm } from "./rule-forms/phenology-form";
import { RuleFormProps } from "./rule-forms/rule-form-parts";
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
import { WaterContentForm } from "./rule-forms/water-content-form";
function NoSettingsForm() {
return <p className="text-sm text-muted-foreground">Hier gibt es nichts einzustellen.</p>;
}
const RULE_FORMS: Partial<Record<AutomationTriggerType, ComponentType<RuleFormProps>>> = {
VARROA_THRESHOLD: VarroaThresholdForm,
INSPECTION_INTERVAL: InspectionIntervalForm,
QUEEN_CELLS_SWARM_CHECK: ChainForm,
QUEEN_ACCEPTANCE_CHECK: ChainForm,
SPLIT_SCHEDULE: SplitScheduleForm,
COLONY_ENDED_CLEANUP: NoSettingsForm,
WITHDRAWAL_ENDED: NotifyForm,
HONEY_WATER_CONTENT: WaterContentForm,
SEASON_AUTOPILOT: SeasonAutopilotForm,
PHENOLOGY: PhenologyForm,
};
/** Rules with a form of their own instead of the generic card. */
export function hasRuleForm(trigger: AutomationTriggerType): boolean {
return trigger in RULE_FORMS;
}
export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient();
const [issues, setIssues] = useState<Record<string, string>>({});
const Form = RULE_FORMS[rule.triggerType] as ComponentType<RuleFormProps>;
const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: RuleConfigValue }) =>
updateAutomationRule(rule.id, input),
onSuccess: () => {
setIssues({});
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
toast.success("Regel gespeichert");
},
onError: (error) => {
setIssues(issuesByField(error));
toast.error(errorMessage(error, "Regel konnte nicht gespeichert werden"));
},
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
<Form
key={JSON.stringify(rule.config)}
rule={rule}
serverIssues={issues}
pending={mutation.isPending}
onSave={(config) => mutation.mutate({ config })}
/>
<div>
<Button
type="button"
variant="outline"
disabled={mutation.isPending}
onClick={() => {
setIssues({});
mutation.mutate({ active: !rule.active });
}}
>
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
</div>
</CardContent>
</Card>
);
}