Files
ApiCore/ApiCore-Frontend/src/components/colony-select-field.tsx
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

237 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchColonies } from "@/lib/colonies/api";
import { colonyOptionLabel } from "@/lib/colonies/labels";
import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import {
OFFLINE_PACKS_QUERY_KEY,
listPacks,
livingColoniesFromPacks,
} from "@/lib/apiaries/offline-pack";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { Field } from "@/components/form/field";
import { RecordSelect } from "@/components/form/record-select";
const PLACEHOLDER = "Volk wählen";
const LOADING_PLACEHOLDER = "Völker werden geladen…";
const LOAD_ERROR_HINT = "Völker konnten nicht geladen werden – bitte die Seite neu laden.";
const OFFLINE_PACK_HINT = "Offline: nur Völker der geladenen Stände.";
const OFFLINE_NOT_LOADED_HINT = "Kein Netz. Lade den Stand vorher mit „Für offline laden“.";
/** What a colony choice needs - a server colony or one from an offline pack. */
type ColonyChoice = Pick<Colony, "id" | "code" | "status" | "apiaryId">;
function useColonies() {
return useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
}
type ChoicesHint = "load-error" | "offline-not-loaded" | "from-packs";
const HINT_TEXTS: Record<ChoicesHint, string> = {
"load-error": LOAD_ERROR_HINT,
"offline-not-loaded": OFFLINE_NOT_LOADED_HINT,
"from-packs": OFFLINE_PACK_HINT,
};
interface LivingColonyChoices {
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
hint: ChoicesHint | null;
}
/**
* Every colony from the shared ["colonies"] query - one request for every
* colony list on the page. Only when that failed for lack of network (never
* on a server refusal) the living colonies of the user's offline packs step
* in, from their own query: pack colonies are partial records and never go
* into the shared cache.
*/
function useLivingColonyChoices(): LivingColonyChoices {
const userId = useCurrentUserId();
const server = useColonies();
// failureReason: offline the retry pauses, so `error` stays unset.
const isOffline =
server.data === undefined && isNetworkError(server.error ?? server.failureReason);
const packs = useQuery({
queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"],
queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")),
enabled: isOffline && userId !== null,
// Reads this device's storage, never the network.
networkMode: "always",
});
if (!isOffline) {
return {
colonies: server.data,
isLoading: server.isLoading,
hint: server.isError && server.data === undefined ? "load-error" : null,
};
}
const packColonies = packs.data && packs.data.length > 0 ? packs.data : undefined;
let hint: ChoicesHint | null = "offline-not-loaded";
if (packColonies) {
hint = "from-packs";
} else if (packs.isLoading) {
hint = null;
}
return { colonies: packColonies, isLoading: packs.isLoading, hint };
}
interface ColonyChoicesProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
/** The colonies on offer; `undefined` while loading. */
options: readonly ColonyChoice[] | undefined;
/** Every known colony, so a chosen one is named even if it is not offered. */
colonies: readonly ColonyChoice[] | undefined;
isLoading: boolean;
dataField?: string;
describedBy?: string;
invalid?: boolean;
}
/** The colony select: a RecordSelect named by code and status ("V-03 (Weisellos)"). */
function ColonyChoices({ colonies, options, isLoading, ...props }: ColonyChoicesProps) {
return (
<RecordSelect
{...props}
items={options}
labelItems={colonies}
getLabel={colonyOptionLabel}
placeholder={isLoading ? LOADING_PLACEHOLDER : PLACEHOLDER}
/>
);
}
interface LivingColonySelectFieldProps {
/** Id of the select trigger, used to focus it on a validation error. */
triggerId: string;
value: string;
onChange: (colonyId: string) => void;
error?: string | null;
label?: string;
/** A colony that must not be offered, e.g. the one another is merged into. */
excludeId?: string;
/** Shown when there is nothing to choose from. */
emptyHint?: string;
/** `data-field` of the trigger; the capture forms focus "colonyId" on errors. */
dataField?: string;
}
/**
* Picks a colony that still lives in the apiary: the one a new record
* (inspection, varroa count, feeding, treatment, swarm control check) is filed
* under, the colony a queen moves into, the colony merged into another.
* Weak, conspicuous and queenless colonies are included - they need the most
* attention - and none that has ended. A colony handed in by a link that has
* ended since is still named, just not offered.
*
* Without network it offers the living colonies of the apiaries loaded for
* offline use ("Für offline laden") and says so.
*/
export function LivingColonySelectField({
triggerId,
value,
onChange,
error,
label = "Volk",
excludeId,
emptyHint = "Noch keine aktiven Völker – zuerst unter „Völker“ eines anlegen.",
dataField = "colonyId",
}: LivingColonySelectFieldProps) {
const { colonies, isLoading, hint: choicesHint } = useLivingColonyChoices();
const living = colonies?.filter(
(colony) => isLivingColony(colony) && colony.id !== excludeId,
);
function hint(): string | undefined {
if (choicesHint) {
return HINT_TEXTS[choicesHint];
}
return living?.length === 0 ? emptyHint : undefined;
}
return (
<Field
id={triggerId}
label={label}
required
error={error}
hint={hint()}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices
id={id}
value={value}
onChange={onChange}
options={living}
colonies={colonies}
isLoading={isLoading}
dataField={dataField}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
}
interface ColonySelectFieldProps {
id: string;
value: string;
onChange: (colonyId: string) => void;
label?: string;
error?: string | null;
dataField?: string;
}
/**
* Picks the colony a record belongs to. Every colony-scoped record
* (inspection, treatment, feeding, varroa measurement, swarm control check)
* can be re-filed under a different colony after the fact, because filing one
* against the wrong colony is a data-entry error rather than a fact of
* beekeeping - and the only alternative repair would be deleting the record.
*
* Ended colonies stay in the list: a record already filed against one has
* to remain visible and selectable, otherwise opening the form would silently
* re-file it somewhere else.
*/
export function ColonySelectField({
id: triggerId,
value,
onChange,
label = "Volk",
error,
dataField,
}: ColonySelectFieldProps) {
const { data: colonies, isLoading, isError } = useColonies();
return (
<Field
id={triggerId}
label={label}
required
error={error}
hint={isError ? LOAD_ERROR_HINT : undefined}
>
{({ id, describedBy, invalid }) => (
<ColonyChoices
id={id}
value={value}
onChange={onChange}
options={colonies}
colonies={colonies}
isLoading={isLoading}
dataField={dataField}
describedBy={describedBy}
invalid={invalid}
/>
)}
</Field>
);
}