feat(frontend): Automatisierungen-Verwaltung + erreichbare Trigger (Modul 30)

Neue Seite /settings/automation-rules (nur Administrator, Sidebar-Link
unter Verwaltung): zeigt alle drei fest hinterlegten Regeln mit
Beschreibung, Aktiv/Inaktiv-Umschalter, editierbarem Aufgabentext und
– nur bei Varroa-Schwellenwert – editierbarem Schwellenwert.

Zusätzlich zwei Lücken im Durchsicht-Formular geschlossen, ohne die
wären zwei der drei Trigger über die Oberfläche nie auslösbar gewesen:
- "Stifte vorhanden"-Checkbox ergänzt (eggsPresent wurde vom Formular
  bisher gar nicht gesendet, obwohl Backend und Typ es unterstützen)
- "Futtervorrat ausreichend"-Checkbox durch ein Select mit
  ausreichend/niedrig/kritisch ersetzt (bisher nur binär, "kritisch"
  war nicht erreichbar)

Live gegen das Backend verifiziert: Speichern einer Durchsicht mit
Königin nicht gesehen + keine Stifte erzeugt automatisch die erwartete
Aufgabe (sichtbar unter /tasks) und eine Benachrichtigung, zero
Konsolenfehler. Aktiv/Inaktiv-Umschalter auf der Automatisierungen-Seite
live getestet.
This commit is contained in:
Nick Szaunig committed 2026-08-24 17:34:29 +02:00
1 parent 3f47a6ae8d
commit 5d32026092
6 files changed
+212 -12

No files matched your search

@@ -40,8 +40,11 @@ export default function NewInspectionPage() {
const [date, setDate] = useState(todayIsoDate()); const [date, setDate] = useState(todayIsoDate());
const [colonyStrength, setColonyStrength] = useState(""); const [colonyStrength, setColonyStrength] = useState("");
const [queenSeen, setQueenSeen] = useState(false); const [queenSeen, setQueenSeen] = useState(false);
const [eggsPresent, setEggsPresent] = useState(true);
const [queenCellsPresent, setQueenCellsPresent] = useState(false); const [queenCellsPresent, setQueenCellsPresent] = useState(false);
const [feedStatusOk, setFeedStatusOk] = useState(true); const [feedStatus, setFeedStatus] = useState<"ausreichend" | "niedrig" | "kritisch">(
"ausreichend",
);
const [healthIssues, setHealthIssues] = useState(""); const [healthIssues, setHealthIssues] = useState("");
const [varroaIssues, setVarroaIssues] = useState(""); const [varroaIssues, setVarroaIssues] = useState("");
const [measures, setMeasures] = useState(""); const [measures, setMeasures] = useState("");
@@ -74,8 +77,9 @@ export default function NewInspectionPage() {
date: new Date(date).toISOString(), date: new Date(date).toISOString(),
colonyStrength: colonyStrength ? Number(colonyStrength) : undefined, colonyStrength: colonyStrength ? Number(colonyStrength) : undefined,
queenSeen, queenSeen,
eggsPresent,
queenCellsPresent, queenCellsPresent,
feedStatus: feedStatusOk ? "ausreichend" : "niedrig", feedStatus,
healthIssues: healthIssues || undefined, healthIssues: healthIssues || undefined,
varroaIssues: varroaIssues || undefined, varroaIssues: varroaIssues || undefined,
measures: measures || undefined, measures: measures || undefined,
@@ -142,6 +146,19 @@ export default function NewInspectionPage() {
/> />
Königin gesehen Königin gesehen
</label> </label>
<label
className="flex h-11 items-center gap-3 text-sm"
htmlFor="inspection-eggs-present"
>
<input
id="inspection-eggs-present"
type="checkbox"
className="size-5"
checked={eggsPresent}
onChange={(event) => setEggsPresent(event.target.checked)}
/>
Stifte vorhanden
</label>
<label <label
className="flex h-11 items-center gap-3 text-sm" className="flex h-11 items-center gap-3 text-sm"
htmlFor="inspection-queen-cells" htmlFor="inspection-queen-cells"
@@ -155,16 +172,24 @@ export default function NewInspectionPage() {
/> />
Weiselzellen vorhanden Weiselzellen vorhanden
</label> </label>
<label className="flex h-11 items-center gap-3 text-sm" htmlFor="inspection-feed-ok"> </div>
<input <div className="flex flex-col gap-2">
id="inspection-feed-ok" <Label htmlFor="inspection-feed-status">Futterstatus</Label>
type="checkbox" <Select
className="size-5" value={feedStatus}
checked={feedStatusOk} onValueChange={(value) =>
onChange={(event) => setFeedStatusOk(event.target.checked)} setFeedStatus((value ?? "ausreichend") as typeof feedStatus)
/> }
Futtervorrat ausreichend >
</label> <SelectTrigger id="inspection-feed-status" className="h-11 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ausreichend">Ausreichend</SelectItem>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="kritisch">Kritisch</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="inspection-varroa">Varroa-Befund</Label> <Label htmlFor="inspection-varroa">Varroa-Befund</Label>
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { Zap } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen & keine Stifte",
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
};
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden → Kontrollaufgabe.",
VARROA_THRESHOLD:
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert → Behandlung prüfen.",
FEED_STATUS_CRITICAL:
"Futterstatus bei einer Durchsicht auf „kritisch“ gesetzt → Fütterungsaufgabe.",
};
function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? "");
}
function RuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) =>
updateAutomationRule(rule.id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
toast.success("Regel gespeichert");
},
onError: () => toast.error("Regel konnte nicht gespeichert werden"),
});
function handleSave() {
const input: { config?: Record<string, unknown>; actionTemplate?: string } = {
actionTemplate: actionTemplate || undefined,
};
if (rule.triggerType === "VARROA_THRESHOLD") {
input.config = { thresholdMitesPerDay: Number(threshold) || 10 };
}
mutation.mutate(input);
}
return (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{TRIGGER_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>
<div className="flex flex-col gap-2">
<Label htmlFor={`template-${rule.id}`}>Aufgabentext</Label>
<Input
id={`template-${rule.id}`}
value={actionTemplate}
onChange={(event) => setActionTemplate(event.target.value)}
/>
</div>
{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)}
/>
</div>
) : null}
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ active: !rule.active })}
>
{rule.active ? "Deaktivieren" : "Aktivieren"}
</Button>
<Button type="button" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
</div>
</CardContent>
</Card>
);
}
export default function AutomationRulesPage() {
const { data: rules, isLoading, isError } = useQuery({
queryKey: ["automation-rules"],
queryFn: fetchAutomationRules,
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Zap}
title="Automatisierungen"
description="Fest hinterlegte Regeln aktivieren, deaktivieren und anpassen"
gradient="var(--gradient-sky)"
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Automatisierungen konnten nicht geladen werden.
</p>
) : null}
{rules?.map((rule) => (
<RuleCard key={rule.id} rule={rule} />
))}
</main>
);
}
@@ -22,6 +22,7 @@ import {
Warehouse, Warehouse,
Webhook, Webhook,
Wrench, Wrench,
Zap,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AuthUser } from "@/lib/auth/auth-context"; import type { AuthUser } from "@/lib/auth/auth-context";
@@ -82,6 +83,7 @@ const ADMIN_GROUP: NavGroup = {
{ label: "Benutzer", href: "/settings/users", icon: Users }, { label: "Benutzer", href: "/settings/users", icon: Users },
{ label: "Audit-Log", href: "/settings/audit-log", icon: History }, { label: "Audit-Log", href: "/settings/audit-log", icon: History },
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook }, { label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
], ],
}; };
@@ -0,0 +1,16 @@
import { apiFetch } from "@/lib/auth/api-client";
import { AutomationRule, UpdateAutomationRuleInput } from "./types";
export function fetchAutomationRules(): Promise<AutomationRule[]> {
return apiFetch<AutomationRule[]>("/automation-rules");
}
export function updateAutomationRule(
id: string,
input: UpdateAutomationRuleInput,
): Promise<AutomationRule> {
return apiFetch<AutomationRule>(`/automation-rules/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
@@ -0,0 +1,20 @@
export type AutomationTriggerType =
| "QUEENLESS_NO_EGGS"
| "VARROA_THRESHOLD"
| "FEED_STATUS_CRITICAL";
export interface AutomationRule {
id: string;
triggerType: AutomationTriggerType;
active: boolean;
config: Record<string, unknown> | null;
actionTemplate: string | null;
createdAt: string;
updatedAt: string;
}
export interface UpdateAutomationRuleInput {
active?: boolean;
config?: Record<string, unknown>;
actionTemplate?: string;
}
@@ -32,6 +32,7 @@ export interface CreateInspectionInput {
temperature?: number; temperature?: number;
colonyStrength?: number; colonyStrength?: number;
queenSeen?: boolean; queenSeen?: boolean;
eggsPresent?: boolean;
queenCellsPresent?: boolean; queenCellsPresent?: boolean;
feedStatus?: string; feedStatus?: string;
healthIssues?: string; healthIssues?: string;