Files
ApiCore/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx
T
Nick SzaunigandClaude Opus 5.5 c2f71bcb03
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s
fix: T4a browser check findings
- 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]>
2026-10-14 18:26:09 +02:00

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