feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
Backend Image / build-and-push (push) Successful in 6m52s
Frontend Image / build-and-push (push) Failing after 12m28s
Manifests / validate (push) Successful in 22s

- Treatment form and bulk entry: catalogue medicine or free text, lot with
  oldest preselected on create, lot fields from the lot, dose with at most
  3 decimals, catalogue prefill with explicit take-over of the withdrawal
  period on a medicine switch, ?editTreatment deep link.
- Authority notices: list with year filter, create with draft dedupe link,
  detail steps with PDF download and mark-sent, settings with MM-TT check,
  digit-rule editor and "Annahme – bitte prüfen" notes; COMPLIANCE label
  in notifications and Telegram categories.
- Restricted zone: page with active/planned/ended, create and end dialogs
  (endsOn inclusive), red band at the apiary, guards on move, merge, edit
  and new colony, override only with compliance:manage, harvest warning.
- ARCHITECTURE.md §12.11, Annahmen 91–94; TASKS.md T6 done with open items.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 17:33:55 +02:00
1 parent 13dda0e139
commit 8188e4cab3
90 files changed
+8069 -265

No files matched your search

@@ -48,6 +48,12 @@ export interface CreateColonyInput {
originText?: string | null;
occupiedFrames?: number | null;
notes?: string | null;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
@@ -65,11 +71,23 @@ export interface UpdateColonyInput {
healthStatus?: ColonyCondition | null;
notes?: string | null;
status?: ColonyStatus;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
export interface MoveColonyInput {
apiaryId: string;
notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
/** Where the split colony gets its queen from - an observation, never prefilled. */
@@ -88,6 +106,12 @@ export interface SplitColonyInput {
export interface MergeColonyInput {
mergedColonyIds: string[];
notes?: string;
/**
* Why the action may go ahead although an apiary lies in a restricted zone
* (10-500 characters, only with compliance:manage; the server answers 403
* to anyone else). Send it only when the exception is really used.
*/
restrictionOverrideReason?: string;
}
export interface ColonyHistory {
@@ -41,7 +41,7 @@ describe("compliance errors", () => {
});
it("builds on saveErrorText for the text line", () => {
expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new ApiError(409, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
});
@@ -70,3 +70,20 @@ describe("compliance errors", () => {
expect(readinessConflictText(new Error("x"))).toBeNull();
});
});
describe("complianceErrorText", () => {
it("shows German text for a 403 and for a server error, never Nest's English", () => {
expect(complianceErrorText(new ApiError(403, "Forbidden resource"), "x")).toBe("Dazu fehlt dir das Recht.");
expect(complianceErrorText(new ApiError(502, "Bad Gateway"), "x")).toBe(
"Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.",
);
});
it("joins a 400 array message and keeps a plain 409 text", () => {
const backend = ["tskReferenceDay muss ein Tag im Format MM-TT sein"];
expect(complianceErrorText(new ApiError(400, "x", { message: backend }), "f")).toBe(backend[0]);
expect(complianceErrorText(new ApiError(409, "Erst die Erstanzeige erfassen."), "f")).toBe(
"Erst die Erstanzeige erfassen.",
);
});
});
+24 -1
View File
@@ -91,7 +91,30 @@ export function readinessConflictText(error: unknown): string | null {
return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
}
/** One line for every other failure: the shared reader, no second error logic. */
const NO_RIGHT_TEXT = "Dazu fehlt dir das Recht.";
const SERVER_FAILED_TEXT = "Der Server hat einen Fehler gemeldet. Versuch es gleich noch einmal.";
/** Nest sends `message` as an array when several DTO checks fail; join the German parts. */
function arrayMessage(error: ApiError): string | null {
const message = (error.body as { message?: unknown } | null)?.message;
if (!Array.isArray(message)) return null;
const parts = message.filter((part): part is string => typeof part === "string" && part !== "");
return parts.length > 0 ? parts.join(" ") : null;
}
/**
* One line for every other failure: the shared reader, no second error logic.
* A 403 and a server error never show Nest's English text; a 400 with an array
* message shows its parts.
*/
export function complianceErrorText(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
if (error.status === 403) return NO_RIGHT_TEXT;
if (error.status >= 500) return SERVER_FAILED_TEXT;
if (error.status === 400) {
const joined = arrayMessage(error);
if (joined) return joined;
}
}
return saveErrorText(error, fallback);
}
@@ -51,15 +51,6 @@ describe("fixLinkFor", () => {
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1" }).available).toBe(false);
});
it.each([
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("hides %s until its target page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(false);
});
it.each([
["holder-data", null],
["medicine-lot-gaps", { type: "lot", id: "l1" }],
@@ -67,13 +58,22 @@ describe("fixLinkFor", () => {
["label-gaps", { type: "template", id: "x1" }],
["harvest-withdrawal", { type: "harvest", id: "h1" }],
["medicine-withdrawal-missing", null],
["registration", { type: "notice", id: "n1" }],
["restriction-active", { type: "apiary", id: "a1" }],
["vet-office", null],
["herd-change", null],
])("shows %s, its page exists", (itemKey, subject) => {
expect(fixLinkFor(itemKey, subject).available).toBe(true);
});
it("is switched on per route in one set", () => {
expect(ROUTE_AVAILABLE.has("/compliance/notices")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/restrictions")).toBe(false);
expect(ROUTE_AVAILABLE.has("/compliance/settings")).toBe(false);
for (const route of [
"/compliance/notices",
"/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
]) {
expect(ROUTE_AVAILABLE.has(route)).toBe(true);
}
});
});
@@ -2,11 +2,14 @@ const FIX_LABEL = "Beheben";
/**
* The pages that exist. A „Beheben“ link to any other route is hidden, so
* nobody lands on a 404. Tasks 18 and 19 add their routes here to switch the
* links on: `/compliance/notices`, `/compliance/notices/[id]`,
* `/compliance/restrictions` and `/compliance/settings`.
* nobody lands on a 404. A new page adds its route here to switch the links
* on.
*/
export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([
"/compliance/notices",
"/compliance/notices/[id]",
"/compliance/restrictions",
"/compliance/settings",
"/colonies/[id]",
"/harvests/[id]",
"/medicines",
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { isValidMonthDay } from "./month-day";
describe("isValidMonthDay", () => {
it.each(["01-01", "12-31", "02-28", "04-30"])("accepts %s", (value) => {
expect(isValidMonthDay(value)).toBe(true);
});
it.each(["02-29", "04-31", "13-01", "00-10", "1-1", "01-1", "2027-01-01", "", " 01-01"])("rejects %s", (value) => {
expect(isValidMonthDay(value)).toBe(false);
});
});
@@ -0,0 +1,13 @@
/** Days per month in a non-leap year, as the backend counts them (02-29 does not exist). */
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const MONTH_DAY = /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
export const MONTH_DAY_PROBLEM =
"Bitte einen Tag im Format MM-TT eingeben, zum Beispiel 03-31. Den 29. Februar gibt es hier nicht.";
/** MM-TT with a real month and a day that exists in a non-leap year (mirrors the backend). */
export function isValidMonthDay(value: string): boolean {
const match = MONTH_DAY.exec(value);
if (!match) return false;
return Number(match[2]) <= DAYS_IN_MONTH[Number(match[1]) - 1];
}
@@ -0,0 +1,56 @@
import type { AuthorityNotice, ComplianceSettings } from "./notice-types";
/** Test data for notices and settings; override only what a test is about. */
export function noticeFixture(overrides: Partial<AuthorityNotice> = {}): AuthorityNotice {
return {
id: "n1",
kind: "HERD_CHANGE",
status: "DRAFT",
year: null,
referenceDate: "2027-01-10T00:00:00.000Z",
countedAt: "2027-01-10T08:00:00.000Z",
snapshot: {
version: 1,
takenAt: "2027-01-10T08:00:00.000Z",
totalLiving: 7,
apiaries: [
{
apiaryId: "a1",
name: "Waldrand",
address: "Dorfstraße 1",
location: "12345 Musterdorf",
latitude: null,
longitude: null,
livingColonies: 7,
byType: { PRODUCTION_COLONY: 5, NUCLEUS_COLONY: 2 },
},
],
},
totalColonies: 7,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
createdAt: "2027-01-10T08:00:00.000Z",
...overrides,
};
}
export function settingsFixture(overrides: Partial<ComplianceSettings> = {}): ComplianceSettings {
return {
veterinaryOfficeName: "Veterinäramt Musterstadt",
veterinaryOfficeAddress: "Amtsweg 2, 12345 Musterstadt",
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: "01-01",
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 100, minMm: 1.5 },
{ maxGrams: null, minMm: 2.5 },
],
...overrides,
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { NOTICE_KIND_LABEL, NOTICE_STATUS_LABEL, noticeDay, noticeTitle } from "./notice-labels";
import { noticeFixture } from "./notice-fixtures";
describe("notice labels", () => {
it("names every kind and status in German", () => {
expect(NOTICE_KIND_LABEL).toEqual({
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
});
expect(NOTICE_STATUS_LABEL).toEqual({ DRAFT: "Entwurf", SENT: "Gesendet", CANCELLED: "Storniert" });
});
it("reads a day from an ISO date-time as written, without a time zone shift", () => {
expect(noticeDay("2027-01-10T00:00:00.000Z")).toBe("2027-01-10");
expect(noticeDay(null)).toBeNull();
});
it("titles a TSK count with its year", () => {
expect(noticeTitle(noticeFixture({ kind: "TSK_REPORT", year: 2027 }))).toBe("TSK-Zählung 2027");
expect(noticeTitle(noticeFixture({ kind: "REGISTRATION" }))).toBe("Erstanzeige");
});
});
@@ -0,0 +1,25 @@
import type { AuthorityNotice, AuthorityNoticeKind, AuthorityNoticeStatus } from "./notice-types";
export const NOTICE_KIND_LABEL: Record<AuthorityNoticeKind, string> = {
REGISTRATION: "Erstanzeige",
HERD_CHANGE: "Bestandsänderung",
TSK_REPORT: "TSK-Zählung",
};
export const NOTICE_KINDS: readonly AuthorityNoticeKind[] = ["REGISTRATION", "HERD_CHANGE", "TSK_REPORT"];
export const NOTICE_STATUS_LABEL: Record<AuthorityNoticeStatus, string> = {
DRAFT: "Entwurf",
SENT: "Gesendet",
CANCELLED: "Storniert",
};
/** The calendar day of an ISO date-time as written ("2027-01-10"); no time zone shift. */
export function noticeDay(value: string | null): string | null {
return value ? value.slice(0, 10) : null;
}
export function noticeTitle(notice: Pick<AuthorityNotice, "kind" | "year">): string {
const label = NOTICE_KIND_LABEL[notice.kind];
return notice.year === null ? label : `${label} ${notice.year}`;
}
@@ -0,0 +1,85 @@
import type { ColonyType } from "@/lib/colonies/types";
// Mirrors the backend (ApiCore-Backend/src/compliance: AuthorityNotice, HerdSnapshot, ComplianceSettings).
export type AuthorityNoticeKind = "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT";
export type AuthorityNoticeStatus = "DRAFT" | "SENT" | "CANCELLED";
export interface HerdApiarySnapshot {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
livingColonies: number;
byType: Partial<Record<ColonyType, number>>;
}
export interface HerdSnapshot {
version: number;
takenAt: string;
apiaries: HerdApiarySnapshot[];
totalLiving: number;
}
/** Days arrive as ISO date-times ("2027-01-01T00:00:00.000Z"); `noticeDay` reads the day. */
export interface AuthorityNotice {
id: string;
kind: AuthorityNoticeKind;
status: AuthorityNoticeStatus;
year: number | null;
referenceDate: string;
countedAt: string;
snapshot: HerdSnapshot;
totalColonies: number;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
createdAt: string;
}
export interface CreateNoticeInput {
kind: AuthorityNoticeKind;
year?: number;
referenceDate?: string;
sentOn?: string;
}
export type NoticeAction = "recount" | "mark-sent" | "reopen" | "cancel";
export interface MarkSentInput {
sentOn?: string;
sentVia?: string;
reference?: string;
}
export interface NoticeEdit {
totalColonies: number;
referenceDate: string;
sentOn: string | null;
sentVia: string | null;
reference: string | null;
notes: string | null;
}
export interface NetQuantityDigitRule {
/** Upper limit in grams; `null` = everything above. */
maxGrams: number | null;
minMm: number;
}
export interface ComplianceSettings {
veterinaryOfficeName: string | null;
veterinaryOfficeAddress: string | null;
registrationNumber: string | null;
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
/** MM-TT. */
tskReferenceDay: string;
tskReportDueDay: string | null;
tskMemberNumber: string | null;
tskExcludedColonyTypes: ColonyType[];
netQuantityDigitRules: NetQuantityDigitRule[];
}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import {
createNotice,
fetchNotice,
fetchNotices,
noticeAction,
openNoticePdf,
updateNotice,
} from "./notices-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
describe("notices api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("lists with the kind and year filter only when given", async () => {
await fetchNotices();
await fetchNotices({ kind: "TSK_REPORT", year: 2027 });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices?kind=TSK_REPORT&year=2027");
});
it("reads one notice by encoded id", async () => {
await fetchNotice("a/1");
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/a%2F1");
});
it("creates with the given idempotency key", async () => {
await createNotice({ kind: "REGISTRATION", sentOn: "2026-05-01" }, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/authority-notices");
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({ kind: "REGISTRATION", sentOn: "2026-05-01" }),
headers: { "Idempotency-Key": "key-1" },
});
});
it("posts an action; mark-sent carries date, channel and reference", async () => {
await noticeAction("n1", "recount");
await noticeAction("n1", "mark-sent", { sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" });
expect(apiFetch).toHaveBeenNthCalledWith(1, "/authority-notices/n1/recount", { method: "POST" });
expect(apiFetch).toHaveBeenNthCalledWith(2, "/authority-notices/n1/mark-sent", {
method: "POST",
body: JSON.stringify({ sentOn: "2027-01-15", sentVia: "E-Mail", reference: "AZ 1" }),
});
});
it("patches the edit", async () => {
await updateNotice("n1", { totalColonies: 9, notes: null });
expect(apiFetch).toHaveBeenCalledWith("/authority-notices/n1", {
method: "PATCH",
body: JSON.stringify({ totalColonies: 9, notes: null }),
});
});
it("downloads the pdf over the authenticated client, never through a link with a token", async () => {
const blob = new Blob(["x"]);
vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "anzeige-n1.pdf" });
await openNoticePdf("n1");
expect(apiFetchBlob).toHaveBeenCalledWith("/authority-notices/n1/pdf", "anzeige-n1.pdf");
expect(downloadBlob).toHaveBeenCalledWith(blob, "anzeige-n1.pdf");
});
});
@@ -0,0 +1,55 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { type CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
AuthorityNotice,
AuthorityNoticeKind,
CreateNoticeInput,
MarkSentInput,
NoticeAction,
NoticeEdit,
} from "./notice-types";
const NOTICES = "/authority-notices";
function noticePath(id: string, rest = ""): string {
return `${NOTICES}/${encodeURIComponent(id)}${rest}`;
}
export function fetchNotices(query: { kind?: AuthorityNoticeKind; year?: number } = {}): Promise<AuthorityNotice[]> {
const params = new URLSearchParams();
if (query.kind) params.set("kind", query.kind);
if (query.year !== undefined) params.set("year", String(query.year));
const text = params.toString();
return apiFetch<AuthorityNotice[]>(text ? `${NOTICES}?${text}` : NOTICES);
}
export function fetchNotice(id: string): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id));
}
/** 409 `DRAFT_EXISTS` carries `existingId`; read it with `draftExists` from errors.ts. */
export function createNotice(input: CreateNoticeInput, options?: CreateOptions): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(NOTICES, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
export function noticeAction(id: string, action: NoticeAction, body?: MarkSentInput): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id, `/${action}`), {
method: "POST",
...(body ? { body: JSON.stringify(body) } : {}),
});
}
export function updateNotice(id: string, input: Partial<NoticeEdit>): Promise<AuthorityNotice> {
return apiFetch<AuthorityNotice>(noticePath(id), { method: "PATCH", body: JSON.stringify(input) });
}
/** The notice as PDF: an authenticated download, never a link that carries a token. */
export async function openNoticePdf(id: string): Promise<void> {
const { blob, filename } = await apiFetchBlob(noticePath(id, "/pdf"), `anzeige-${id}.pdf`);
downloadBlob(blob, filename);
}
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
createRestrictions,
endRestriction,
fetchActiveRestrictions,
fetchRestrictions,
isRestrictionActive,
restrictionsAmong,
type ActiveRestriction,
} from "./restrictions-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
const active = (id: string, apiaryId: string): ActiveRestriction => ({
id,
apiaryId,
apiaryName: `Stand ${apiaryId}`,
disease: "Amerikanische Faulbrut",
startsOn: "2026-10-01",
});
describe("restrictions api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue([]);
});
it("reads the active restrictions and the full list", async () => {
await fetchActiveRestrictions();
await fetchRestrictions();
expect(apiFetch).toHaveBeenNthCalledWith(1, "/apiary-restrictions?active=true");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/apiary-restrictions");
});
it("creates restrictions for several apiaries with one idempotency key", async () => {
const input = { apiaryIds: ["a-1", "a-2"], disease: "Kleiner Beutenkäfer", startsOn: "2026-10-01" };
await createRestrictions(input, { idempotencyKey: "key-1" });
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/apiary-restrictions");
expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
expect((init?.headers as Record<string, string>)["Idempotency-Key"]).toBe("key-1");
});
it("ends a restriction by setting endsOn (PATCH, never DELETE)", async () => {
await endRestriction("r/1", "2026-11-30");
expect(apiFetch).toHaveBeenCalledWith("/apiary-restrictions/r%2F1", {
method: "PATCH",
body: JSON.stringify({ endsOn: "2026-11-30" }),
});
});
it("restrictionsAmong keeps only the restrictions of the named apiaries", () => {
const rows = [active("r-1", "a-1"), active("r-2", "a-2"), active("r-3", "a-1")];
expect(restrictionsAmong(["a-1"], rows).map((row) => row.id)).toEqual(["r-1", "r-3"]);
expect(restrictionsAmong([], rows)).toEqual([]);
});
it("treats the start and the end day as inside and an open end as until further notice", () => {
const row = { startsOn: "2026-10-01T00:00:00.000Z", endsOn: "2026-10-05T00:00:00.000Z" };
expect(isRestrictionActive(row, "2026-09-30")).toBe(false);
expect(isRestrictionActive(row, "2026-10-01")).toBe(true);
expect(isRestrictionActive(row, "2026-10-05")).toBe(true);
expect(isRestrictionActive(row, "2026-10-06")).toBe(false);
expect(isRestrictionActive({ ...row, endsOn: null }, "2030-01-01")).toBe(true);
});
});
@@ -0,0 +1,102 @@
import { apiFetch } from "@/lib/auth/api-client";
import { toCalendarDay } from "@/lib/dates";
import { idempotencyHeaders, type CreateOptions } from "@/lib/offline/idempotency";
/** What `GET /apiary-restrictions?active=true` returns: no authority, reference or notes. */
export interface ActiveRestriction {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
}
/**
* A restriction row. Authority, reference, notes and creator come only with
* `compliance:read`; a reader with `apiaries:read` alone gets the rest.
*/
export interface ApiaryRestriction {
id: string;
apiaryId: string;
disease: string;
authority?: string | null;
reference?: string | null;
/** Timestamp of the start day (midnight UTC); read with `toCalendarDay`. */
startsOn: string;
/** Last day of the restriction; `null` = until further notice. */
endsOn: string | null;
notes?: string | null;
}
export interface CreateRestrictionsInput {
apiaryIds: string[];
disease: string;
authority?: string;
reference?: string;
/** Calendar day "YYYY-MM-DD". */
startsOn: string;
endsOn?: string;
notes?: string;
}
export function fetchActiveRestrictions(): Promise<ActiveRestriction[]> {
return apiFetch<ActiveRestriction[]>("/apiary-restrictions?active=true");
}
/** Every restriction, active and ended (ended ones are kept, never deleted). */
export function fetchRestrictions(): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions");
}
/** One row per apiary; the idempotency key makes a retry after a lost answer safe. */
export function createRestrictions(
input: CreateRestrictionsInput,
options: CreateOptions = {},
): Promise<ApiaryRestriction[]> {
return apiFetch<ApiaryRestriction[]>("/apiary-restrictions", {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(input),
});
}
/** Ending sets the last day; the entry stays. */
export function endRestriction(id: string, endsOn: string): Promise<ApiaryRestriction> {
return apiFetch<ApiaryRestriction>(`/apiary-restrictions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ endsOn }),
});
}
/** The restrictions of the named apiaries, in the order they were given. */
export function restrictionsAmong(
apiaryIds: readonly string[],
active: readonly ActiveRestriction[],
): ActiveRestriction[] {
const wanted = new Set(apiaryIds);
return active.filter((restriction) => wanted.has(restriction.apiaryId));
}
export type RestrictionState = "planned" | "active" | "ended";
/** Not started yet ("Geplant"), in force, or over - by the Berlin calendar day. */
export function restrictionState(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): RestrictionState {
if (toCalendarDay(restriction.startsOn) > day) return "planned";
return isRestrictionActive(restriction, day) ? "active" : "ended";
}
/**
* Active from the start day to the end day, both included; no end day =
* until further notice. `day` is the Berlin calendar day, as on the server.
*/
export function isRestrictionActive(
restriction: Pick<ApiaryRestriction, "startsOn" | "endsOn">,
day: string,
): boolean {
if (toCalendarDay(restriction.startsOn) > day) return false;
return restriction.endsOn === null || day <= toCalendarDay(restriction.endsOn);
}
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import { fetchComplianceSettings, updateComplianceSettings } from "./settings-api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn() }));
describe("compliance settings api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads the settings", async () => {
await fetchComplianceSettings();
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings");
});
it("patches only what it is given, keeping explicit nulls", async () => {
await updateComplianceSettings({ tskReportDueDay: null, tskReferenceDay: "03-15" });
expect(apiFetch).toHaveBeenCalledWith("/compliance/settings", {
method: "PATCH",
body: JSON.stringify({ tskReportDueDay: null, tskReferenceDay: "03-15" }),
});
});
});
@@ -0,0 +1,12 @@
import { apiFetch } from "@/lib/auth/api-client";
import type { ComplianceSettings } from "./notice-types";
const SETTINGS = "/compliance/settings";
export function fetchComplianceSettings(): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS);
}
export function updateComplianceSettings(input: Partial<ComplianceSettings>): Promise<ComplianceSettings> {
return apiFetch<ComplianceSettings>(SETTINGS, { method: "PATCH", body: JSON.stringify(input) });
}
@@ -0,0 +1,27 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { complianceKey } from "./use-readiness";
import { fetchActiveRestrictions, type ActiveRestriction } from "./restrictions-api";
/** Under the `compliance` key, so one invalidation reloads every compliance query. */
export const activeRestrictionsKey = [...complianceKey, "restrictions", "active"] as const;
const ACTIVE_RESTRICTIONS_STALE_MS = 30_000;
const NONE: ActiveRestriction[] = [];
/**
* The active restrictions, loaded once per page (shared query key). The
* server decides in the end; this only hides a blocked action early.
*/
export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; isLoading: boolean } {
const query = useQuery({
queryKey: activeRestrictionsKey,
queryFn: fetchActiveRestrictions,
retry: false,
// Short-lived: a restriction may start any minute; the server still decides.
staleTime: ACTIVE_RESTRICTIONS_STALE_MS,
refetchOnWindowFocus: true,
});
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
}
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/auth/api-client";
import { errorMessage } from "@/lib/forms/error-message";
import { restrictedZone } from "./errors";
import { restrictionsAmong, type ActiveRestriction } from "./restrictions-api";
import { activeRestrictionsKey, useActiveRestrictions } from "./use-active-restrictions";
/** A failed save, worded for the place that shows it. */
export interface ExplainedError {
text: string;
/** A 409: the focus jumps to the message. */
focus: boolean;
/** A 409 "Bitte neu laden": offer to reload the colony. */
reload: boolean;
}
const ZONE_FALLBACK = "Der Stand liegt im Sperrbezirk.";
/**
* Puts the focus on the element (a message with tabIndex -1) when `active`
* turns true - after a 409 the beekeeper must hear what happened.
*/
export function useFocusWhen(active: boolean) {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (active) ref.current?.focus();
}, [active]);
return ref;
}
export interface RestrictionGuard {
/** Active restrictions of the apiaries the action touches. */
blocking: ActiveRestriction[];
isBlocked: boolean;
/** Changes with the set of blocking restrictions; use it as a React `key`. */
resetKey: string;
/** The confirmed override reason, only while something blocks. */
overrideReason: string | null;
/** The action may go ahead: nothing blocks, or the override was confirmed. */
canProceed: boolean;
/** Id of the notice, for `aria-describedby` of the switched-off button. */
noticeId: string;
setReason: (reason: string | null) => void;
reset: () => void;
/**
* The text of a failed save: the server wording (409 RESTRICTED_ZONE,
* "Der Stand wurde gerade geändert"), otherwise the fallback. A blocked
* zone the client did not know yet is loaded again.
*/
explain: (error: unknown, fallback: string) => ExplainedError;
/** Loads the active restrictions again (a dialog opens: the list may be old). */
refresh: () => void;
/** The request fragment: the reason only when the override is really used. */
overrideFields: () => { restrictionOverrideReason?: string };
}
/**
* Whether an action that brings a colony into, out of or between apiaries is
* blocked by a restricted zone - and the state of the override. The server
* decides in the end; this hides a blocked action early.
*/
export function useRestrictionGuard(apiaryIds: readonly string[]): RestrictionGuard {
const { restrictions } = useActiveRestrictions();
const noticeId = useId();
const queryClient = useQueryClient();
const [stored, setStored] = useState<{ key: string; reason: string } | null>(null);
const blocking = restrictionsAmong(apiaryIds, restrictions);
const resetKey = blocking.map((restriction) => restriction.id).join(",");
const isBlocked = blocking.length > 0;
// Nothing blocks any more: a reason confirmed earlier belongs to a notice that is gone.
if (!isBlocked && stored !== null) setStored(null);
const overrideReason = isBlocked && stored?.key === resetKey ? stored.reason : null;
return {
blocking,
isBlocked,
resetKey,
overrideReason,
canProceed: !isBlocked || overrideReason !== null,
noticeId,
setReason: (reason) => setStored(reason === null ? null : { key: resetKey, reason }),
reset: () => setStored(null),
explain: (error, fallback) => {
const zone = restrictedZone(error);
if (zone) void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
const conflict = error instanceof ApiError && error.status === 409;
const text = zone ? zone.message || ZONE_FALLBACK : errorMessage(error, fallback);
return { text, focus: conflict, reload: conflict && !zone && /neu laden/i.test(text) };
},
refresh: () => {
void queryClient.invalidateQueries({ queryKey: activeRestrictionsKey });
},
overrideFields: () => (overrideReason !== null ? { restrictionOverrideReason: overrideReason } : {}),
};
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS } from "./labels";
describe("notification category labels", () => {
it("names the compliance category and offers it in the category selection", () => {
expect(NOTIFICATION_CATEGORY_LABELS.COMPLIANCE).toBe("Behörden & Prüfung");
expect(NOTIFICATION_CATEGORIES).toContain("COMPLIANCE");
});
});
@@ -7,6 +7,7 @@ export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>
WITHDRAWAL: "Wartezeit",
SEASON: "Saison",
SYSTEM: "System",
COMPLIANCE: "Behörden & Prüfung",
};
export const NOTIFICATION_CATEGORIES = Object.keys(
@@ -6,7 +6,8 @@ export type NotificationCategory =
| "SUGGESTION"
| "WITHDRAWAL"
| "SEASON"
| "SYSTEM";
| "SYSTEM"
| "COMPLIANCE";
export interface Notification {
id: string;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { bookingLine } from "./booking-line";
describe("bookingLine", () => {
it("multiplies the dose by the number of colonies", () => {
expect(bookingLine(30, "ML", 6)).toBe("Bucht 30 ml je Volk ab (× 6 = 180 ml).");
});
it("leaves the multiplication out for one colony", () => {
expect(bookingLine(30, "ML", 1)).toBe("Bucht 30 ml je Volk ab.");
expect(bookingLine(30, "ML", 0)).toBe("Bucht 30 ml je Volk ab.");
});
it("writes German decimals and the unit of the medicine", () => {
expect(bookingLine(2.5, "G", 3)).toBe("Bucht 2,5 g je Volk ab (× 3 = 7,5 g).");
expect(bookingLine(1, "PIECE", 2)).toBe("Bucht 1 Stück je Volk ab (× 2 = 2 Stück).");
});
it("does not show float noise", () => {
expect(bookingLine(0.1, "ML", 3)).toBe("Bucht 0,1 ml je Volk ab (× 3 = 0,3 ml).");
});
it("is null without a pack or without a dose", () => {
expect(bookingLine(30, null, 6)).toBeNull();
expect(bookingLine(null, "ML", 6)).toBeNull();
expect(bookingLine(Number.NaN, "ML", 6)).toBeNull();
expect(bookingLine(0, "ML", 6)).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { formatQuantity } from "@/lib/medicines/format";
import type { MedicineUnit } from "@/lib/medicines/types";
/**
* The line above „Speichern“ that tells what the treatment takes out of the
* pack: "Bucht 30 ml je Volk ab (× 6 = 180 ml)." `unit` is `null` when no pack
* is chosen (nothing is booked then), `dose` is `null` while none is typed.
* With one colony the multiplication is left out.
*/
export function bookingLine(
dose: number | null,
unit: MedicineUnit | null,
colonyCount: number,
): string | null {
if (dose === null || unit === null || !Number.isFinite(dose) || dose <= 0) return null;
const perColony = `Bucht ${formatQuantity(dose, unit)} je Volk ab`;
if (colonyCount <= 1) return `${perColony}.`;
return `${perColony} (× ${colonyCount} = ${formatQuantity(dose * colonyCount, unit)}).`;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { doseError, doseText, parsedDose } from "./dose";
describe("doseError", () => {
it("allows an empty dose and valid ones with up to three decimals", () => {
expect(doseError("")).toBeNull();
expect(doseError("2,5")).toBeNull();
expect(doseError("0,001")).toBeNull();
});
it("rejects more than three decimal places in German", () => {
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
});
it("rejects zero and text", () => {
expect(doseError("0")).toBe("Die Dosis muss mindestens 0,001 sein.");
expect(doseError("abc")).toMatch(/Zahl/);
});
});
describe("parsedDose / doseText", () => {
it("round-trips a German decimal", () => {
expect(parsedDose(doseText(2.5))).toBe(2.5);
expect(doseText(null)).toBe("");
});
});
@@ -0,0 +1,29 @@
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
/** The smallest dose the server accepts. */
export const MIN_DOSE = 0.001;
const MAX_DECIMALS = 3;
const DECIMALS = /[.,](\d+)$/;
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
export function doseError(text: string): string | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5.");
const decimals = DECIMALS.exec(text.trim())?.[1].length ?? 0;
if (decimals > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein.";
return null;
}
/** The typed dose as a number, `null` when empty or not a valid number. */
export function parsedDose(text: string): number | null {
if (text.trim() === "") return null;
const dose = parseDecimal(text);
return Number.isFinite(dose) ? dose : null;
}
/** A stored dose as the user types it: "2.5" is „2,5“. */
export function doseText(dose: number | null): string {
return dose === null ? "" : String(dose).replace(".", ",");
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { medicinePatch, type MedicineGroupForm } from "./edit-patch";
import type { Treatment } from "./types";
const FREE_TEXT: Treatment = {
id: "t-1",
colonyId: "c-1",
userId: "u-1",
date: "2026-08-01T00:00:00.000Z",
startDate: null,
endDate: null,
product: "Oxuvar",
activeIngredient: null,
amount: null,
method: null,
reason: null,
batchNumber: "Ch-1",
notes: null,
withdrawalPeriodDays: null,
supplier: "Imkereibedarf",
purchaseProof: null,
prescribingVet: null,
medicineId: null,
medicineLotId: null,
doseQuantity: null,
createdAt: "2026-08-01T00:00:00.000Z",
};
const FROM_PACK: Treatment = {
...FREE_TEXT,
batchNumber: null,
supplier: null,
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 30,
};
function formOf(treatment: Treatment, overrides: Partial<MedicineGroupForm> = {}): MedicineGroupForm {
return {
product: treatment.product,
medicineId: treatment.medicineId ?? "",
medicineLotId: treatment.medicineLotId ?? "",
doseQuantity: treatment.doseQuantity === null ? "" : String(treatment.doseQuantity),
batchNumber: treatment.batchNumber ?? "",
supplier: treatment.supplier ?? "",
purchaseProof: treatment.purchaseProof ?? "",
prescribingVet: treatment.prescribingVet ?? "",
...overrides,
};
}
describe("medicinePatch", () => {
it("is empty while nothing of the group changed", () => {
expect(medicinePatch(FREE_TEXT, formOf(FREE_TEXT))).toEqual({});
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK))).toEqual({});
});
it("clears the lot-sourced texts with null once a pack is in play", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "2,5" }))).toEqual({
medicineId: "m-1",
medicineLotId: "l-1",
doseQuantity: 2.5,
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
it("sends the typed texts and no pack for a catalogue medicine without a pack", () => {
expect(
medicinePatch(FROM_PACK, formOf(FROM_PACK, { medicineLotId: "", supplier: "Apotheke" })),
).toEqual({
medicineId: "m-1",
medicineLotId: null,
doseQuantity: 30,
batchNumber: null,
supplier: "Apotheke",
purchaseProof: null,
prescribingVet: null,
});
});
it("sends an emptied dose as null", () => {
expect(medicinePatch(FROM_PACK, formOf(FROM_PACK, { doseQuantity: "" })).doseQuantity).toBeNull();
});
it("clears medicine, pack and dose that were stored when a free-text medicine is chosen", () => {
expect(
medicinePatch(
FROM_PACK,
formOf(FROM_PACK, { medicineId: "", medicineLotId: "", product: " Eigenes " }),
),
).toMatchObject({ product: "Eigenes", medicineId: null, medicineLotId: null, doseQuantity: null });
});
it("does not null what a free-text treatment never had", () => {
const patch = medicinePatch(FREE_TEXT, formOf(FREE_TEXT, { supplier: "" }));
expect(patch).toEqual({
product: "Oxuvar",
batchNumber: "Ch-1",
supplier: null,
purchaseProof: null,
prescribingVet: null,
});
});
});
@@ -0,0 +1,74 @@
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { parsedDose } from "./dose";
import type { Treatment, UpdateTreatmentInput } from "./types";
/** The part of the edit form that decides which medicine, pack and texts a treatment has. */
export interface MedicineGroupForm {
product: string;
medicineId: string;
medicineLotId: string;
doseQuantity: string;
batchNumber: string;
supplier: string;
purchaseProof: string;
prescribingVet: string;
}
function sameText(typed: string, stored: string | null): boolean {
return emptyToNull(typed) === stored;
}
function isUnchanged(treatment: Treatment, form: MedicineGroupForm): boolean {
return (
form.product.trim() === treatment.product &&
form.medicineId === (treatment.medicineId ?? "") &&
form.medicineLotId === (treatment.medicineLotId ?? "") &&
parsedDose(form.doseQuantity) === treatment.doseQuantity &&
sameText(form.batchNumber, treatment.batchNumber) &&
sameText(form.supplier, treatment.supplier) &&
sameText(form.purchaseProof, treatment.purchaseProof) &&
sameText(form.prescribingVet, treatment.prescribingVet)
);
}
/** What the pack supplies is cleared on the treatment (the server answers 400 for text next to a pack). */
function textFields(form: MedicineGroupForm) {
if (form.medicineLotId) {
return { batchNumber: null, supplier: null, purchaseProof: null, prescribingVet: null };
}
return {
batchNumber: emptyToNull(form.batchNumber),
supplier: emptyToNull(form.supplier),
purchaseProof: emptyToNull(form.purchaseProof),
prescribingVet: emptyToNull(form.prescribingVet),
};
}
/**
* The medicine part of an update. Left out completely while nothing of it
* changed - the server checks catalogue and pack only when the patch touches
* them, so a note on an old treatment is saved even if its pack was changed
* since. Once something changed the whole group goes out, with `null` for
* everything the user cleared (a stored value must stay removable).
*/
export function medicinePatch(
treatment: Treatment,
form: MedicineGroupForm,
): Partial<UpdateTreatmentInput> {
if (isUnchanged(treatment, form)) return {};
const dose = parsedDose(form.doseQuantity);
const medicine: Partial<UpdateTreatmentInput> = form.medicineId
? {
// The trade name follows the catalogue.
medicineId: form.medicineId,
medicineLotId: form.medicineLotId || null,
doseQuantity: dose,
}
: {
product: form.product.trim(),
...(treatment.medicineId ? { medicineId: null } : {}),
...(treatment.medicineLotId ? { medicineLotId: null } : {}),
...(treatment.medicineId && treatment.doseQuantity !== null ? { doseQuantity: null } : {}),
};
return { ...medicine, ...textFields(form) };
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { lot } from "@/lib/medicines/fixtures.spec-helper";
import { lotLabel, lotSourceLine, lotWarnings } from "./lot-text";
const ML_LOT = lot({
unit: "ML",
batchNumber: "4711",
remaining: 180,
expiresOn: "2027-03-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: null,
});
describe("lotLabel", () => {
it("shows batch, rest and expiry", () => {
expect(lotLabel(ML_LOT)).toBe("Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027");
});
it("says so when there is no batch number and leaves out a missing expiry", () => {
expect(lotLabel({ ...ML_LOT, batchNumber: null, expiresOn: null })).toBe(
"Ohne Ch.-B. · Rest 180 ml",
);
});
});
describe("lotSourceLine", () => {
it("lists only what the pack holds", () => {
expect(lotSourceLine(ML_LOT)).toBe(
"aus Packung: Ch.-B. 4711 · Lieferant Tierarzt Muster · Beleg Rechnung 4711",
);
});
it("still says where the data comes from when the pack holds nothing", () => {
expect(
lotSourceLine({ ...ML_LOT, batchNumber: null, supplier: null, purchaseProof: null }),
).toBe("aus Packung");
});
});
describe("lotWarnings", () => {
it("warns about an expired pack, judged on the treatment day", () => {
expect(lotWarnings(ML_LOT, 30, 1, "2027-03-31")).toEqual([]);
expect(lotWarnings(ML_LOT, 30, 1, "2027-04-01")).toEqual([
"Die Packung ist abgelaufen (verw. bis 03/2027).",
]);
});
it("warns when the rest does not cover all colonies", () => {
expect(lotWarnings(ML_LOT, 30, 7, "2026-08-01")).toEqual([
"Die Packung reicht nicht: Rest 180 ml, gebraucht 210 ml.",
]);
expect(lotWarnings(ML_LOT, 30, 6, "2026-08-01")).toEqual([]);
});
it("does not warn about float noise", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0.3 }, 0.1, 3, "2026-08-01")).toEqual([]);
});
it("warns about an empty pack when no dose is typed", () => {
expect(lotWarnings({ ...ML_LOT, remaining: 0 }, null, 1, "2026-08-01")).toEqual([
"Die Packung ist leer.",
]);
});
});
@@ -0,0 +1,59 @@
import { formatQuantity, roundToThousandths } from "@/lib/medicines/format";
import type { LotView } from "@/lib/medicines/types";
const SEPARATOR = " · ";
/** "2027-03-31" as "03/2027", read as written. */
function monthYear(day: string): string {
const [year, month] = day.split("-");
return `${month}/${year}`;
}
/** "Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027" */
export function lotLabel(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : "Ohne Ch.-B.",
`Rest ${formatQuantity(lot.remaining, lot.unit)}`,
];
if (lot.expiresOn) parts.push(`verw. bis ${monthYear(lot.expiresOn)}`);
return parts.join(SEPARATOR);
}
/** What the pack supplies instead of the typed fields, e.g. "aus Packung: Ch.-B. 4711 · Lieferant X". */
export function lotSourceLine(lot: LotView): string {
const parts = [
lot.batchNumber ? `Ch.-B. ${lot.batchNumber}` : null,
lot.supplier ? `Lieferant ${lot.supplier}` : null,
lot.purchaseProof ? `Beleg ${lot.purchaseProof}` : null,
lot.prescribingVet ? `Tierarzt ${lot.prescribingVet}` : null,
].filter((part): part is string => part !== null);
return parts.length > 0 ? `aus Packung: ${parts.join(SEPARATOR)}` : "aus Packung";
}
/**
* What speaks against taking from this pack: expired on the treatment day, or
* too little left for the dose of all colonies. Only warnings - the server
* books anyway and the readiness check reports a negative rest.
*/
export function lotWarnings(
lot: LotView,
dose: number | null,
colonyCount: number,
treatmentDay: string,
): string[] {
const warnings: string[] = [];
if (lot.expiresOn && treatmentDay && lot.expiresOn < treatmentDay) {
warnings.push(`Die Packung ist abgelaufen (verw. bis ${monthYear(lot.expiresOn)}).`);
}
if (dose !== null && Number.isFinite(dose) && dose > 0) {
const needed = roundToThousandths(dose * Math.max(colonyCount, 1));
if (needed > lot.remaining) {
warnings.push(
`Die Packung reicht nicht: Rest ${formatQuantity(lot.remaining, lot.unit)}, gebraucht ${formatQuantity(needed, lot.unit)}.`,
);
}
} else if (lot.remaining <= 0) {
warnings.push("Die Packung ist leer.");
}
return warnings;
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { temperatureHint } from "./temperature-hint";
const WINDOW = { min: 12, max: 25 };
describe("temperatureHint", () => {
it("names today and the window of the catalogue", () => {
expect(temperatureHint({ min: 14, max: 22 }, WINDOW)).toEqual({
text: "Heute 14–22 °C. Laut Katalog 12–25 °C.",
warn: false,
});
});
it("warns when today is colder or warmer than the window", () => {
expect(temperatureHint({ min: 9, max: 17 }, WINDOW)).toEqual({
text: "Heute 9–17 °C. Laut Katalog 12–25 °C.",
warn: true,
});
expect(temperatureHint({ min: 20, max: 31 }, WINDOW)?.warn).toBe(true);
});
it("counts the limits themselves as inside", () => {
expect(temperatureHint({ min: 12, max: 25 }, WINDOW)?.warn).toBe(false);
});
it("copes with a window that has only one limit", () => {
expect(temperatureHint({ min: 14, max: 22 }, { min: 12, max: null })).toEqual({
text: "Heute 14–22 °C. Laut Katalog ab 12 °C.",
warn: false,
});
expect(temperatureHint({ min: 14, max: 30 }, { min: null, max: 25 })).toEqual({
text: "Heute 14–30 °C. Laut Katalog bis 25 °C.",
warn: true,
});
});
it("writes German decimals", () => {
expect(temperatureHint({ min: 9.5, max: 17 }, WINDOW)?.text).toBe(
"Heute 9,5–17 °C. Laut Katalog 12–25 °C.",
);
});
it("is null when either side knows no temperature", () => {
expect(temperatureHint({ min: null, max: null }, WINDOW)).toBeNull();
expect(temperatureHint({ min: 9, max: 17 }, { min: null, max: null })).toBeNull();
});
});
@@ -0,0 +1,46 @@
export interface TemperatureRange {
min: number | null;
max: number | null;
}
export interface TemperatureHint {
text: string;
/** Today lies (partly) outside the window of the catalogue. */
warn: boolean;
}
const EN_DASH = "–";
function degrees(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
function rangeText({ min, max }: TemperatureRange): string | null {
if (min !== null && max !== null) return `${degrees(min)}${EN_DASH}${degrees(max)} °C`;
if (min !== null) return `ab ${degrees(min)} °C`;
if (max !== null) return `bis ${degrees(max)} °C`;
return null;
}
function isOutside(today: TemperatureRange, window: TemperatureRange): boolean {
const tooCold = window.min !== null && today.min !== null && today.min < window.min;
const tooWarm = window.max !== null && today.max !== null && today.max > window.max;
return tooCold || tooWarm;
}
/**
* "Heute 9–17 °C. Laut Katalog 12–25 °C." - nothing when either side has no
* temperature at all. The limits themselves are still inside the window.
*/
export function temperatureHint(
today: TemperatureRange,
window: TemperatureRange,
): TemperatureHint | null {
const todayText = rangeText(today);
const windowText = rangeText(window);
if (todayText === null || windowText === null) return null;
return {
text: `Heute ${todayText}. Laut Katalog ${windowText}.`,
warn: isOutside(today, window),
};
}
+16 -1
View File
@@ -16,6 +16,12 @@ export interface Treatment {
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
/** Catalogue entry (veterinary medicine) the treatment was taken from. */
medicineId: string | null;
/** Pack the dose was booked from; supplier, proof, batch and vet then come from it. */
medicineLotId: string | null;
/** Dose per colony in the unit of the medicine. */
doseQuantity: number | null;
createdAt: string;
}
@@ -25,7 +31,12 @@ export interface CreateTreatmentInput {
date: string;
startDate?: string | null;
endDate?: string | null;
product: string;
/** Left out when a catalogue medicine is chosen: the server takes its name. */
product?: string;
medicineId?: string | null;
/** With a pack, supplier / purchaseProof / batchNumber / prescribingVet must be left out (400 otherwise). */
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
@@ -46,6 +57,10 @@ export interface UpdateTreatmentInput {
startDate?: string | null;
endDate?: string | null;
product?: string;
/** `null` clears; with a pack set, `medicineId` must stay set. */
medicineId?: string | null;
medicineLotId?: string | null;
doseQuantity?: number | null;
activeIngredient?: string | null;
amount?: string | null;
method?: string | null;
@@ -0,0 +1,97 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import type { MedicineView } from "@/lib/medicines/types";
import { doseText } from "./dose";
/** The form fields a catalogue medicine can fill in (as typed text). */
export const CATALOGUE_FIELDS = [
"activeIngredient",
"withdrawalPeriodDays",
"method",
"doseQuantity",
] as const;
export type CatalogueField = (typeof CATALOGUE_FIELDS)[number];
/** Fields filled from the catalogue and not touched since: they carry the note „aus Katalog“. */
export type CatalogueFilled = Partial<Record<CatalogueField, true>>;
type CatalogueValues = Record<CatalogueField, string>;
function isCatalogueField(key: string): key is CatalogueField {
return (CATALOGUE_FIELDS as readonly string[]).includes(key);
}
/** What the catalogue knows of the medicine; unknown values (null) are left out. */
export function catalogueValues(medicine: MedicineView): Partial<CatalogueValues> {
const values: Partial<CatalogueValues> = {};
if (medicine.activeIngredient !== null) values.activeIngredient = medicine.activeIngredient;
if (medicine.withdrawalPeriodDays !== null) {
values.withdrawalPeriodDays = String(medicine.withdrawalPeriodDays);
}
if (medicine.applicationMethod !== null) values.method = medicine.applicationMethod;
if (medicine.dosePerColony !== null) values.doseQuantity = doseText(medicine.dosePerColony);
return values;
}
interface CataloguePrefill {
filled: CatalogueFilled;
/**
* Fills the fields the user has not touched from the medicine (`null` =
* „Anderes Mittel“: only takes back what an earlier medicine filled in).
* `markEdited` tells the last-treatment prefill to leave these fields alone,
* so the catalogue wins.
*/
apply: (medicine: MedicineView | null) => void;
/** Call for every edit: the field belongs to the user and is never refilled. */
edited: (key: string) => void;
/** After a draft replaced the values: whatever it holds belongs to the user. */
resetFrom: (values: CatalogueValues) => void;
}
/** Prefills a treatment form from the chosen catalogue medicine. */
export function useCataloguePrefill<T extends CatalogueValues>(
setValues: Dispatch<SetStateAction<T>>,
markEdited: (key: string) => void,
): CataloguePrefill {
const [filled, setFilled] = useState<CatalogueFilled>({});
const [touched, setTouched] = useState<readonly CatalogueField[]>([]);
return {
filled,
apply: (medicine) => {
const next = medicine ? catalogueValues(medicine) : {};
const nextFilled: CatalogueFilled = {};
setValues((current) => {
const updated = { ...current };
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field)) continue;
if (filled[field]) updated[field] = "" as T[CatalogueField];
const value = next[field];
if (value !== undefined) updated[field] = value as T[CatalogueField];
}
return updated;
});
for (const field of CATALOGUE_FIELDS) {
if (touched.includes(field) || next[field] === undefined) continue;
nextFilled[field] = true;
markEdited(field);
}
setFilled(nextFilled);
},
edited: (key) => {
if (!isCatalogueField(key)) return;
setTouched((current) => (current.includes(key) ? current : [...current, key]));
setFilled((current) => {
const rest = { ...current };
delete rest[key];
return rest;
});
},
resetFrom: (values) => {
setFilled({});
setTouched(CATALOGUE_FIELDS.filter((field) => values[field].trim() !== ""));
},
};
}
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchMedicines, fetchSelectableLots } from "@/lib/medicines/api";
import type { LotView, MedicineView } from "@/lib/medicines/types";
interface QueryStatus {
isLoading: boolean;
isError: boolean;
isRetrying: boolean;
retry: () => void;
}
export interface MedicineChoice {
/** The user may read the catalogue; without the right nothing is asked for. */
canRead: boolean;
/** The medicines on offer: the active ones, plus the one a treatment already has. */
medicines: readonly MedicineView[] | undefined;
/** Every known medicine, so a chosen one is always named. */
allMedicines: readonly MedicineView[] | undefined;
medicinesStatus: QueryStatus;
medicine: MedicineView | undefined;
/** The selectable lots of the chosen medicine, oldest expiry first. */
lots: readonly LotView[] | undefined;
lotsStatus: QueryStatus;
lot: LotView | undefined;
/** The lot the form chose by itself and nobody has changed since. */
preselectedLotId: string | null;
/** The user made the lot choice (including „Ohne Packung“): never choose again. */
decideLot: () => void;
/** A new medicine was chosen: the oldest lot may be preselected again. */
undecideLot: () => void;
}
interface Options {
canRead: boolean;
medicineId: string;
medicineLotId: string;
/** The pack of a treatment that is being edited; stays on the list when empty. */
includeLotId?: string;
/** The medicine of a treatment that is being edited; offered even when switched off. */
keepMedicineId?: string;
/** A form that edits an existing treatment never chooses a pack by itself. */
startDecided?: boolean;
onPreselect: (lotId: string) => void;
}
function status(query: {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
refetch: () => unknown;
}): QueryStatus {
return {
isLoading: query.isLoading,
isError: query.isError,
isRetrying: query.isFetching,
retry: () => void query.refetch(),
};
}
/**
* The catalogue medicines and the packs of the chosen one for a treatment
* form. A missing right is no error: nothing is asked and only „Anderes
* Mittel“ is offered. The oldest pack is chosen once per medicine until the
* user decides themselves.
*/
export function useMedicineChoice({
canRead,
medicineId,
medicineLotId,
includeLotId,
keepMedicineId,
startDecided = false,
onPreselect,
}: Options): MedicineChoice {
const medicinesQuery = useQuery({
queryKey: ["medicines"],
queryFn: () => fetchMedicines(),
enabled: canRead,
});
const lotsQuery = useQuery({
queryKey: ["medicine-lots", "selectable", medicineId, includeLotId ?? null],
queryFn: () => fetchSelectableLots(medicineId, includeLotId),
enabled: canRead && medicineId !== "",
});
const [decided, setDecided] = useState(startDecided);
const [preselectedLotId, setPreselectedLotId] = useState<string | null>(null);
const [handled, setHandled] = useState<string | null>(null);
const lots = medicineId ? lotsQuery.data : undefined;
const oldestLotId = lots?.[0]?.id;
const preselectKey = oldestLotId ? `${medicineId}:${oldestLotId}` : null;
// Chosen while rendering (like the prefill hooks): the form never shows a
// frame with a medicine and a pack that is still to be picked.
if (!decided && !medicineLotId && oldestLotId && preselectKey !== handled) {
setHandled(preselectKey);
setPreselectedLotId(oldestLotId);
onPreselect(oldestLotId);
}
const allMedicines = canRead ? medicinesQuery.data : undefined;
const medicines = allMedicines?.filter(
(candidate) => candidate.active || candidate.id === keepMedicineId,
);
return {
canRead,
medicines,
allMedicines,
medicinesStatus: status(medicinesQuery),
medicine: allMedicines?.find((candidate) => candidate.id === medicineId),
lots,
lotsStatus: status(lotsQuery),
lot: lots?.find((candidate) => candidate.id === medicineLotId),
preselectedLotId: medicineLotId && medicineLotId === preselectedLotId ? preselectedLotId : null,
decideLot: () => setDecided(true),
undecideLot: () => {
setDecided(false);
setHandled(null);
setPreselectedLotId(null);
},
};
}