feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -0,0 +1,145 @@
"use client";
import { Layers } from "lucide-react";
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
import { FEED_CONDITION_LABELS, ratingToCondition } from "@/lib/colonies/condition";
import { COLONY_CONDITION_LABELS } from "@/lib/colonies/labels";
import type { Colony, ColonyCondition } from "@/lib/colonies/types";
import { COLONY_TAKEOVER_FIELDS, type ColonyTakeoverField } from "@/lib/inspections/types";
export interface ColonyTakeoverSectionProps {
/** What the colony card holds now; null when it is not known (nothing loaded yet). */
colony: Pick<Colony, "occupiedFrames" | "broodStatus" | "feedStatus"> | null;
/** What this inspection recorded. */
values: Pick<InspectionFormValues, "colonyStrength" | "broodPattern" | "feedStatus">;
selected: ColonyTakeoverField[];
onChange: (fields: ColonyTakeoverField[]) => void;
/** Without colonies:update the section is not offered at all. */
canUpdateColony: boolean;
}
interface TakeoverRow {
field: ColonyTakeoverField;
label: string;
}
const NOT_RECORDED = "noch nicht erfasst";
const UNKNOWN = "bisher unbekannt";
function conditionText(
condition: ColonyCondition | null | undefined,
labels: Record<ColonyCondition, string>,
): string {
if (condition === undefined) return UNKNOWN;
return condition === null ? NOT_RECORDED : labels[condition];
}
function rowText(name: string, before: string, after: string): string {
return before === after ? `${name}: ${after} (unverändert)` : `${name}: ${before} → ${after}`;
}
/** One row per colony-card value this inspection has an answer for. */
function takeoverRows({ colony, values }: Pick<ColonyTakeoverSectionProps, "colony" | "values">): TakeoverRow[] {
const rows: TakeoverRow[] = [];
for (const field of COLONY_TAKEOVER_FIELDS) {
if (field === "occupiedFrames" && values.colonyStrength.trim() !== "") {
const before =
colony === null
? UNKNOWN
: colony.occupiedFrames === null
? NOT_RECORDED
: String(colony.occupiedFrames);
rows.push({
field,
label: rowText("Besetzte Waben", before, String(Number(values.colonyStrength))),
});
}
const brood = ratingToCondition(values.broodPattern);
if (field === "broodStatus" && brood !== null) {
rows.push({
field,
label: rowText(
"Brut",
conditionText(colony?.broodStatus, COLONY_CONDITION_LABELS),
COLONY_CONDITION_LABELS[brood],
),
});
}
if (field === "feedStatus" && values.feedStatus !== null) {
rows.push({
field,
label: rowText(
"Futter",
conditionText(colony?.feedStatus, FEED_CONDITION_LABELS),
FEED_CONDITION_LABELS[values.feedStatus],
),
});
}
}
return rows;
}
function countText(count: number): string {
if (count === 0) return "Es wird nichts auf die Volkskarte übernommen.";
if (count === 1) return "1 Wert wird auf die Volkskarte übernommen.";
return `${count} Werte werden auf die Volkskarte übernommen.`;
}
/**
* „Volkskarte“ above the save button: which of today's answers also go onto
* the colony card, each as „bisher → neu“ with a box. Ticked are exactly the
* fields filled in this inspection; nothing is taken over that was not asked.
*/
export function ColonyTakeoverSection({
colony,
values,
selected,
onChange,
canUpdateColony,
}: ColonyTakeoverSectionProps) {
if (!canUpdateColony) {
return null;
}
const rows = takeoverRows({ colony, values });
function toggle(field: ColonyTakeoverField, checked: boolean) {
onChange(
COLONY_TAKEOVER_FIELDS.filter((candidate) =>
candidate === field ? checked : selected.includes(candidate),
),
);
}
const takenCount = rows.filter((row) => selected.includes(row.field)).length;
return (
<div className="flex flex-col gap-2">
{rows.length > 0 ? (
<fieldset className="flex flex-col gap-2 rounded-xl bg-muted/50 p-3">
<legend className="flex items-center gap-2 px-1 text-sm font-semibold">
<Layers className="size-4" aria-hidden />
Volkskarte
</legend>
<p className="text-sm text-muted-foreground">
Diese Angaben auch auf der Volkskarte eintragen:
</p>
{rows.map((row) => (
<label key={row.field} className="flex min-h-11 items-center gap-3 text-sm">
<input
type="checkbox"
className="size-5 shrink-0 accent-primary"
checked={selected.includes(row.field)}
onChange={(event) => toggle(row.field, event.target.checked)}
/>
{row.label}
</label>
))}
</fieldset>
) : null}
{/* Mounted even while empty, or its first message is not announced. */}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground empty:hidden">
{rows.length > 0 ? countText(takenCount) : ""}
</p>
</div>
);
}