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:
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>
|
||||
);
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
+116
@@ -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],
|
||||
);
|
||||
}
|
||||
+97
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,15 +7,18 @@ interface ToggleChipProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Ids of a hint or error shown next to the tile. */
|
||||
describedBy?: string;
|
||||
}
|
||||
|
||||
/** A yes/no observation as a large tile; state shown by tick AND colour. */
|
||||
export function ToggleChip({ label, checked, onChange }: ToggleChipProps) {
|
||||
export function ToggleChip({ label, checked, onChange, describedBy }: ToggleChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-describedby={describedBy}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { AUTOMATION_TRIGGER_TYPES } from "./types";
|
||||
import { RULE_FORM_DEFAULTS, RULE_LABELS, RULE_VALUE_SOURCES, issuesByField } from "./rule-configs";
|
||||
|
||||
describe("RULE_LABELS", () => {
|
||||
it("every trigger has a label", () => {
|
||||
for (const trigger of AUTOMATION_TRIGGER_TYPES) {
|
||||
expect(RULE_LABELS[trigger], trigger).toBeTruthy();
|
||||
}
|
||||
expect(RULE_LABELS.SEASON_AUTOPILOT).toBe("Saison-Autopilot");
|
||||
expect(RULE_LABELS.PHENOLOGY).toBe("Pflanzenentwicklung");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RULE_VALUE_SOURCES", () => {
|
||||
const sources = RULE_VALUE_SOURCES;
|
||||
|
||||
it("follows the spec tables for the varroa and water values", () => {
|
||||
expect(sources.VARROA_THRESHOLD?.["naturalFall.redAbove"]).toMatchObject({ kind: "SOURCE" });
|
||||
expect(sources.VARROA_THRESHOLD?.["naturalFall.redAbove"].label).toContain("LWG Veitshöchheim");
|
||||
expect(sources.VARROA_THRESHOLD?.youngColonyFactor).toEqual({
|
||||
label: "Erfahrungswert",
|
||||
kind: "SOURCE",
|
||||
});
|
||||
expect(sources.HONEY_WATER_CONTENT?.warnAbovePercent.label).toContain("D.I.B.");
|
||||
expect(sources.HONEY_WATER_CONTENT?.confirmAbovePercent.label).toContain("HonigV Anlage 2");
|
||||
});
|
||||
|
||||
it("names Erfahrungswert for the inspection intervals and the acceptance check", () => {
|
||||
const intervals = sources.INSPECTION_INTERVAL!;
|
||||
expect(intervals["intervalDays.FRUEHJAHR"].label).toBe("Erfahrungswert");
|
||||
expect(intervals["intervalDays.HERBST"].label).toBe("Erfahrungswert");
|
||||
expect(intervals["intervalDays.SCHWARMZEIT"].label).toBe(
|
||||
"Bieneninstitute (LWG, LLH Kirchhain)",
|
||||
);
|
||||
expect(sources.QUEEN_ACCEPTANCE_CHECK?.dueInDays.label).toBe("Erfahrungswert");
|
||||
});
|
||||
|
||||
it("gives every split step a source from its variant", () => {
|
||||
const steps = sources.SPLIT_SCHEDULE!;
|
||||
expect(steps["RAISE_OWN[0].offsetDays"].label).toBe("Erfahrungswert");
|
||||
expect(steps["RAISE_OWN[1].windowDays"].label).toBe("Erfahrungswert");
|
||||
expect(steps["QUEEN_CELL[0].offsetDays"].kind).toBe("ASSUMPTION");
|
||||
expect(steps["QUEEN[0].offsetDays"].label).toBe("wie Königin zugesetzt");
|
||||
});
|
||||
|
||||
it("marks the autopilot window and phenology guesses as assumptions", () => {
|
||||
const autopilot = sources.SEASON_AUTOPILOT!;
|
||||
expect(autopilot["winterVarroaWindow.from"]).toEqual({
|
||||
label: "Annahme (LWG-Konzept)",
|
||||
kind: "ASSUMPTION",
|
||||
});
|
||||
expect(autopilot.varroaMeasureIntervalDays.label).toBe("Erfahrungswert (28 Tage)");
|
||||
expect(autopilot["rules.A9"].label).toBe("Annahme");
|
||||
expect(autopilot["rules.A10"].label).toBe("Annahme");
|
||||
for (let n = 1; n <= 10; n += 1) expect(autopilot[`rules.A${n}`], `A${n}`).toBeDefined();
|
||||
const phenology = sources.PHENOLOGY!;
|
||||
expect(phenology["swarmSeason.from"].label).toBe("Annahme nach Bieneninstitut-Praxis");
|
||||
expect(phenology["gtsThresholds.SALWEIDE"].label).toBe(
|
||||
"DWD; Gleichsetzung mit Salweidenblüte = Annahme",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Pinned to the table "Regeln" of the global constraints, so a changed default is a decision.
|
||||
describe("RULE_FORM_DEFAULTS", () => {
|
||||
it("matches the built-in settings of the backend", () => {
|
||||
expect(RULE_FORM_DEFAULTS).toEqual({
|
||||
VARROA_THRESHOLD: {
|
||||
naturalFall: {
|
||||
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
winterRedAbove: 0.5,
|
||||
},
|
||||
infestationPercent: {
|
||||
summerMonths: [5, 6, 7],
|
||||
summer: { yellowFrom: 1, redAbove: 5 },
|
||||
autumnMonths: [8, 9, 10],
|
||||
autumn: { yellowFrom: 2, redAbove: 5 },
|
||||
},
|
||||
youngColonyFactor: 0.5,
|
||||
youngColonyTypes: ["YOUNG_COLONY", "NUCLEUS_COLONY", "MATING_NUCLEUS"],
|
||||
recheckAfterDays: 14,
|
||||
notify: true,
|
||||
},
|
||||
INSPECTION_INTERVAL: {
|
||||
intervalDays: { FRUEHJAHR: 14, SCHWARMZEIT: 7, SOMMER: 14, SPAETSOMMER: 21, HERBST: 21 },
|
||||
},
|
||||
QUEEN_CELLS_SWARM_CHECK: { dueInDays: 7, notify: false },
|
||||
QUEEN_ACCEPTANCE_CHECK: { dueInDays: 7, notify: false },
|
||||
SPLIT_SCHEDULE: {
|
||||
RAISE_OWN: [
|
||||
{ step: "RAISE_CELLS_CHECK", offsetDays: 9 },
|
||||
{ step: "MATING_CHECK", offsetDays: 21, windowDays: 3 },
|
||||
{ step: "EGGS_CHECK", offsetDays: 28 },
|
||||
],
|
||||
QUEEN_CELL: [{ step: "EGGS_CHECK", offsetDays: 21 }],
|
||||
QUEEN: [{ step: "ACCEPTANCE_CHECK", offsetDays: 7 }],
|
||||
notify: false,
|
||||
},
|
||||
WITHDRAWAL_ENDED: { notify: true },
|
||||
HONEY_WATER_CONTENT: { warnAbovePercent: 18, confirmAbovePercent: 20, byHoneyType: {} },
|
||||
SEASON_AUTOPILOT: {
|
||||
honeySuperMinFrames: 8,
|
||||
varroaMeasureIntervalDays: 28,
|
||||
winterVarroaWindow: { from: "11-15", to: "12-20" },
|
||||
queenMaxAgeYears: 2,
|
||||
suggestionTtlDays: 30,
|
||||
maxStepsPerColony: 3,
|
||||
rules: {
|
||||
A1: true, A2: true, A3: true, A4: true, A5: true,
|
||||
A6: true, A7: true, A8: true, A9: true, A10: true,
|
||||
},
|
||||
},
|
||||
PHENOLOGY: {
|
||||
useGts: true,
|
||||
gtsThresholds: { SALWEIDE: 200 },
|
||||
calibrateFromReports: true,
|
||||
calibratePhases: ["KIRSCHE", "APFEL"],
|
||||
calendarFallback: {
|
||||
SALWEIDE: "03-20", KIRSCHE: "04-15", APFEL: "04-28", HOLUNDER: "05-30",
|
||||
LINDE: "06-20", TRACHTENDE: "08-01", HERBST: "09-15", WINTER: "11-15",
|
||||
},
|
||||
swarmSeason: { from: "KIRSCHE", until: "LINDE" },
|
||||
maxGapShare: 0.1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issuesByField", () => {
|
||||
it("maps the issues of a 400 to their fields", () => {
|
||||
const error = new ApiError(400, "Die Einstellung ist ungültig.", {
|
||||
message: "Die Einstellung ist ungültig.",
|
||||
issues: [
|
||||
{ field: "naturalFall.redAbove", message: "Zu hoch." },
|
||||
{ field: "rules.A1", message: "Ungültig." },
|
||||
],
|
||||
});
|
||||
expect(issuesByField(error)).toEqual({
|
||||
"naturalFall.redAbove": "Zu hoch.",
|
||||
"rules.A1": "Ungültig.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the first message of a field", () => {
|
||||
const error = new ApiError(400, "x", {
|
||||
issues: [
|
||||
{ field: "a", message: "erste" },
|
||||
{ field: "a", message: "zweite" },
|
||||
],
|
||||
});
|
||||
expect(issuesByField(error)).toEqual({ a: "erste" });
|
||||
});
|
||||
|
||||
it("returns nothing for other errors and malformed bodies", () => {
|
||||
expect(issuesByField(new Error("x"))).toEqual({});
|
||||
expect(issuesByField(new ApiError(500, "x", { issues: "nope" }))).toEqual({});
|
||||
expect(issuesByField(new ApiError(400, "x", { issues: [{ field: 3 }, null] }))).toEqual({});
|
||||
expect(issuesByField(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { AutomationTriggerType } from "./types";
|
||||
|
||||
export const RULE_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",
|
||||
INSPECTION_INTERVAL: "Durchsicht-Rhythmus",
|
||||
QUEEN_CELLS_SWARM_CHECK: "Weiselzellen: Schwarmkontrolle",
|
||||
QUEEN_ACCEPTANCE_CHECK: "Königin zugesetzt: Annahme prüfen",
|
||||
SPLIT_SCHEDULE: "Ableger-Fahrplan",
|
||||
COLONY_ENDED_CLEANUP: "Volk beendet: Aufräumen",
|
||||
WITHDRAWAL_ENDED: "Wartezeit zu Ende",
|
||||
HONEY_WATER_CONTENT: "Wassergehalt Honig",
|
||||
SEASON_AUTOPILOT: "Saison-Autopilot",
|
||||
PHENOLOGY: "Pflanzenentwicklung",
|
||||
};
|
||||
|
||||
export const RULE_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
||||
QUEENLESS_NO_EGGS:
|
||||
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
|
||||
VARROA_THRESHOLD:
|
||||
"Ampel und Vorschlag „Behandlung prüfen“ nach Messmethode, Jahreszeit und Volkstyp.",
|
||||
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.",
|
||||
INSPECTION_INTERVAL:
|
||||
"Nach wie vielen Tagen eine Durchsicht fällig ist. Der Wert hängt vom Abschnitt der Saison ab.",
|
||||
QUEEN_CELLS_SWARM_CHECK:
|
||||
"Weiselzellen gesehen: Schwarmkontrolle vorschlagen, fällig nach den eingestellten Tagen.",
|
||||
QUEEN_ACCEPTANCE_CHECK:
|
||||
"Königin zugesetzt: Prüfung der Annahme vorschlagen, fällig nach den eingestellten Tagen.",
|
||||
SPLIT_SCHEDULE:
|
||||
"Nach dem Ableger die Kontrollen vorschlagen. Die Tage zählen ab dem Tag der Bildung.",
|
||||
COLONY_ENDED_CLEANUP:
|
||||
"Wird ein Volk beendet, schlägt die App vor, offene Aufgaben und Vorschläge dieses Volkes aufzuräumen. Es gibt nichts einzustellen.",
|
||||
WITHDRAWAL_ENDED:
|
||||
"Die Wartezeit einer Behandlung ist vorbei: Ernte ist wieder erlaubt.",
|
||||
HONEY_WATER_CONTENT:
|
||||
"Ab welchem Wassergehalt die App beim Honig warnt und ab wann beim Abfüllen eine Bestätigung nötig ist.",
|
||||
SEASON_AUTOPILOT:
|
||||
"Die App schlägt je Volk die nächsten Schritte der Saison vor. Jede Regel A1 bis A10 lässt sich einzeln ausschalten.",
|
||||
PHENOLOGY:
|
||||
"Wie die App die Saison erkennt: aus deiner Meldung, aus der Wärmesumme (GTS) oder nach dem Kalender.",
|
||||
};
|
||||
|
||||
export interface ValueSource {
|
||||
label: string;
|
||||
kind: "SOURCE" | "ASSUMPTION";
|
||||
}
|
||||
|
||||
/** Built-in settings the forms start from when the stored config is partial. */
|
||||
export const RULE_FORM_DEFAULTS = {
|
||||
VARROA_THRESHOLD: {
|
||||
naturalFall: {
|
||||
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
winterRedAbove: 0.5,
|
||||
},
|
||||
infestationPercent: {
|
||||
summerMonths: [5, 6, 7],
|
||||
summer: { yellowFrom: 1, redAbove: 5 },
|
||||
autumnMonths: [8, 9, 10],
|
||||
autumn: { yellowFrom: 2, redAbove: 5 },
|
||||
},
|
||||
youngColonyFactor: 0.5,
|
||||
youngColonyTypes: ["YOUNG_COLONY", "NUCLEUS_COLONY", "MATING_NUCLEUS"],
|
||||
recheckAfterDays: 14,
|
||||
notify: true,
|
||||
},
|
||||
INSPECTION_INTERVAL: {
|
||||
intervalDays: {
|
||||
FRUEHJAHR: 14,
|
||||
SCHWARMZEIT: 7,
|
||||
SOMMER: 14,
|
||||
SPAETSOMMER: 21,
|
||||
HERBST: 21,
|
||||
},
|
||||
},
|
||||
QUEEN_CELLS_SWARM_CHECK: { dueInDays: 7, notify: false },
|
||||
QUEEN_ACCEPTANCE_CHECK: { dueInDays: 7, notify: false },
|
||||
SPLIT_SCHEDULE: {
|
||||
RAISE_OWN: [
|
||||
{ step: "RAISE_CELLS_CHECK", offsetDays: 9 },
|
||||
{ step: "MATING_CHECK", offsetDays: 21, windowDays: 3 },
|
||||
{ step: "EGGS_CHECK", offsetDays: 28 },
|
||||
],
|
||||
QUEEN_CELL: [{ step: "EGGS_CHECK", offsetDays: 21 }],
|
||||
QUEEN: [{ step: "ACCEPTANCE_CHECK", offsetDays: 7 }],
|
||||
notify: false,
|
||||
},
|
||||
WITHDRAWAL_ENDED: { notify: true },
|
||||
HONEY_WATER_CONTENT: {
|
||||
warnAbovePercent: 18,
|
||||
confirmAbovePercent: 20,
|
||||
byHoneyType: {},
|
||||
},
|
||||
SEASON_AUTOPILOT: {
|
||||
honeySuperMinFrames: 8,
|
||||
varroaMeasureIntervalDays: 28,
|
||||
winterVarroaWindow: { from: "11-15", to: "12-20" },
|
||||
queenMaxAgeYears: 2,
|
||||
suggestionTtlDays: 30,
|
||||
maxStepsPerColony: 3,
|
||||
rules: {
|
||||
A1: true,
|
||||
A2: true,
|
||||
A3: true,
|
||||
A4: true,
|
||||
A5: true,
|
||||
A6: true,
|
||||
A7: true,
|
||||
A8: true,
|
||||
A9: true,
|
||||
A10: true,
|
||||
},
|
||||
},
|
||||
PHENOLOGY: {
|
||||
useGts: true,
|
||||
gtsThresholds: { SALWEIDE: 200 },
|
||||
calibrateFromReports: true,
|
||||
calibratePhases: ["KIRSCHE", "APFEL"],
|
||||
calendarFallback: {
|
||||
SALWEIDE: "03-20",
|
||||
KIRSCHE: "04-15",
|
||||
APFEL: "04-28",
|
||||
HOLUNDER: "05-30",
|
||||
LINDE: "06-20",
|
||||
TRACHTENDE: "08-01",
|
||||
HERBST: "09-15",
|
||||
WINTER: "11-15",
|
||||
},
|
||||
swarmSeason: { from: "KIRSCHE", until: "LINDE" },
|
||||
maxGapShare: 0.1,
|
||||
},
|
||||
} as const satisfies Partial<Record<AutomationTriggerType, object>>;
|
||||
|
||||
// Spec rows citing "Fahrplan §10" without an institute show "Erfahrungswert".
|
||||
const FAHRPLAN: ValueSource = { label: "Erfahrungswert", kind: "SOURCE" };
|
||||
const ASSUMPTION: ValueSource = { label: "Annahme", kind: "ASSUMPTION" };
|
||||
|
||||
function source(label: string): ValueSource {
|
||||
return { label, kind: "SOURCE" };
|
||||
}
|
||||
|
||||
function assumption(label: string): ValueSource {
|
||||
return { label, kind: "ASSUMPTION" };
|
||||
}
|
||||
|
||||
const LWG_NATURAL_FALL = source(
|
||||
"LWG Veitshöchheim; Gültigkeit ab März = Annahme", // table from Fahrplan §10
|
||||
);
|
||||
const LWG_WINTER = source(
|
||||
"LWG Veitshöchheim („ab November Behandlung schon über 0,5“); Ende Februar = Annahme",
|
||||
);
|
||||
const LWG_SUMMER = source("LWG Veitshöchheim („Juli“); Mai und Juni = Annahme");
|
||||
const LWG_AUTUMN = source("LWG Veitshöchheim („September“); August und Oktober = Annahme");
|
||||
const SWARM_CHECK = source(
|
||||
"Bieneninstitute (LWG, LLH Kirchhain): Verdeckelung nach 8–9 Tagen",
|
||||
);
|
||||
|
||||
/** Plan steps per split variant: offsets and windows with their source (spec 7.4). */
|
||||
function splitScheduleSources(): Record<string, ValueSource> {
|
||||
const variantSource: Record<string, ValueSource> = {
|
||||
RAISE_OWN: FAHRPLAN,
|
||||
QUEEN_CELL: ASSUMPTION,
|
||||
QUEEN: source("wie Königin zugesetzt"),
|
||||
};
|
||||
const result: Record<string, ValueSource> = {};
|
||||
const defaults = RULE_FORM_DEFAULTS.SPLIT_SCHEDULE as unknown as Record<
|
||||
string,
|
||||
{ offsetDays: number; windowDays?: number }[]
|
||||
>;
|
||||
for (const [variant, steps] of Object.entries(defaults)) {
|
||||
if (!Array.isArray(steps)) continue;
|
||||
steps.forEach((step, index) => {
|
||||
result[`${variant}[${index}].offsetDays`] = variantSource[variant];
|
||||
if (step.windowDays !== undefined) {
|
||||
result[`${variant}[${index}].windowDays`] = variantSource[variant];
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Where the built-in values come from; paths follow the config keys. */
|
||||
export const RULE_VALUE_SOURCES: Partial<
|
||||
Record<AutomationTriggerType, Record<string, ValueSource>>
|
||||
> = {
|
||||
VARROA_THRESHOLD: {
|
||||
"naturalFall.yellowFrom": LWG_NATURAL_FALL,
|
||||
"naturalFall.redAbove": LWG_NATURAL_FALL,
|
||||
"naturalFall.winterRedAbove": LWG_WINTER,
|
||||
"infestationPercent.summer.yellowFrom": LWG_SUMMER,
|
||||
"infestationPercent.summer.redAbove": LWG_SUMMER,
|
||||
"infestationPercent.autumn.yellowFrom": LWG_AUTUMN,
|
||||
"infestationPercent.autumn.redAbove": LWG_AUTUMN,
|
||||
youngColonyFactor: FAHRPLAN,
|
||||
recheckAfterDays: FAHRPLAN,
|
||||
},
|
||||
INSPECTION_INTERVAL: {
|
||||
"intervalDays.FRUEHJAHR": FAHRPLAN,
|
||||
"intervalDays.SCHWARMZEIT": source(
|
||||
"Bieneninstitute (LWG, LLH Kirchhain)",
|
||||
),
|
||||
"intervalDays.SOMMER": FAHRPLAN,
|
||||
"intervalDays.SPAETSOMMER": FAHRPLAN,
|
||||
"intervalDays.HERBST": FAHRPLAN,
|
||||
},
|
||||
QUEEN_CELLS_SWARM_CHECK: { dueInDays: SWARM_CHECK },
|
||||
QUEEN_ACCEPTANCE_CHECK: { dueInDays: FAHRPLAN },
|
||||
SPLIT_SCHEDULE: splitScheduleSources(),
|
||||
HONEY_WATER_CONTENT: {
|
||||
warnAbovePercent: source("D.I.B., Warenzeichen „Echter Deutscher Honig“"),
|
||||
confirmAbovePercent: source("HonigV Anlage 2 (RL 2001/110/EG Anhang II)"),
|
||||
},
|
||||
SEASON_AUTOPILOT: {
|
||||
honeySuperMinFrames: assumption("Annahme (Imkerpraxis, 8 Waben)"),
|
||||
varroaMeasureIntervalDays: source("Erfahrungswert (28 Tage)"),
|
||||
"winterVarroaWindow.from": assumption("Annahme (LWG-Konzept)"),
|
||||
"winterVarroaWindow.to": assumption("Annahme (LWG-Konzept)"),
|
||||
queenMaxAgeYears: assumption("Annahme (Imkerpraxis, 2 Jahre)"),
|
||||
suggestionTtlDays: ASSUMPTION,
|
||||
maxStepsPerColony: ASSUMPTION,
|
||||
"rules.A1": source("Fahrplan, Durchsicht-Rhythmus"), // spec 7.2
|
||||
"rules.A2": source("Erfahrungswert (28 Tage); Winterfenster = Annahme (LWG-Konzept)"),
|
||||
"rules.A3": source("LWG Veitshöchheim"),
|
||||
"rules.A4": source("LWG-Varroakonzept: Behandlung nach der letzten Ernte"),
|
||||
"rules.A5": source("Imkerpraxis; bewusst ohne Zielmenge"),
|
||||
"rules.A6": source("Erfahrungswert"), // Lastenheft §23
|
||||
"rules.A7": source("Imkerpraxis; 8 Waben = Annahme"),
|
||||
"rules.A8": source("Imkerpraxis; 2 Jahre = Annahme"),
|
||||
"rules.A9": assumption("Annahme"), // Annahme 16
|
||||
"rules.A10": assumption("Annahme"), // Annahme 16
|
||||
},
|
||||
PHENOLOGY: {
|
||||
"gtsThresholds.SALWEIDE": source(
|
||||
"DWD; Gleichsetzung mit Salweidenblüte = Annahme",
|
||||
),
|
||||
"calendarFallback.SALWEIDE": ASSUMPTION,
|
||||
"calendarFallback.KIRSCHE": ASSUMPTION,
|
||||
"calendarFallback.APFEL": ASSUMPTION,
|
||||
"calendarFallback.HOLUNDER": ASSUMPTION,
|
||||
"calendarFallback.LINDE": ASSUMPTION,
|
||||
"calendarFallback.TRACHTENDE": ASSUMPTION,
|
||||
"calendarFallback.HERBST": ASSUMPTION,
|
||||
"calendarFallback.WINTER": ASSUMPTION,
|
||||
"swarmSeason.from": assumption("Annahme nach Bieneninstitut-Praxis"),
|
||||
"swarmSeason.until": assumption("Annahme nach Bieneninstitut-Praxis"),
|
||||
maxGapShare: ASSUMPTION,
|
||||
},
|
||||
};
|
||||
|
||||
/** The server's field messages of a rejected config (400 with `issues`). */
|
||||
export function issuesByField(error: unknown): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const body = (error as { body?: unknown } | null | undefined)?.body;
|
||||
const issues = (body as { issues?: unknown } | null | undefined)?.issues;
|
||||
if (!Array.isArray(issues)) return result;
|
||||
for (const issue of issues) {
|
||||
if (typeof issue !== "object" || issue === null) continue;
|
||||
const { field, message } = issue as { field?: unknown; message?: unknown };
|
||||
if (typeof field !== "string" || typeof message !== "string") continue;
|
||||
if (!(field in result)) result[field] = message;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** Default of the notify switch for the older rules (`DEFAULT_RULES` of the backend). */
|
||||
export const SIMPLE_RULE_NOTIFY_DEFAULTS: Partial<Record<AutomationTriggerType, boolean>> = {
|
||||
QUEENLESS_NO_EGGS: true,
|
||||
COLONY_HEALTH_CRITICAL: true,
|
||||
FEED_STATUS_CRITICAL: false,
|
||||
SWARM_MOOD_HIGH: false,
|
||||
STOCK_ITEM_LOW: false,
|
||||
RAW_MATERIAL_LOW: false,
|
||||
TREATMENT_FOLLOW_UP: false,
|
||||
FEEDING_FOLLOW_UP: false,
|
||||
};
|
||||
@@ -1,17 +1,29 @@
|
||||
export type AutomationTriggerType =
|
||||
| "QUEENLESS_NO_EGGS"
|
||||
| "VARROA_THRESHOLD"
|
||||
| "FEED_STATUS_CRITICAL"
|
||||
| "SWARM_MOOD_HIGH"
|
||||
| "COLONY_HEALTH_CRITICAL"
|
||||
| "STOCK_ITEM_LOW"
|
||||
| "HONEY_BATCH_NUMBER_FORMAT"
|
||||
| "BOTTLING_LOT_NUMBER_FORMAT"
|
||||
| "TREATMENT_FOLLOW_UP"
|
||||
| "FEEDING_FOLLOW_UP"
|
||||
| "RAW_MATERIAL_LOT_NUMBER_FORMAT"
|
||||
| "PRODUCTION_BATCH_NUMBER_FORMAT"
|
||||
| "RAW_MATERIAL_LOW";
|
||||
export const AUTOMATION_TRIGGER_TYPES = [
|
||||
"QUEENLESS_NO_EGGS",
|
||||
"VARROA_THRESHOLD",
|
||||
"FEED_STATUS_CRITICAL",
|
||||
"SWARM_MOOD_HIGH",
|
||||
"COLONY_HEALTH_CRITICAL",
|
||||
"STOCK_ITEM_LOW",
|
||||
"HONEY_BATCH_NUMBER_FORMAT",
|
||||
"BOTTLING_LOT_NUMBER_FORMAT",
|
||||
"TREATMENT_FOLLOW_UP",
|
||||
"FEEDING_FOLLOW_UP",
|
||||
"RAW_MATERIAL_LOT_NUMBER_FORMAT",
|
||||
"PRODUCTION_BATCH_NUMBER_FORMAT",
|
||||
"RAW_MATERIAL_LOW",
|
||||
"INSPECTION_INTERVAL",
|
||||
"QUEEN_CELLS_SWARM_CHECK",
|
||||
"QUEEN_ACCEPTANCE_CHECK",
|
||||
"SPLIT_SCHEDULE",
|
||||
"COLONY_ENDED_CLEANUP",
|
||||
"WITHDRAWAL_ENDED",
|
||||
"HONEY_WATER_CONTENT",
|
||||
"SEASON_AUTOPILOT",
|
||||
"PHENOLOGY",
|
||||
] as const;
|
||||
|
||||
export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number];
|
||||
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user