feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Check, EllipsisVertical, X } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DueChip } from "@/components/task-status";
|
||||
import { captureHref, INTENT_ICONS } from "@/lib/suggestions/labels";
|
||||
import type { Suggestion } from "@/lib/suggestions/types";
|
||||
import { TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { dueState } from "@/lib/tasks/today";
|
||||
import { todayLocalIsoDate } from "@/lib/dates";
|
||||
import { DueDateSheet } from "./due-date-sheet";
|
||||
|
||||
export interface SuggestionCardProps {
|
||||
suggestion: Suggestion;
|
||||
/** A decision for this very suggestion is running. */
|
||||
busy?: boolean;
|
||||
/** tasks:create - needed for „Übernehmen“ and every way to plan a task. */
|
||||
canAccept?: boolean;
|
||||
/** tasks:update - needed for „Verwerfen“. */
|
||||
canDismiss?: boolean;
|
||||
/** `dueDate` is undefined for the suggestion's own day, else YYYY-MM-DD. */
|
||||
onAccept: (suggestion: Suggestion, dueDate?: string) => void;
|
||||
onDismiss: (suggestion: Suggestion) => void;
|
||||
/** „Aufräumen“ for a CLEANUP suggestion; without a handler the button is not offered. */
|
||||
onCleanup?: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
type MenuAction = { label: string; sheetTitle: string; confirmLabel: string };
|
||||
|
||||
function menuActionOf(suggestion: Suggestion): MenuAction | null {
|
||||
if (suggestion.mode === "PLAN") {
|
||||
return { label: "Termin ändern", sheetTitle: "Termin ändern", confirmLabel: "Übernehmen" };
|
||||
}
|
||||
if (suggestion.intent === "CLEANUP") return null;
|
||||
return { label: "Als Aufgabe planen", sheetTitle: "Als Aufgabe planen", confirmLabel: "Aufgabe anlegen" };
|
||||
}
|
||||
|
||||
function MainAction({ suggestion, busy, canAccept = true, onAccept, onCleanup }: SuggestionCardProps) {
|
||||
if (suggestion.intent === "CLEANUP") {
|
||||
if (!onCleanup) return null;
|
||||
return (
|
||||
<Button size="touch" disabled={busy} onClick={() => onCleanup(suggestion)}>
|
||||
<Check aria-hidden />
|
||||
Aufräumen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (suggestion.mode === "DO") {
|
||||
return (
|
||||
<Link
|
||||
href={captureHref(suggestion.intent, suggestion.colonyId)}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erfassen
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (!canAccept) return null;
|
||||
return (
|
||||
<Button size="touch" disabled={busy} onClick={() => onAccept(suggestion, undefined)}>
|
||||
<Check aria-hidden />
|
||||
Übernehmen
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SuggestionCard(props: SuggestionCardProps) {
|
||||
const { suggestion, busy, canAccept = true, canDismiss = true, onAccept, onDismiss } = props;
|
||||
const [now] = useState(() => new Date());
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const Icon = INTENT_ICONS[suggestion.intent];
|
||||
const menuAction = canAccept ? menuActionOf(suggestion) : null;
|
||||
const isUrgent = suggestion.priority === "HIGH" || suggestion.priority === "URGENT";
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3
|
||||
tabIndex={-1}
|
||||
data-suggestion-heading={suggestion.id}
|
||||
className="font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
{suggestion.title}
|
||||
</h3>
|
||||
{suggestion.colonyCode ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
|
||||
{suggestion.colonyCode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{suggestion.reasons.length > 0 ? (
|
||||
<ul className="flex flex-col gap-0.5 text-sm text-muted-foreground">
|
||||
{suggestion.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DueChip state={dueState(suggestion, now)} dueDate={suggestion.dueDate} />
|
||||
{isUrgent ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-warning/20 px-2 py-0.5 text-xs font-medium">
|
||||
<AlertTriangle className="size-3.5" aria-hidden />
|
||||
{TASK_PRIORITY_LABELS[suggestion.priority]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MainAction {...props} />
|
||||
{canDismiss ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => onDismiss(suggestion)}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Verwerfen
|
||||
</Button>
|
||||
) : null}
|
||||
{menuAction ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-11"
|
||||
disabled={busy}
|
||||
aria-label="Weitere Aktionen"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EllipsisVertical className="size-5" aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem className="min-h-11" onClick={() => setIsSheetOpen(true)}>
|
||||
{menuAction.label}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
{menuAction ? (
|
||||
<DueDateSheet
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
title={menuAction.sheetTitle}
|
||||
description={suggestion.title}
|
||||
confirmLabel={menuAction.confirmLabel}
|
||||
initialDate={suggestion.dueDate ?? todayLocalIsoDate()}
|
||||
onConfirm={(dueDate) => {
|
||||
setIsSheetOpen(false);
|
||||
onAccept(suggestion, dueDate);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user