- 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]>
175 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|