- Withdrawal wording from one backend helper everywhere ("bis
einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
crosses New Year); withdrawal-status returns lastBlockedDay and the
ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
colony codes; collapsible automation rule cards; varroa list newest
first with chart axes; text fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useId, useState } from "react";
|
|
import Link from "next/link";
|
|
import { Check, ChevronDown, Play } from "lucide-react";
|
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
|
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 { isOffline, hintId, describedBy } = useOnlineOnly();
|
|
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 || isOffline}
|
|
aria-describedby={describedBy}
|
|
onClick={() => handlers.onAcceptAll(suggestions.map((suggestion) => suggestion.id))}
|
|
>
|
|
<Check aria-hidden />
|
|
Alle übernehmen
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
{canAcceptAll && !round ? <OnlineOnlyHint id={hintId} /> : null}
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 self-start"
|
|
aria-expanded={isOpen}
|
|
aria-controls={listId}
|
|
onClick={() => setIsOpen((open) => !open)}
|
|
>
|
|
<ChevronDown
|
|
aria-hidden
|
|
className={cn("motion-safe: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>
|
|
);
|
|
}
|