Files
ApiCore/ApiCore-Frontend/src/components/suggestions/suggestion-card.tsx
T
Nick SzaunigandClaude Opus 5.5 02eb16924e 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]>
2026-10-14 15:22:36 +02:00

175 lines
6.3 KiB
TypeScript

"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>
);
}