feat: T6 wave 6 – treatment form with medicine and lot, notices and settings UI, restricted zone UI
- 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:
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user