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,152 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, ChevronDown, Play } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { INTENT_ICONS, INTENT_LABELS } from "@/lib/suggestions/labels";
|
||||
import { roundTarget, type SuggestionGroupModel } from "@/lib/suggestions/grouping";
|
||||
import type { Suggestion, SuggestionIntent } from "@/lib/suggestions/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SuggestionCard } from "./suggestion-card";
|
||||
|
||||
export interface SuggestionGroupProps {
|
||||
group: SuggestionGroupModel;
|
||||
/** Suggestions with a running decision; only their cards wait. */
|
||||
busyIds?: ReadonlySet<string>;
|
||||
canAccept?: boolean;
|
||||
canDismiss?: boolean;
|
||||
/** False without a signed-in user: „Runde starten“ then only opens the first inspection. */
|
||||
canStartRound?: boolean;
|
||||
onAccept: (suggestion: Suggestion, dueDate?: string) => void;
|
||||
onDismiss: (suggestion: Suggestion) => void;
|
||||
onAcceptAll: (ids: string[]) => void;
|
||||
/** Called when „Runde starten“ is tapped; the link then opens the first inspection. */
|
||||
onStartRound: (suggestions: Suggestion[]) => void;
|
||||
onCleanup?: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
interface CombinedRowProps extends Omit<SuggestionGroupProps, "group"> {
|
||||
intent: SuggestionIntent;
|
||||
suggestions: Suggestion[];
|
||||
}
|
||||
|
||||
/** „n Völker“ when the rows belong to colonies, else „n Vorschläge“. */
|
||||
function countLabel(suggestions: readonly Suggestion[]): string {
|
||||
const colonies = new Set(
|
||||
suggestions.flatMap((suggestion) => (suggestion.colonyId ? [suggestion.colonyId] : [])),
|
||||
);
|
||||
if (colonies.size === 0) return `${suggestions.length} Vorschläge`;
|
||||
return colonies.size === 1 ? "1 Volk" : `${colonies.size} Völker`;
|
||||
}
|
||||
|
||||
function CombinedRow({
|
||||
intent,
|
||||
suggestions,
|
||||
busyIds,
|
||||
canAccept = true,
|
||||
canDismiss = true,
|
||||
canStartRound = true,
|
||||
...handlers
|
||||
}: CombinedRowProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const listId = useId();
|
||||
const Icon = INTENT_ICONS[intent];
|
||||
const round = intent === "INSPECT" ? roundTarget(suggestions) : null;
|
||||
const canAcceptAll =
|
||||
canAccept && suggestions.every((suggestion) => suggestion.mode === "PLAN");
|
||||
const isBusy = suggestions.some((suggestion) => busyIds?.has(suggestion.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex flex-wrap items-center 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>
|
||||
<h3 className="min-w-0 flex-1 font-medium">
|
||||
{INTENT_LABELS[intent]} · {countLabel(suggestions)}
|
||||
</h3>
|
||||
{round ? (
|
||||
<Link
|
||||
href={canStartRound ? round.href : round.plainHref}
|
||||
onClick={() => {
|
||||
if (canStartRound) handlers.onStartRound(suggestions);
|
||||
}}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
<Play aria-hidden />
|
||||
Runde starten
|
||||
</Link>
|
||||
) : canAcceptAll ? (
|
||||
<Button
|
||||
size="touch"
|
||||
disabled={isBusy}
|
||||
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Alle übernehmen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-11 self-start"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listId}
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("transition-transform", isOpen && "rotate-180")}
|
||||
/>
|
||||
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
|
||||
</Button>
|
||||
<div id={listId} hidden={!isOpen} className="flex flex-col gap-3">
|
||||
{isOpen
|
||||
? suggestions.map((suggestion) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={busyIds?.has(suggestion.id)}
|
||||
canAccept={canAccept}
|
||||
canDismiss={canDismiss}
|
||||
onAccept={handlers.onAccept}
|
||||
onDismiss={handlers.onDismiss}
|
||||
onCleanup={handlers.onCleanup}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** All open suggestions of one stand (or the farm), same intents folded into one row. */
|
||||
export function SuggestionGroup({ group, ...handlers }: SuggestionGroupProps) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="font-heading text-lg font-semibold">{group.label}</h2>
|
||||
{group.rows.map((row) =>
|
||||
row.kind === "single" ? (
|
||||
<SuggestionCard
|
||||
key={row.suggestion.id}
|
||||
suggestion={row.suggestion}
|
||||
busy={handlers.busyIds?.has(row.suggestion.id)}
|
||||
canAccept={handlers.canAccept}
|
||||
canDismiss={handlers.canDismiss}
|
||||
onAccept={handlers.onAccept}
|
||||
onDismiss={handlers.onDismiss}
|
||||
onCleanup={handlers.onCleanup}
|
||||
/>
|
||||
) : (
|
||||
<CombinedRow
|
||||
key={`${group.key}:${row.intent}`}
|
||||
intent={row.intent}
|
||||
suggestions={row.suggestions}
|
||||
{...handlers}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user