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]>
237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|