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:
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;
|
||||||
|
|||||||
Reference in new issue
Block a user