feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
Backend Image / build-and-push (push) Successful in 6m45s
Frontend Image / build-and-push (push) Failing after 9m30s

- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
  harvest withdrawal), medicine subjects masked without
  veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
  409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
  nav; veterinary medicine catalogue, lots, movement book with inventory
  and disposal; label designer check panel, print card with gap list and
  proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
  on every answer); compliance, medicine, restriction, notice and label
  check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.

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 15:59:08 +02:00
1 parent b912d29166
commit b4746a2b17
149 files changed
+11869 -320

No files matched your search

@@ -94,6 +94,13 @@ describe("api-client", () => {
});
});
it("apiFetchBlob passes the request options on, e.g. cache: no-store", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("pdf"));
vi.stubGlobal("fetch", fetchMock);
await apiFetchBlob("/x", "f.pdf", { cache: "no-store" });
expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe("no-store");
});
it("apiFetchBlob builds an ApiError with the parsed body", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(403, { message: "Nein", why: 1 })));
const error = (await apiFetchBlob("/x", "f.csv").catch((e: unknown) => e)) as ApiError;
+7 -3
View File
@@ -58,13 +58,17 @@ function filenameFromContentDisposition(header: string | null, fallback: string)
return match?.[1] ?? fallback;
}
export async function apiFetchBlob(path: string, fallbackFilename: string): Promise<BlobDownload> {
let response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
export async function apiFetchBlob(
path: string,
fallbackFilename: string,
init: RequestInit = {},
): Promise<BlobDownload> {
let response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
response = await rawFetch(path, init, DOWNLOAD_TIMEOUT_MS);
}
}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import {
acknowledge,
downloadReadinessReport,
fetchReadiness,
fetchReadinessSummary,
revokeAcknowledgement,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(), apiFetchBlob: vi.fn() }));
vi.mock("@/lib/export/api", () => ({ downloadBlob: vi.fn() }));
describe("compliance api", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(undefined);
});
it("reads the report and the summary", async () => {
await fetchReadiness();
await fetchReadinessSummary();
expect(apiFetch).toHaveBeenNthCalledWith(1, "/compliance/readiness");
expect(apiFetch).toHaveBeenNthCalledWith(2, "/compliance/readiness/summary");
});
it("acknowledges with one idempotency key per call", async () => {
const input = {
itemKey: "k",
subjectId: "global",
fingerprint: "f".repeat(64),
note: "Mit dem Amt abgesprochen.",
};
await acknowledge(input);
const [path, init] = vi.mocked(apiFetch).mock.calls[0];
expect(path).toBe("/compliance/acknowledgements");
expect(init).toMatchObject({ method: "POST", body: JSON.stringify(input) });
expect((init?.headers as Record<string, string>)["Idempotency-Key"]).toMatch(/^[A-Za-z0-9_-]{16,128}$/);
});
it("revokes by id", async () => {
await revokeAcknowledgement("a/1");
expect(apiFetch).toHaveBeenCalledWith("/compliance/acknowledgements/a%2F1/revoke", { method: "POST" });
});
it("downloads the report as pruefbericht.pdf", async () => {
const blob = new Blob(["x"]);
vi.mocked(apiFetchBlob).mockResolvedValue({ blob, filename: "pruefbericht.pdf" });
await downloadReadinessReport();
expect(apiFetchBlob).toHaveBeenCalledWith("/compliance/readiness/report.pdf", "pruefbericht.pdf");
expect(downloadBlob).toHaveBeenCalledWith(blob, "pruefbericht.pdf");
});
});
@@ -0,0 +1,32 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { idempotencyHeaders, newIdempotencyKey } from "@/lib/offline/idempotency";
import type { AcknowledgeInput, ReadinessReport, ReadinessSummary } from "./types";
export function fetchReadiness(): Promise<ReadinessReport> {
return apiFetch<ReadinessReport>("/compliance/readiness");
}
export function fetchReadinessSummary(): Promise<ReadinessSummary> {
return apiFetch<ReadinessSummary>("/compliance/readiness/summary");
}
export function acknowledge(input: AcknowledgeInput): Promise<void> {
return apiFetch<void>("/compliance/acknowledgements", {
method: "POST",
headers: idempotencyHeaders({ idempotencyKey: newIdempotencyKey() }),
body: JSON.stringify(input),
});
}
export function revokeAcknowledgement(id: string): Promise<void> {
return apiFetch<void>(`/compliance/acknowledgements/${encodeURIComponent(id)}/revoke`, {
method: "POST",
});
}
/** The Prüfbericht as PDF (authenticated download, never cached). */
export async function downloadReadinessReport(): Promise<void> {
const { blob, filename } = await apiFetchBlob("/compliance/readiness/report.pdf", "pruefbericht.pdf");
downloadBlob(blob, filename);
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { complianceErrorText, draftExists, readinessConflictText, restrictedZone, staleFingerprint } from "./errors";
const conflict = (body: unknown) => new ApiError(409, "x", body);
describe("compliance errors", () => {
it("reads DRAFT_EXISTS with existingId, RESTRICTED_ZONE with its restrictions and a stale fingerprint", () => {
expect(
draftExists(conflict({ code: "DRAFT_EXISTS", existingId: "n1", message: "Es gibt schon einen Entwurf." })),
).toEqual({ existingId: "n1", message: "Es gibt schon einen Entwurf." });
const restriction = { id: "r1", apiaryId: "a1", apiaryName: "Wiese", disease: "AFB", startsOn: "2026-10-01" };
expect(
restrictedZone(
conflict({ code: "RESTRICTED_ZONE", message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] }),
),
).toEqual({ message: "Sperrbezirk.", restrictionIds: ["r1"], restrictions: [restriction] });
expect(staleFingerprint(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
"Der Punkt hat sich geändert. Bitte neu laden.",
);
});
it("ignores other errors", () => {
expect(draftExists(conflict({ code: "RESTRICTED_ZONE" }))).toBeNull();
expect(draftExists(new ApiError(400, "x", { code: "DRAFT_EXISTS", existingId: "n1" }))).toBeNull();
expect(draftExists(conflict({ code: "DRAFT_EXISTS", existingId: 5 }))).toBeNull();
expect(restrictedZone(conflict({ code: "DRAFT_EXISTS" }))).toBeNull();
expect(restrictedZone(new Error("boom"))).toBeNull();
expect(staleFingerprint(conflict({ message: "Die Quittung ist schon zurückgenommen." }))).toBeNull();
expect(staleFingerprint(null)).toBeNull();
});
it("drops malformed restrictions instead of throwing", () => {
expect(
restrictedZone(
conflict({ code: "RESTRICTED_ZONE", message: "m", restrictionIds: ["r1", 3], restrictions: [{ id: 1 }, null] }),
),
).toEqual({ message: "m", restrictionIds: ["r1"], restrictions: [] });
});
it("builds on saveErrorText for the text line", () => {
expect(complianceErrorText(new ApiError(500, "Serverfehler"), "Fallback")).toBe("Serverfehler");
expect(complianceErrorText(new Error("x"), "Fallback")).toBe("Fallback");
});
it("gives each conflict code its own German text", () => {
const texts = ["READINESS_FINGERPRINT_STALE", "READINESS_ITEM_NOT_OPEN", "READINESS_ACK_REVOKED"].map((code) =>
readinessConflictText(conflict({ code, message: "English or whatever" })),
);
expect(texts.every((text) => typeof text === "string" && text.length > 0)).toBe(true);
expect(new Set(texts).size).toBe(3);
expect(texts[0]).toContain("geändert");
expect(texts[1]).toContain("nicht mehr offen");
expect(texts[2]).toContain("zurückgenommen");
});
it("prefers the code over the message and falls back to the message text", () => {
expect(
readinessConflictText(
conflict({ code: "READINESS_ITEM_NOT_OPEN", message: "Der Punkt hat sich geändert. Bitte neu laden." }),
),
).toContain("nicht mehr offen");
expect(readinessConflictText(conflict({ message: "Der Punkt hat sich geändert. Bitte neu laden." }))).toBe(
"Der Punkt hat sich geändert. Bitte neu laden.",
);
expect(readinessConflictText(conflict({ code: "SOMETHING_ELSE", message: "x" }))).toBeNull();
expect(readinessConflictText(new ApiError(500, "x", { code: "READINESS_ACK_REVOKED" }))).toBeNull();
expect(readinessConflictText(new Error("x"))).toBeNull();
});
});
@@ -0,0 +1,97 @@
import { ApiError } from "@/lib/auth/api-client";
import { saveErrorText } from "@/lib/honey-batches/errors";
/** The server's wording for an acknowledgement whose item changed meanwhile. */
const STALE_FINGERPRINT_MESSAGE = "Der Punkt hat sich geändert. Bitte neu laden.";
export interface DraftExists {
existingId: string;
message: string;
}
export interface RestrictionInfo {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
startsOn: string;
}
export interface RestrictedZone {
message: string;
restrictionIds: string[];
restrictions: RestrictionInfo[];
}
function conflictBody(error: unknown): Record<string, unknown> | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
return typeof error.body === "object" && error.body !== null
? (error.body as Record<string, unknown>)
: null;
}
function textOf(value: unknown): string {
return typeof value === "string" ? value : "";
}
function isRestrictionInfo(value: unknown): value is RestrictionInfo {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return ["id", "apiaryId", "apiaryName", "disease", "startsOn"].every(
(key) => typeof row[key] === "string",
);
}
/** A 409 `DRAFT_EXISTS`: the id of the draft that is already open. */
export function draftExists(error: unknown): DraftExists | null {
const body = conflictBody(error);
if (body?.code !== "DRAFT_EXISTS" || typeof body.existingId !== "string") return null;
return { existingId: body.existingId, message: textOf(body.message) };
}
/** A 409 `RESTRICTED_ZONE`: the restrictions that block the move or the change. */
export function restrictedZone(error: unknown): RestrictedZone | null {
const body = conflictBody(error);
if (body?.code !== "RESTRICTED_ZONE") return null;
const ids = Array.isArray(body.restrictionIds) ? body.restrictionIds : [];
const rows = Array.isArray(body.restrictions) ? body.restrictions : [];
return {
message: textOf(body.message),
restrictionIds: ids.filter((id): id is string => typeof id === "string"),
restrictions: rows.filter(isRestrictionInfo),
};
}
/** Codes of a 409 on acknowledging or revoking, each with its own German text. */
const READINESS_CONFLICT_TEXT: Readonly<Record<string, string>> = {
READINESS_FINGERPRINT_STALE:
"Der Punkt hat sich inzwischen geändert. Die Liste wird neu geladen, bitte prüf ihn noch einmal.",
READINESS_ITEM_NOT_OPEN: "Dieser Punkt ist nicht mehr offen. Die Liste wird neu geladen.",
READINESS_ACK_REVOKED: "Diese Quittung ist schon zurückgenommen. Die Liste wird neu geladen.",
};
/**
* A 409 on an acknowledgement whose item changed since the page was loaded
* (stale fingerprint): the server text. Fallback for a server that sends no
* code; `readinessConflictText` prefers the code.
*/
export function staleFingerprint(error: unknown): string | null {
const body = conflictBody(error);
return body && textOf(body.message) === STALE_FINGERPRINT_MESSAGE ? STALE_FINGERPRINT_MESSAGE : null;
}
/**
* The German text for a 409 of the acknowledgement endpoints: by `body.code`
* (stale fingerprint, item not open, already revoked); the message match is
* only the fallback. `null` for every other error.
*/
export function readinessConflictText(error: unknown): string | null {
const body = conflictBody(error);
const code = typeof body?.code === "string" ? body.code : "";
return READINESS_CONFLICT_TEXT[code] ?? staleFingerprint(error);
}
/** One line for every other failure: the shared reader, no second error logic. */
export function complianceErrorText(error: unknown, fallback: string): string {
return saveErrorText(error, fallback);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { ROUTE_AVAILABLE, fixLinkFor } from "./fix-links";
const STAMMDATEN = "/settings/automation?tab=social-media";
describe("fixLinkFor", () => {
it.each([
["medicine-lot-gaps", { type: "lot", id: "l1" }, "/medicines/lots/l1"],
["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }, "/colonies/c1"],
["label-gaps", { type: "template", id: "x1" }, "/settings/label-templates/x1"],
["harvest-withdrawal", { type: "harvest", id: "h1" }, "/harvests/h1"],
["registration", { type: "notice", id: "n1" }, "/compliance/notices/n1"],
["registration", { type: "AUTHORITY_NOTICE", id: "n2" }, "/compliance/notices/n2"],
["restriction-active", { type: "apiary", id: "a1" }, "/compliance/restrictions"],
["restriction-active", { type: "APIARY", id: "a1" }, "/compliance/restrictions"],
["restriction-active", { type: "restriction", id: "r1" }, "/compliance/restrictions"],
["herd-change", { type: "APIARY", id: "a1" }, "/compliance/notices"],
["vet-office", { type: "COMPLIANCE_SETTINGS", id: "global" }, "/compliance/settings"],
])("maps %s with %j to %s", (itemKey, subject, href) => {
expect(fixLinkFor(itemKey, subject).href).toBe(href);
});
it.each([
["holder-data", STAMMDATEN],
["food-business-data", STAMMDATEN],
["vet-office", "/compliance/settings"],
["registration", "/compliance/notices"],
["herd-change", "/compliance/notices"],
["tsk-report", "/compliance/notices"],
["restriction-active", "/compliance/restrictions"],
["medicine-withdrawal-missing", "/medicines"],
])("maps the global item %s to %s", (itemKey, href) => {
expect(fixLinkFor(itemKey, null).href).toBe(href);
});
it("treats the site settings subject like a global item", () => {
expect(fixLinkFor("holder-data", { type: "SITE_SETTINGS", id: "global" }).href).toBe(STAMMDATEN);
});
it("encodes ids", () => {
expect(fixLinkFor("x", { type: "lot", id: "a/b" }).href).toBe("/medicines/lots/a%2Fb");
});
it("falls back to the compliance page and always says Beheben", () => {
expect(fixLinkFor("unknown-rule", null)).toEqual({ href: "/compliance", label: "Beheben", available: false });
expect(fixLinkFor("holder-data", null).label).toBe("Beheben");
});
it("links to the colony page for a treatment, no parameter nothing reads", () => {
expect(fixLinkFor("treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }).href).toBe("/colonies/c1");
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" }],
["treatment-log-gaps", { type: "treatment", id: "t1", parentId: "c1" }],
["label-gaps", { type: "template", id: "x1" }],
["harvest-withdrawal", { type: "harvest", id: "h1" }],
["medicine-withdrawal-missing", 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);
});
});
@@ -0,0 +1,96 @@
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`.
*/
export const ROUTE_AVAILABLE: ReadonlySet<string> = new Set([
"/colonies/[id]",
"/harvests/[id]",
"/medicines",
"/medicines/lots/[id]",
"/settings/automation",
"/settings/label-templates/[id]",
]);
/** The tab "Stammdaten & Web" under Verwaltung > Automatisierung. */
const MASTER_DATA = "/settings/automation?tab=social-media";
interface Target {
/** The route pattern, as a key of ROUTE_AVAILABLE. */
route: string;
href: string;
}
/** A rule without a record of its own, or the fallback when nothing else fits. */
const TARGET_BY_ITEM_KEY: Readonly<Record<string, Target>> = {
"holder-data": { route: "/settings/automation", href: MASTER_DATA },
"food-business-data": { route: "/settings/automation", href: MASTER_DATA },
"vet-office": { route: "/compliance/settings", href: "/compliance/settings" },
registration: { route: "/compliance/notices", href: "/compliance/notices" },
"herd-change": { route: "/compliance/notices", href: "/compliance/notices" },
"tsk-report": { route: "/compliance/notices", href: "/compliance/notices" },
"restriction-active": { route: "/compliance/restrictions", href: "/compliance/restrictions" },
"medicine-withdrawal-missing": { route: "/medicines", href: "/medicines" },
};
/** This page itself: never a fix target. */
const COMPLIANCE_HOME: Target = { route: "/compliance", href: "/compliance" };
export interface FixSubject {
type: string;
id: string;
parentId?: string;
}
export interface FixLink {
href: string;
label: string;
/** False while the target page does not exist yet: show no link. */
available: boolean;
}
function targetForSubject(itemKey: string, subject: FixSubject): Target | null {
const id = encodeURIComponent(subject.id);
switch (subject.type.toLowerCase()) {
case "lot":
return { route: "/medicines/lots/[id]", href: `/medicines/lots/${id}` };
case "treatment":
// The treatments are listed (and edited) on the colony page.
return subject.parentId
? { route: "/colonies/[id]", href: `/colonies/${encodeURIComponent(subject.parentId)}` }
: null;
case "template":
return { route: "/settings/label-templates/[id]", href: `/settings/label-templates/${id}` };
case "harvest":
return { route: "/harvests/[id]", href: `/harvests/${id}` };
case "notice":
case "authority_notice":
return { route: "/compliance/notices/[id]", href: `/compliance/notices/${id}` };
case "apiary":
// A herd change is settled by a notice; every other apiary item is a restriction.
return itemKey === "herd-change"
? TARGET_BY_ITEM_KEY["herd-change"]
: TARGET_BY_ITEM_KEY["restriction-active"];
case "restriction":
return TARGET_BY_ITEM_KEY["restriction-active"];
case "compliance_settings":
return TARGET_BY_ITEM_KEY["vet-office"];
default:
return null;
}
}
/**
* The ONE place that maps a readiness subject to the page that fixes it.
* Without a subject of its own (or for the settings subjects) the rule decides.
*/
export function fixLinkFor(itemKey: string, subject: FixSubject | null): FixLink {
const target =
(subject ? targetForSubject(itemKey, subject) : null) ??
TARGET_BY_ITEM_KEY[itemKey] ??
COMPLIANCE_HOME;
return { href: target.href, label: FIX_LABEL, available: ROUTE_AVAILABLE.has(target.route) };
}
@@ -0,0 +1,35 @@
import type { ReadinessItem, ReadinessReport } from "./types";
/** Test data for the readiness report; override only what a test is about. */
export function itemFixture(overrides: Partial<ReadinessItem> = {}): ReadinessItem {
return {
key: "holder-data",
area: "STAMMDATEN",
severity: "PFLICHT",
subjectId: "global",
title: "Angaben zum Bienenhalter fehlen",
detail: "Es fehlen: Name, Anschrift.",
legalBasis: null,
acknowledgeable: false,
fingerprint: "f".repeat(64),
subjects: [],
more: 0,
state: "OPEN",
acknowledgement: null,
...overrides,
};
}
export function reportFixture(
items: ReadinessItem[],
overrides: Partial<ReadinessReport> = {},
): ReadinessReport {
const openCount = items.filter((item) => item.state === "OPEN").length;
return {
status: openCount > 0 ? "OPEN" : "READY",
generatedAt: "2026-10-15T08:00:00.000Z",
openCount,
items,
...overrides,
};
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { AREA_LABEL, AREA_ORDER, SEVERITY_LABEL, STATE_LABEL, itemTone } from "./labels";
describe("compliance labels", () => {
it("maps the severity to its German word", () => {
expect(SEVERITY_LABEL).toEqual({ PFLICHT: "Pflicht", PRUEFEN: "Prüfen" });
});
it("names the five areas in a fixed order", () => {
expect(AREA_ORDER).toEqual(["STAMMDATEN", "BEHOERDEN", "TIERARZNEIMITTEL", "ETIKETTEN", "ERNTE"]);
expect(AREA_ORDER.every((area) => AREA_LABEL[area].length > 0)).toBe(true);
});
it("shows every open item red with symbol and word, whatever its severity", () => {
expect(itemTone("OPEN")).toEqual({ tone: "destructive", word: "Offen" });
expect(STATE_LABEL).toEqual({ OPEN: "Offen", DONE: "Erledigt", ACKNOWLEDGED: "Quittiert" });
expect(itemTone("DONE").tone).not.toBe("destructive");
expect(itemTone("ACKNOWLEDGED").tone).not.toBe("destructive");
});
});
@@ -0,0 +1,44 @@
import type { ComponentProps } from "react";
import type { Badge } from "@/components/ui/badge";
import type { ReadinessArea, ReadinessItemState, ReadinessSeverity } from "./types";
/** The severity is always a word next to the title, never only a colour. */
export const SEVERITY_LABEL: Record<ReadinessSeverity, string> = {
PFLICHT: "Pflicht",
PRUEFEN: "Prüfen",
};
export const AREA_ORDER: readonly ReadinessArea[] = [
"STAMMDATEN",
"BEHOERDEN",
"TIERARZNEIMITTEL",
"ETIKETTEN",
"ERNTE",
];
export const AREA_LABEL: Record<ReadinessArea, string> = {
STAMMDATEN: "Stammdaten",
BEHOERDEN: "Behörden",
TIERARZNEIMITTEL: "Tierarzneimittel",
ETIKETTEN: "Etiketten",
ERNTE: "Ernte",
};
export const STATE_LABEL: Record<ReadinessItemState, string> = {
OPEN: "Offen",
DONE: "Erledigt",
ACKNOWLEDGED: "Quittiert",
};
export interface ItemTone {
tone: NonNullable<ComponentProps<typeof Badge>["variant"]>;
word: string;
}
/** Every open item is red (token destructive), whatever its severity. */
export function itemTone(state: ReadinessItemState): ItemTone {
const word = STATE_LABEL[state];
if (state === "OPEN") return { tone: "destructive", word };
if (state === "ACKNOWLEDGED") return { tone: "outline", word };
return { tone: "secondary", word };
}
@@ -0,0 +1,63 @@
// Mirrors the backend's readiness report (ApiCore-Backend/src/compliance/readiness/readiness.types.ts).
export type ReadinessSeverity = "PFLICHT" | "PRUEFEN";
export type ReadinessArea =
| "STAMMDATEN"
| "BEHOERDEN"
| "TIERARZNEIMITTEL"
| "ETIKETTEN"
| "ERNTE";
export type ReadinessItemState = "OPEN" | "DONE" | "ACKNOWLEDGED";
export type ReadinessStatus = "READY" | "OPEN";
export interface ReadinessSubject {
type: string;
id: string;
label: string;
missing?: string[];
/** e.g. the colony of a treatment (link target). */
parentId?: string;
}
export interface ReadinessAcknowledgement {
id: string;
note: string;
userName: string;
createdAt: string;
}
export interface ReadinessItem {
key: string;
area: ReadinessArea;
severity: ReadinessSeverity;
subjectId: string;
title: string;
detail: string;
legalBasis: string | null;
acknowledgeable: boolean;
fingerprint: string;
subjects: ReadinessSubject[];
more: number;
state: ReadinessItemState;
acknowledgement: ReadinessAcknowledgement | null;
}
export interface ReadinessReport {
status: ReadinessStatus;
generatedAt: string;
openCount: number;
items: ReadinessItem[];
}
export interface ReadinessSummary {
status: ReadinessStatus;
openCount: number;
firstItemKey: string | null;
}
export interface AcknowledgeInput {
itemKey: string;
subjectId: string;
fingerprint: string;
note: string;
}
@@ -0,0 +1,22 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchReadiness, fetchReadinessSummary } from "./api";
/** Every compliance query starts with this key, so one invalidation reloads all. */
export const complianceKey = ["compliance"] as const;
export const readinessKey = [...complianceKey, "readiness"] as const;
export const readinessSummaryKey = [...complianceKey, "readiness", "summary"] as const;
export function useReadiness() {
return useQuery({ queryKey: readinessKey, queryFn: fetchReadiness });
}
export function useReadinessSummary(enabled: boolean) {
return useQuery({
queryKey: readinessSummaryKey,
queryFn: fetchReadinessSummary,
enabled,
retry: false,
});
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { DOCUMENT_ENTITY_TYPES } from "./types";
describe("DOCUMENT_ENTITY_TYPES", () => {
it("knows the three compliance kinds besides the existing ones", () => {
expect(DOCUMENT_ENTITY_TYPES).toEqual([
"APIARY",
"COLONY",
"QUEEN",
"INSPECTION",
"HONEY_BATCH",
"MEDICINE_LOT",
"AUTHORITY_NOTICE",
"APIARY_RESTRICTION",
]);
});
});
+12 -1
View File
@@ -1,4 +1,15 @@
export type DocumentEntityType = "APIARY" | "COLONY" | "QUEEN" | "INSPECTION" | "HONEY_BATCH";
export const DOCUMENT_ENTITY_TYPES = [
"APIARY",
"COLONY",
"QUEEN",
"INSPECTION",
"HONEY_BATCH",
"MEDICINE_LOT",
"AUTHORITY_NOTICE",
"APIARY_RESTRICTION",
] as const;
export type DocumentEntityType = (typeof DOCUMENT_ENTITY_TYPES)[number];
export interface DocumentSummary {
id: string;
@@ -1,5 +1,4 @@
import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { CreateLabelTemplateInput, LabelTemplate, UpdateLabelTemplateInput } from "./types";
export function fetchLabelTemplates(): Promise<LabelTemplate[]> {
@@ -27,15 +26,32 @@ export function deactivateLabelTemplate(id: string): Promise<LabelTemplate> {
return apiFetch<LabelTemplate>(`/label-templates/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export interface PrintLabelsOptions {
/** 1 = top left of the sheet; omitted prints from the start. */
startPosition?: number;
/** A test print stamped „MUSTER – nicht verkaufsfähig“; always allowed. */
proof?: boolean;
}
/**
* The label PDF. A 409 `LABEL_PARTICULARS_MISSING` throws an `ApiError` whose
* `body.gaps` lists what is missing (`labelParticularsMissing` reads it).
*/
export async function printLabels(
templateId: string,
bottlingBatchId: string,
startPosition?: number,
): Promise<void> {
const query = startPosition && startPosition > 1 ? `?startPosition=${startPosition}` : "";
const { blob, filename } = await apiFetchBlob(
options: PrintLabelsOptions = {},
): Promise<Blob> {
const params = new URLSearchParams();
if (options.startPosition && options.startPosition > 1) {
params.set("startPosition", String(options.startPosition));
}
if (options.proof) params.set("proof", "true");
const query = params.size > 0 ? `?${params.toString()}` : "";
const { blob } = await apiFetchBlob(
`/label-templates/${encodeURIComponent(templateId)}/print/${encodeURIComponent(bottlingBatchId)}${query}`,
"etiketten.pdf",
{ cache: "no-store" },
);
downloadBlob(blob, filename);
return blob;
}
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { apiFetch, ApiError } from "@/lib/auth/api-client";
import {
checkDraftTemplate,
checkTemplateForBottling,
labelCheckErrorText,
labelParticularsMissing,
useDebouncedLabelCheck,
type LabelCheckResult,
} from "./check";
import type { LabelElement } from "./types";
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
const RESULT: LabelCheckResult = {
printable: true,
gaps: [],
warnings: [],
sample: true,
requirements: { minXHeightMm: 1.2, netQuantityMinCapHeightMm: null },
};
const TEXT: LabelElement = {
id: "t1",
type: "text",
x: 1,
y: 2,
width: 40,
height: 8,
text: "Honig",
placeholderKey: null,
fontSize: 8,
fontFamily: "sans",
bold: false,
italic: false,
color: "#000000",
align: "left",
};
const QR: LabelElement = { id: "q1", type: "qrCode", x: 0, y: 0, size: 10 };
const LINE: LabelElement = {
id: "l1",
type: "line",
x: 0,
y: 0,
length: 10,
thickness: 1,
color: "#000000",
orientation: "horizontal",
};
const DRAFT = { elements: [TEXT, QR, LINE], widthMm: 90, heightMm: 35, minXHeightMm: 1.2 };
function sentBody(): { elements: { id: string }[]; [key: string]: unknown } {
const init = vi.mocked(apiFetch).mock.calls.at(-1)![1] as RequestInit;
return JSON.parse(String(init.body));
}
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(RESULT);
});
describe("checkDraftTemplate", () => {
it("posts the text elements only", async () => {
await checkDraftTemplate(DRAFT);
expect(apiFetch).toHaveBeenCalledWith(
"/label-templates/check",
expect.objectContaining({ method: "POST" }),
);
const body = sentBody();
expect(body.elements.map((element) => element.id)).toEqual(["t1"]);
expect(body).toMatchObject({ widthMm: 90, heightMm: 35, minXHeightMm: 1.2 });
expect(body).not.toHaveProperty("bottlingBatchId");
});
it("sends the bottling batch when one is given", async () => {
await checkDraftTemplate({ ...DRAFT, bottlingBatchId: "b-1" });
expect(sentBody()).toMatchObject({ bottlingBatchId: "b-1" });
});
});
describe("checkTemplateForBottling", () => {
it("reads the check of a saved template for one bottling", async () => {
await checkTemplateForBottling("tpl 1", "b-1");
expect(apiFetch).toHaveBeenCalledWith("/label-templates/tpl%201/check/b-1", {
cache: "no-store",
});
});
});
describe("labelParticularsMissing", () => {
const gaps = [{ code: "ORIGIN", title: "Herkunftsland fehlt", detail: "d", elementIds: [] }];
it("reads the gaps of a 409 LABEL_PARTICULARS_MISSING", () => {
const error = new ApiError(409, "Pflichtangaben fehlen.", {
code: "LABEL_PARTICULARS_MISSING",
message: "Pflichtangaben fehlen.",
gaps,
});
expect(labelParticularsMissing(error)).toEqual({ message: "Pflichtangaben fehlen.", gaps });
});
it("ignores other errors and malformed bodies", () => {
expect(labelParticularsMissing(new Error("x"))).toBeNull();
expect(labelParticularsMissing(new ApiError(409, "x", { code: "OTHER" }))).toBeNull();
expect(
labelParticularsMissing(new ApiError(500, "x", { code: "LABEL_PARTICULARS_MISSING", gaps })),
).toBeNull();
expect(
labelParticularsMissing(
new ApiError(409, "x", { code: "LABEL_PARTICULARS_MISSING", gaps: "no" }),
),
).toBeNull();
});
});
describe("labelCheckErrorText", () => {
it("explains a missing permission", () => {
expect(labelCheckErrorText(new ApiError(403, "Forbidden"), "f")).toMatch(/Berechtigung/);
});
it("gives the invalid compliance settings a clear German message", () => {
const error = new ApiError(409, "Compliance-Einstellungen fehlen oder sind ungültig.", {
code: "COMPLIANCE_SETTINGS_INVALID",
message: "Compliance-Einstellungen fehlen oder sind ungültig.",
});
expect(labelCheckErrorText(error, "f")).toMatch(/Compliance-Einstellungen/);
});
it("falls back for unknown errors", () => {
expect(labelCheckErrorText(new Error("boom"), "Fallback")).toBe("Fallback");
});
});
describe("useDebouncedLabelCheck", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("waits 500 ms after the last change before it asks the server", async () => {
const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
expect(result.current.pending).toBe(true);
await act(async () => {
vi.advanceTimersByTime(300);
});
rerender({ draft: { ...DRAFT, widthMm: 91 } });
await act(async () => {
vi.advanceTimersByTime(499);
});
expect(apiFetch).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(1);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(sentBody()).toMatchObject({ widthMm: 91 });
expect(result.current.result).toEqual(RESULT);
expect(result.current.pending).toBe(false);
expect(result.current.error).toBeNull();
});
it("does not ask again for an equal draft", async () => {
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
rerender({ draft: { ...DRAFT, elements: [...DRAFT.elements] } });
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("ignores changes to elements that are not text", async () => {
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
rerender({ draft: { ...DRAFT, elements: [TEXT, { ...QR, x: 9 }, LINE] } });
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("reports a failed check as a German error", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(403, "Forbidden"));
const { result } = renderHook(() => useDebouncedLabelCheck(DRAFT));
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(result.current.error).toMatch(/Berechtigung/);
expect(result.current.pending).toBe(false);
});
it("asks without caching and with an abort signal, and aborts a request that a newer draft replaces", async () => {
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
const { rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
expect(init.cache).toBe("no-store");
expect(init.signal?.aborted).toBe(false);
rerender({ draft: { ...DRAFT, widthMm: 95 } });
expect(init.signal?.aborted).toBe(true);
});
it("aborts the request when the component goes away", async () => {
vi.mocked(apiFetch).mockReturnValue(new Promise(() => {}));
const { unmount } = renderHook(() => useDebouncedLabelCheck(DRAFT));
await act(async () => {
vi.advanceTimersByTime(500);
});
const init = vi.mocked(apiFetch).mock.calls[0][1] as RequestInit;
unmount();
expect(init.signal?.aborted).toBe(true);
});
it("marks the old result stale when the draft becomes invalid", async () => {
const { result, rerender } = renderHook(({ draft }) => useDebouncedLabelCheck(draft), {
initialProps: { draft: DRAFT },
});
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(result.current.result).toEqual(RESULT);
expect(result.current.stale).toBe(false);
rerender({ draft: { ...DRAFT, minXHeightMm: Number.NaN } });
expect(result.current.stale).toBe(true);
expect(result.current.pending).toBe(false);
rerender({ draft: DRAFT });
expect(result.current.stale).toBe(false);
});
it("does not ask while the minimum x-height is out of range", async () => {
renderHook(() => useDebouncedLabelCheck({ ...DRAFT, minXHeightMm: 9 }));
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(apiFetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,237 @@
import { useEffect, useState } from "react";
import { apiFetch, ApiError } from "@/lib/auth/api-client";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { saveErrorText } from "@/lib/honey-batches/errors";
import type { LabelElement, LabelTextElement } from "./types";
/**
* The label check of the server. It holds every legal value (minimum x-height,
* digit heights), the frontend only shows what comes back and never decides.
*
* The answer of a check against a bottling contains the food business address:
* it is never put into a persisted query cache or the service worker cache
* (`/label-templates/:id/check/…` is in `NEVER_CACHED_API_PATHS`).
*/
export type LabelFindingCode =
| "LEGAL_NAME"
| "NET_QUANTITY"
| "BEST_BEFORE"
| "LOT"
| "ORIGIN"
| "FOOD_BUSINESS"
| "LOT_PREFIX"
| "X_HEIGHT"
| "NET_QUANTITY_HEIGHT"
| "TEXT_CLIPPED";
/** The six particulars that block printing, in the order the panel lists them. */
export const PARTICULAR_CODES = [
"LEGAL_NAME",
"NET_QUANTITY",
"BEST_BEFORE",
"LOT",
"ORIGIN",
"FOOD_BUSINESS",
] as const satisfies readonly LabelFindingCode[];
export interface LabelFinding {
code: LabelFindingCode;
title: string;
detail: string;
/** Text elements that carry (or would carry) the finding. */
elementIds: string[];
}
export interface LabelCheckResult {
/** No gap: the label may be printed. Warnings never block. */
printable: boolean;
gaps: LabelFinding[];
warnings: LabelFinding[];
/** Checked with example values (no bottling chosen). */
sample: boolean;
/** The legal values the check used. */
requirements: {
minXHeightMm: number;
netQuantityMinCapHeightMm: number | null;
};
}
export interface DraftInput {
elements: LabelElement[];
widthMm: number;
heightMm: number;
minXHeightMm: number;
bottlingBatchId?: string;
}
interface DraftBody {
elements: LabelTextElement[];
widthMm: number;
heightMm: number;
minXHeightMm: number;
bottlingBatchId?: string;
}
export const CHECK_DEBOUNCE_MS = 500;
/**
* Mirrors the bounds of `CheckLabelTemplateDto` / `UpdateLabelTemplateDto`. A technical
* range that keeps invalid input from being sent, not a legal value: the minimum
* that applies comes with every check result.
*/
export const MIN_X_HEIGHT_RANGE_MM = { min: 0.5, max: 5 } as const;
const SHEET_RANGE_MM = { min: 10, max: 1000 } as const;
const MAX_CHECK_ELEMENTS = 100;
const CHECK_FALLBACK = "Die Prüfung ist fehlgeschlagen.";
function isTextElement(element: LabelElement): element is LabelTextElement {
return element.type === "text";
}
function isWithin(value: number, range: { min: number; max: number }): boolean {
return Number.isFinite(value) && value >= range.min && value <= range.max;
}
function toBody(input: DraftInput): DraftBody {
return {
elements: input.elements.filter(isTextElement),
widthMm: input.widthMm,
heightMm: input.heightMm,
minXHeightMm: input.minXHeightMm,
...(input.bottlingBatchId ? { bottlingBatchId: input.bottlingBatchId } : {}),
};
}
function isCheckable(body: DraftBody): boolean {
return (
isWithin(body.minXHeightMm, MIN_X_HEIGHT_RANGE_MM) &&
isWithin(body.widthMm, SHEET_RANGE_MM) &&
isWithin(body.heightMm, SHEET_RANGE_MM) &&
body.elements.length <= MAX_CHECK_ELEMENTS
);
}
function postDraft(body: DraftBody, signal?: AbortSignal): Promise<LabelCheckResult> {
return apiFetch<LabelCheckResult>("/label-templates/check", {
method: "POST",
body: JSON.stringify(body),
cache: "no-store",
signal,
});
}
/** Checks an unsaved draft; only its text elements are sent. */
export function checkDraftTemplate(
input: DraftInput,
signal?: AbortSignal,
): Promise<LabelCheckResult> {
return postDraft(toBody(input), signal);
}
/** Checks a saved template against one bottling (real values, no sample). */
export function checkTemplateForBottling(
templateId: string,
bottlingBatchId: string,
): Promise<LabelCheckResult> {
return apiFetch<LabelCheckResult>(
`/label-templates/${encodeURIComponent(templateId)}/check/${encodeURIComponent(bottlingBatchId)}`,
{ cache: "no-store" },
);
}
export interface LabelParticularsMissing {
message: string;
gaps: LabelFinding[];
}
function isFinding(value: unknown): value is LabelFinding {
if (typeof value !== "object" || value === null) return false;
const finding = value as Partial<LabelFinding>;
return typeof finding.code === "string" && typeof finding.title === "string";
}
/** A 409 `LABEL_PARTICULARS_MISSING` with its gaps, otherwise `null`. */
export function labelParticularsMissing(error: unknown): LabelParticularsMissing | null {
if (!(error instanceof ApiError) || error.status !== 409) return null;
const body = error.body as { code?: unknown; message?: unknown; gaps?: unknown } | null;
if (body?.code !== "LABEL_PARTICULARS_MISSING") return null;
if (!Array.isArray(body.gaps) || !body.gaps.every(isFinding)) return null;
return {
message: typeof body.message === "string" ? body.message : error.message,
gaps: body.gaps,
};
}
function isComplianceSettingsInvalid(error: unknown): boolean {
if (!(error instanceof ApiError) || error.status !== 409) return false;
return (error.body as { code?: unknown } | null)?.code === "COMPLIANCE_SETTINGS_INVALID";
}
/** The ONE reader of a failed check or print: German text for 403, offline and invalid settings. */
export function labelCheckErrorText(error: unknown, fallback: string): string {
if (isNetworkError(error)) return "Das geht nur mit Verbindung.";
if (error instanceof ApiError && error.status === 403) {
return "Dir fehlt die Berechtigung für Etiketten.";
}
if (isComplianceSettingsInvalid(error)) {
return "Die Compliance-Einstellungen fehlen oder sind ungültig. Bitte melde das der Administration.";
}
return saveErrorText(error, fallback);
}
interface CheckState {
/** The serialized draft this state answers. */
key: string | null;
result: LabelCheckResult | null;
error: string | null;
}
/**
* Checks the draft 500 ms after the last change. Only the text elements count:
* moving a QR code or a line does not trigger a request. While a value is out
* of range (the person is still typing) nothing is asked and the last answer stays.
*/
export function useDebouncedLabelCheck(
draft: DraftInput,
delayMs: number = CHECK_DEBOUNCE_MS,
): {
result: LabelCheckResult | null;
pending: boolean;
error: string | null;
/** The draft is invalid right now, so `result` answers an older draft. */
stale: boolean;
} {
const body = toBody(draft);
const payload = isCheckable(body) ? JSON.stringify(body) : null;
const [state, setState] = useState<CheckState>({ key: null, result: null, error: null });
useEffect(() => {
if (payload === null) return;
let cancelled = false;
const controller = new AbortController();
const timer = setTimeout(() => {
postDraft(JSON.parse(payload) as DraftBody, controller.signal).then(
(result) => {
if (!cancelled) setState({ key: payload, result, error: null });
},
(cause: unknown) => {
if (!cancelled) {
setState({ key: payload, result: null, error: labelCheckErrorText(cause, CHECK_FALLBACK) });
}
},
);
}, delayMs);
return () => {
cancelled = true;
controller.abort();
clearTimeout(timer);
};
}, [payload, delayMs]);
return {
result: state.result,
pending: payload !== null && state.key !== payload,
error: state.error,
stale: payload === null && state.result !== null,
};
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildComplianceBlock } from "./compliance-block";
import type { LabelCheckResult } from "./check";
const TEMPLATE = { widthMm: 90, heightMm: 60 };
const NEW_KEYS = [
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine",
] as const;
// Helvetica metrics the backend measures with (thousandths of the font size).
const X_HEIGHT = 523;
const CAP_HEIGHT = 718;
const MM_PER_PT = 25.4 / 72;
function requirements(
minXHeightMm: number,
netQuantityMinCapHeightMm: number | null,
): LabelCheckResult["requirements"] {
return { minXHeightMm, netQuantityMinCapHeightMm };
}
describe("buildComplianceBlock", () => {
it("inserts one text element per new placeholder, sized to meet the required x-height", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
for (const key of NEW_KEYS) {
const matching = block.filter((element) => element.placeholderKey === key);
expect(matching).toHaveLength(1);
const xHeightMm = (matching[0].fontSize * X_HEIGHT * MM_PER_PT) / 1000;
expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
}
expect(block.every((element) => element.type === "text")).toBe(true);
expect(new Set(block.map((element) => element.id)).size).toBe(block.length);
});
it("scales the size with the required x-height instead of using a fixed number", () => {
const small = buildComplianceBlock(TEMPLATE, requirements(0.9, null));
const large = buildComplianceBlock(TEMPLATE, requirements(2.4, null));
const legalSize = (block: ReturnType<typeof buildComplianceBlock>) =>
block.find((element) => element.placeholderKey === "legalName")!.fontSize;
expect(legalSize(large)).toBeGreaterThan(legalSize(small));
});
it("uses the digit height requirement for the net quantity element when present", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
const net = block.find((element) => element.placeholderKey === "jarSize");
expect(net).toBeDefined();
const capHeightMm = (net!.fontSize * CAP_HEIGHT * MM_PER_PT) / 1000;
expect(capHeightMm).toBeGreaterThanOrEqual(3 - 1e-9);
});
it("sizes the net quantity by the x-height when no digit rule applies", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, null));
const net = block.find((element) => element.placeholderKey === "jarSize");
const xHeightMm = (net!.fontSize * X_HEIGHT * MM_PER_PT) / 1000;
expect(xHeightMm).toBeGreaterThanOrEqual(1.2 - 1e-9);
});
it("keeps every element inside the label width", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
for (const element of block) {
expect(element.x).toBeGreaterThanOrEqual(0);
expect(element.x + element.width).toBeLessThanOrEqual(TEMPLATE.widthMm);
}
});
it("stacks the elements without overlap", () => {
const block = buildComplianceBlock(TEMPLATE, requirements(1.2, 3));
for (let index = 1; index < block.length; index += 1) {
expect(block[index].y).toBeGreaterThanOrEqual(block[index - 1].y + block[index - 1].height);
}
});
});
@@ -0,0 +1,83 @@
import type { LabelCheckResult } from "./check";
import type { LabelTextElement, PlaceholderKey } from "./types";
// Metrics of the built-in PDF font (Helvetica, thousandths of the font size),
// the same ones the server measures with. They describe the font, not the law:
// the legal minimums arrive with every check result.
const X_HEIGHT_RATIO = 0.523;
const CAP_HEIGHT_RATIO = 0.718;
const MM_PER_PT = 25.4 / 72;
const SIZE_STEP_PT = 0.5;
const LINE_HEIGHT_FACTOR = 1.25;
const EDGE_MM = 2;
const ROW_GAP_MM = 0.5;
const EPSILON = 1e-9;
interface BlockRow {
key: PlaceholderKey;
/** Which height decides the size. */
basis: "xHeight" | "netQuantity";
lines: number;
}
// Legal name and net quantity first, then the dates and the origin, the
// food business last: the order a buyer reads them on a jar.
const BLOCK_ROWS: readonly BlockRow[] = [
{ key: "legalName", basis: "xHeight", lines: 1 },
{ key: "jarSize", basis: "netQuantity", lines: 1 },
{ key: "bestBeforeLine", basis: "xHeight", lines: 1 },
{ key: "lotLine", basis: "xHeight", lines: 1 },
{ key: "originCountry", basis: "xHeight", lines: 1 },
{ key: "foodBusinessName", basis: "xHeight", lines: 1 },
{ key: "foodBusinessAddress", basis: "xHeight", lines: 2 },
];
/** Smallest font size (in half points) whose glyph height reaches `heightMm`. */
function fontSizeFor(heightMm: number, ratio: number): number {
const points = heightMm / MM_PER_PT / ratio;
return Math.ceil(points / SIZE_STEP_PT - EPSILON) * SIZE_STEP_PT;
}
function sizeOf(row: BlockRow, requirements: LabelCheckResult["requirements"]): number {
const capHeightMm = requirements.netQuantityMinCapHeightMm;
if (row.basis === "netQuantity" && capHeightMm !== null) {
return fontSizeFor(capHeightMm, CAP_HEIGHT_RATIO);
}
return fontSizeFor(requirements.minXHeightMm, X_HEIGHT_RATIO);
}
/**
* One text element per particular the label needs, stacked top to bottom.
* The sizes follow `requirements` of the last check; no legal value is
* stored here. The elements carry only placeholders, so the printed text
* always comes from the master data.
*/
export function buildComplianceBlock(
template: { widthMm: number; heightMm: number },
requirements: LabelCheckResult["requirements"],
): LabelTextElement[] {
const width = Math.max(template.widthMm - 2 * EDGE_MM, 1);
let top = EDGE_MM;
return BLOCK_ROWS.map((row) => {
const fontSize = sizeOf(row, requirements);
const height = fontSize * MM_PER_PT * LINE_HEIGHT_FACTOR * row.lines;
const element: LabelTextElement = {
id: crypto.randomUUID(),
type: "text",
x: EDGE_MM,
y: top,
width,
height,
text: "",
placeholderKey: row.key,
fontSize,
fontFamily: "sans",
bold: false,
italic: false,
color: "#000000",
align: "left",
};
top += height + ROW_GAP_MM;
return element;
});
}
@@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
describe("placeholder keys", () => {
it("the frontend list equals the backend fixture ApiCore-Backend/test/fixtures/placeholder-keys.json", () => {
// The monorepo sits next to the frontend; vitest runs from ApiCore-Frontend.
const fixture = path.resolve(
process.cwd(),
"../ApiCore-Backend/test/fixtures/placeholder-keys.json",
);
const backendKeys: unknown = JSON.parse(readFileSync(fixture, "utf8"));
expect([...PLACEHOLDER_KEYS]).toEqual(backendKeys);
});
it("has a German label for every key", () => {
for (const key of PLACEHOLDER_KEYS) {
expect(PLACEHOLDER_LABELS[key]).toBeTruthy();
}
});
});
@@ -0,0 +1,37 @@
/**
* Placeholder keys a text element can carry. The backend owns the list
* (`PLACEHOLDER_KEYS`); `placeholder-keys.spec.ts` compares this copy with the
* backend fixture so the two never drift apart.
*/
export const PLACEHOLDER_KEYS = [
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine",
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
batchNumber: "Chargennummer",
honeyType: "Honigsorte",
lotNumber: "Los-/Chargennummer der Abfüllung",
bottlingDate: "Abfülldatum",
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
jarSize: "Glasgröße / Füllmenge",
productName: "Produktname",
legalName: "Verkehrsbezeichnung (z. B. Blütenhonig)",
originCountry: "Herkunftsland",
foodBusinessName: "Lebensmittelunternehmer: Name",
foodBusinessAddress: "Lebensmittelunternehmer: Anschrift",
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
lotLine: "Zeile „L Losnummer“",
};
@@ -1,27 +1,11 @@
import type { PlaceholderKey } from "./placeholder-keys";
export type LabelFontFamily = "sans" | "serif" | "mono";
export type LabelTextAlign = "left" | "center" | "right";
export type LabelLineOrientation = "horizontal" | "vertical";
export const PLACEHOLDER_KEYS = [
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
batchNumber: "Chargennummer",
honeyType: "Honigsorte",
lotNumber: "Los-/Chargennummer der Abfüllung",
bottlingDate: "Abfülldatum",
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
jarSize: "Glasgröße",
productName: "Produktname",
};
export { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
export type { PlaceholderKey } from "./placeholder-keys";
interface LabelElementBase {
id: string;
@@ -75,6 +59,8 @@ export interface LabelTemplate {
elements: LabelElement[];
widthMm: number;
heightMm: number;
/** Smallest x-height (mm) the check demands of a text; 0.5 to 5. */
minXHeightMm: number;
columns: number | null;
rows: number | null;
marginTopMm: number;
@@ -93,6 +79,7 @@ export interface CreateLabelTemplateInput {
elements: LabelElement[];
widthMm?: number;
heightMm?: number;
minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
@@ -108,6 +95,7 @@ export interface UpdateLabelTemplateInput {
elements?: LabelElement[];
widthMm?: number;
heightMm?: number;
minXHeightMm?: number;
columns?: number | null;
rows?: number | null;
marginTopMm?: number;
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/auth/api-client";
import {
bookCorrection,
bookDisposal,
createLot,
createMedicine,
fetchLot,
fetchMedicine,
fetchMedicines,
fetchMedicineSuggestions,
fetchSelectableLots,
setMedicineActive,
updateLot,
updateMedicine,
} from "./api";
vi.mock("@/lib/auth/api-client", () => ({ apiFetch: vi.fn(async () => ({})) }));
function lastCall() {
const [path, init] = vi.mocked(apiFetch).mock.calls.at(-1) ?? [];
return { path, init: init as RequestInit | undefined };
}
describe("medicines api", () => {
beforeEach(() => vi.mocked(apiFetch).mockClear());
it("lists all medicines or only the active/inactive ones", async () => {
await fetchMedicines();
expect(lastCall().path).toBe("/veterinary-medicines");
await fetchMedicines(false);
expect(lastCall().path).toBe("/veterinary-medicines?active=false");
});
it("reads suggestions and one medicine with its lots", async () => {
await fetchMedicineSuggestions();
expect(lastCall().path).toBe("/veterinary-medicines/suggestions");
await fetchMedicine("m 1");
expect(lastCall().path).toBe("/veterinary-medicines/m%201");
});
it("creates with an Idempotency-Key and patches without one", async () => {
await createMedicine({ name: "A", unit: "ML" }, { idempotencyKey: "k1" });
expect(lastCall().path).toBe("/veterinary-medicines");
expect(lastCall().init?.method).toBe("POST");
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k1" });
await updateMedicine("m1", { notes: null });
expect(lastCall().init?.method).toBe("PATCH");
expect(lastCall().init?.body).toBe(JSON.stringify({ notes: null }));
});
it("switches a medicine on and off through the lifecycle routes", async () => {
await setMedicineActive("m1", false);
expect(lastCall().path).toBe("/veterinary-medicines/m1/deactivate");
await setMedicineActive("m1", true);
expect(lastCall().path).toBe("/veterinary-medicines/m1/reactivate");
});
it("asks for selectable lots, keeping the chosen one with includeLotId", async () => {
await fetchSelectableLots("m1");
expect(lastCall().path).toBe("/veterinary-medicine-lots?medicineId=m1&selectable=true");
await fetchSelectableLots("m1", "l9");
expect(lastCall().path).toBe(
"/veterinary-medicine-lots?medicineId=m1&selectable=true&includeLotId=l9",
);
});
it("books a lot with an Idempotency-Key and posts the inventory and the disposal", async () => {
await fetchLot("l1");
expect(lastCall().path).toBe("/veterinary-medicine-lots/l1");
await createLot(
{ medicineId: "m1", purchasedOn: "2026-08-01", quantity: 5 },
{ idempotencyKey: "k2" },
);
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k2" });
await updateLot("l1", { supplier: null });
expect(lastCall().init?.method).toBe("PATCH");
await bookCorrection("l1", 3, { idempotencyKey: "k3" });
expect(lastCall().path).toBe("/veterinary-medicine-lots/l1/movements");
expect(lastCall().init?.body).toBe(JSON.stringify({ type: "CORRECTION", actualRemaining: 3 }));
expect(lastCall().init?.headers).toEqual({ "Idempotency-Key": "k3" });
await bookDisposal("l1", 2, "Abgelaufen", { idempotencyKey: "k4" });
expect(lastCall().init?.body).toBe(
JSON.stringify({ type: "DISPOSAL", quantity: 2, reason: "Abgelaufen" }),
);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import type {
LotDetailView,
LotInput,
LotView,
MedicineInput,
MedicineSuggestion,
MedicineView,
} from "./types";
const MEDICINES = "/veterinary-medicines";
const LOTS = "/veterinary-medicine-lots";
function path(base: string, id: string, rest = ""): string {
return `${base}/${encodeURIComponent(id)}${rest}`;
}
function post<T>(url: string, body: unknown, options?: CreateOptions): Promise<T> {
return apiFetch<T>(url, {
method: "POST",
headers: idempotencyHeaders(options),
body: JSON.stringify(body),
});
}
function patch<T>(url: string, body: unknown): Promise<T> {
return apiFetch<T>(url, { method: "PATCH", body: JSON.stringify(body) });
}
/** All medicines, or only the active (`true`) / inactive (`false`) ones. */
export function fetchMedicines(active?: boolean): Promise<MedicineView[]> {
return apiFetch<MedicineView[]>(active === undefined ? MEDICINES : `${MEDICINES}?active=${active}`);
}
/** Own product names from treatments that the catalogue does not know yet. */
export function fetchMedicineSuggestions(): Promise<MedicineSuggestion[]> {
return apiFetch<MedicineSuggestion[]>(`${MEDICINES}/suggestions`);
}
export function fetchMedicine(id: string): Promise<MedicineView & { lots: LotView[] }> {
return apiFetch<MedicineView & { lots: LotView[] }>(path(MEDICINES, id));
}
export function createMedicine(input: MedicineInput, options?: CreateOptions): Promise<MedicineView> {
return post<MedicineView>(MEDICINES, input, options);
}
export function updateMedicine(id: string, input: Partial<MedicineInput>): Promise<MedicineView> {
return patch<MedicineView>(path(MEDICINES, id), input);
}
export function setMedicineActive(id: string, active: boolean): Promise<MedicineView> {
return post<MedicineView>(path(MEDICINES, id, active ? "/reactivate" : "/deactivate"), {});
}
/**
* Lots a treatment may take from (rest above 0, active medicine, oldest expiry
* first). `includeLotId` keeps the lot of a treatment that is being edited on
* the list even when it is empty or its medicine was switched off.
*/
export function fetchSelectableLots(medicineId: string, includeLotId?: string): Promise<LotView[]> {
const params = new URLSearchParams({ medicineId, selectable: "true" });
if (includeLotId) params.set("includeLotId", includeLotId);
return apiFetch<LotView[]>(`${LOTS}?${params.toString()}`);
}
export function fetchLot(id: string): Promise<LotDetailView> {
return apiFetch<LotDetailView>(path(LOTS, id));
}
export function createLot(input: LotInput, options?: CreateOptions): Promise<LotView> {
return post<LotView>(LOTS, input, options);
}
export function updateLot(id: string, input: Partial<LotInput>): Promise<LotView> {
return patch<LotView>(path(LOTS, id), input);
}
/** Inventory: the rest counted in the box; the server books the difference. */
export function bookCorrection(
lotId: string,
actualRemaining: number,
options?: CreateOptions,
): Promise<LotDetailView> {
return post<LotDetailView>(path(LOTS, lotId, "/movements"), { type: "CORRECTION", actualRemaining }, options);
}
export function bookDisposal(
lotId: string,
quantity: number,
reason: string,
options?: CreateOptions,
): Promise<LotDetailView> {
return post<LotDetailView>(path(LOTS, lotId, "/movements"), { type: "DISPOSAL", quantity, reason }, options);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildBook } from "./book";
import { lotDetail } from "./fixtures.spec-helper";
const movement = (id: string, createdAt: string, quantity: number, type = "TREATMENT" as const) => ({
id,
type,
quantity,
treatmentId: null,
reason: null,
userId: null,
createdAt,
});
describe("buildBook", () => {
it("lists the newest booking first and ends at the receipt", () => {
const rows = buildBook(
lotDetail({
quantity: 20,
movements: [movement("b", "2026-08-12T08:00:00.000Z", -3), movement("a", "2026-08-10T08:00:00.000Z", -8)],
}),
);
expect(rows.map((row) => row.label)).toEqual(["Behandlung", "Behandlung", "Eingang"]);
expect(rows.map((row) => row.balance)).toEqual([9, 12, 20]);
});
it("keeps the running rest exact to the thousandth", () => {
const rows = buildBook(
lotDetail({
quantity: 1,
movements: [movement("a", "2026-08-10T08:00:00.000Z", -0.1), movement("b", "2026-08-11T08:00:00.000Z", -0.2)],
}),
);
expect(rows[0].balance).toBe(0.7);
});
it("lets a rest fall below zero and keeps the sign", () => {
const rows = buildBook(lotDetail({ quantity: 2, movements: [movement("a", "2026-08-10T08:00:00.000Z", -5)] }));
expect(rows[0].balance).toBe(-3);
});
it("reads the day of a booking as the Berlin calendar day", () => {
const rows = buildBook(lotDetail({ movements: [movement("a", "2026-08-10T22:30:00.000Z", -1)] }));
expect(rows[0].day).toBe("2026-08-11");
});
});
@@ -0,0 +1,50 @@
import { berlinCalendarDay } from "@/lib/dates";
import { MOVEMENT_TYPE_LABELS, roundToThousandths } from "./format";
import type { LotDetailView } from "./types";
export interface BookRow {
key: string;
/** Calendar day "YYYY-MM-DD". */
day: string;
label: string;
/** Signed. */
quantity: number;
/** Rest of the lot right after this booking. */
balance: number;
reason: string | null;
}
/**
* The movement book of one lot, newest first, each row with the rest it left
* behind. The first entry is the receipt of the pack (the initial quantity);
* the server never changes or deletes a movement, so the running rest always
* ends at `lot.remaining`.
*/
export function buildBook(lot: LotDetailView): BookRow[] {
const movements = [...lot.movements].sort(
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
);
const rows: BookRow[] = [
{
key: "receipt",
day: lot.purchasedOn,
label: "Eingang",
quantity: lot.quantity,
balance: lot.quantity,
reason: null,
},
];
let balance = lot.quantity;
for (const movement of movements) {
balance = roundToThousandths(balance + movement.quantity);
rows.push({
key: movement.id,
day: berlinCalendarDay(movement.createdAt),
label: MOVEMENT_TYPE_LABELS[movement.type],
quantity: movement.quantity,
balance,
reason: movement.reason,
});
}
return rows.reverse();
}
@@ -0,0 +1,79 @@
import type { LotDetailView, LotView, MedicineSuggestion, MedicineView } from "./types";
export function medicine(overrides: Partial<MedicineView> = {}): MedicineView {
return {
id: "m1",
name: "Apivar",
activeIngredient: "Amitraz",
authorisationNumber: null,
prescriptionOnly: false,
withdrawalPeriodDays: 0,
unit: "PIECE",
dosePerColony: null,
doseNote: null,
applicationMethod: null,
minTemperatureC: null,
maxTemperatureC: null,
defaultSupplier: null,
notes: null,
active: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
totalRemaining: 12,
nextExpiry: "2028-01-31",
lotCount: 1,
...overrides,
};
}
export function lot(overrides: Partial<LotView> = {}): LotView {
return {
id: "l1",
medicineId: "m1",
medicineName: "Apivar",
unit: "PIECE",
batchNumber: "CH-42",
purchasedOn: "2026-08-01",
quantity: 20,
remaining: 12,
expiresOn: "2028-01-31",
supplier: "Tierarzt Muster",
purchaseProof: "Rechnung 4711",
prescribingVet: null,
notes: null,
movementCount: 1,
...overrides,
};
}
export function lotDetail(overrides: Partial<LotDetailView> = {}): LotDetailView {
return {
...lot(),
movements: [
{
id: "mv1",
type: "TREATMENT",
quantity: -8,
treatmentId: "t1",
reason: null,
userId: "u1",
createdAt: "2026-08-10T08:00:00.000Z",
},
],
...overrides,
};
}
export function suggestion(overrides: Partial<MedicineSuggestion> = {}): MedicineSuggestion {
return {
name: "Thymovar",
treatmentCount: 3,
latest: {
activeIngredient: "Thymol",
withdrawalPeriodDays: 7,
method: "Streifen",
amount: "2 Stück",
},
...overrides,
};
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
decimalMessage,
formatDay,
formatQuantity,
formatSignedQuantity,
isAmbiguousDecimal,
parseDecimal,
THOUSANDS_SEPARATOR_MESSAGE,
} from "./format";
describe("medicine formatting", () => {
it("writes quantities with the unit and German decimals", () => {
expect(formatQuantity(12.5, "ML")).toBe("12,5 ml");
expect(formatQuantity(3, "G")).toBe("3 g");
expect(formatQuantity(2, "PIECE")).toBe("2 Stück");
});
it("writes a signed change with a real minus sign", () => {
expect(formatSignedQuantity(-2, "ML")).toBe("−2 ml");
expect(formatSignedQuantity(1.5, "G")).toBe("+1,5 g");
expect(formatSignedQuantity(0, "G")).toBe("0 g");
});
it("writes a calendar day as given, without a time zone shift", () => {
expect(formatDay("2028-01-31")).toBe("31.01.2028");
expect(formatDay(null)).toBe("—");
});
});
describe("parseDecimal", () => {
it.each([
["1,5", 1.5],
["1.5", 1.5],
["0.001", 0.001],
["1,000", 1],
["12", 12],
[" 2,5 ", 2.5],
])("reads %s as %s", (text, value) => {
expect(parseDecimal(text)).toBe(value);
});
it.each(["1.000", "12.345", "1.234,5", "1,234.5", "1.2.3", "1,2,3"])(
"refuses the ambiguous %s",
(text) => {
expect(isAmbiguousDecimal(text)).toBe(true);
expect(parseDecimal(text)).toBeNaN();
expect(decimalMessage(text, "x")).toBe(THOUSANDS_SEPARATOR_MESSAGE);
},
);
it("is NaN for empty or non-numeric text and keeps the plain message there", () => {
expect(parseDecimal("")).toBeNaN();
expect(parseDecimal("abc")).toBeNaN();
expect(decimalMessage("abc", "plain")).toBe("plain");
});
});
@@ -0,0 +1,73 @@
import type { MedicineUnit, MovementType } from "./types";
export const MEDICINE_UNITS: readonly MedicineUnit[] = ["ML", "G", "PIECE"];
export const MEDICINE_UNIT_LABELS: Record<MedicineUnit, string> = {
ML: "ml",
G: "g",
PIECE: "Stück",
};
export const MOVEMENT_TYPE_LABELS: Record<MovementType, string> = {
TREATMENT: "Behandlung",
CORRECTION: "Inventur",
DISPOSAL: "Entsorgung",
};
const MAX_FRACTION_DIGITS = 3;
const MINUS = "−";
function number(value: number): string {
return value.toLocaleString("de-DE", { maximumFractionDigits: MAX_FRACTION_DIGITS });
}
/** "12,5 ml": at most thousandths, German decimals. */
export function formatQuantity(value: number, unit: MedicineUnit): string {
return `${number(value).replace("-", MINUS)} ${MEDICINE_UNIT_LABELS[unit]}`;
}
/** "+1,5 g" / "−2 ml" / "0 g": the sign is part of the information. */
export function formatSignedQuantity(value: number, unit: MedicineUnit): string {
if (value > 0) return `+${formatQuantity(value, unit)}`;
return formatQuantity(value, unit);
}
/** A calendar day "YYYY-MM-DD" as "TT.MM.JJJJ", read as written (no time zone). */
export function formatDay(day: string | null): string {
if (!day) return "—";
const [year, month, date] = day.split("-");
return `${date}.${month}.${year}`;
}
/** Thousandths, as the server rounds, so the shown delta equals the booked one. */
export function roundToThousandths(value: number): number {
return Math.round(value * 1000) / 1000 + 0;
}
export const THOUSANDS_SEPARATOR_MESSAGE = "Bitte ohne Tausenderpunkt eingeben, z. B. 1000 oder 1,5";
/**
* Locale rule: a comma is always the German decimal separator ("1,000" is 1).
* A dot is a decimal point only when it is the single separator and does not
* look like a thousands dot, so "1.5" and "0.001" pass, while "1.000", "12.345"
* and any text with more than one separator are ambiguous and refused (a
* silently wrong quantity in a medicine book is worse than a question).
*/
export function isAmbiguousDecimal(text: string): boolean {
const trimmed = text.trim().replace(/\s/g, "");
const separators = trimmed.match(/[.,]/g)?.length ?? 0;
if (separators > 1) return true;
return /^\d*[1-9]\d*\.\d{3}$/.test(trimmed);
}
/** Accepts "2,5" as well as "2.5"; anything else, or an ambiguous thousands dot, is NaN. */
export function parseDecimal(text: string): number {
if (isAmbiguousDecimal(text)) return Number.NaN;
const trimmed = text.trim().replace(",", ".");
return trimmed === "" ? Number.NaN : Number(trimmed);
}
/** The message for a typed number that parses to nothing: the thousands hint or the plain one. */
export function decimalMessage(text: string, plain: string): string {
return isAmbiguousDecimal(text) ? THOUSANDS_SEPARATOR_MESSAGE : plain;
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { prefillFromSuggestion, unitFromAmount } from "./prefill";
import { suggestion } from "./fixtures.spec-helper";
describe("unitFromAmount", () => {
it.each([
["30 ml", "ML"],
["2 Stück", "PIECE"],
["2 Streifen", "PIECE"],
["5 g", "G"],
["5 Gramm", "G"],
])("reads the unit of %s", (amount, unit) => {
expect(unitFromAmount(amount)).toBe(unit);
});
it("names no unit when the text names none", () => {
expect(unitFromAmount("eine Dosis")).toBeUndefined();
expect(unitFromAmount(null)).toBeUndefined();
});
});
describe("prefillFromSuggestion", () => {
it("carries the values of the latest treatment", () => {
expect(prefillFromSuggestion(suggestion())).toEqual({
name: "Thymovar",
unit: "PIECE",
activeIngredient: "Thymol",
withdrawalPeriodDays: 7,
applicationMethod: "Streifen",
doseNote: "Menge der letzten Behandlung: 2 Stück",
});
});
it("leaves the dose note empty when the treatment had no amount", () => {
const result = prefillFromSuggestion(
suggestion({ latest: { activeIngredient: null, withdrawalPeriodDays: null, method: null, amount: null } }),
);
expect(result.doseNote).toBeNull();
expect(result.unit).toBeUndefined();
});
});
@@ -0,0 +1,42 @@
import type { MedicineSuggestion, MedicineUnit } from "./types";
/** What the form starts with when a medicine is taken over from a treatment. */
export interface MedicineFormPrefill {
name?: string;
unit?: MedicineUnit;
activeIngredient?: string | null;
withdrawalPeriodDays?: number | null;
applicationMethod?: string | null;
doseNote?: string | null;
}
const MAX_DOSE_NOTE_LENGTH = 400;
const UNIT_HINTS: readonly [RegExp, MedicineUnit][] = [
[/\bml\b/i, "ML"],
[/\b(stück|stueck|stk|streifen)\b/i, "PIECE"],
[/\b(g|gramm)\b/i, "G"],
];
/** The unit the amount text of the latest treatment names, or nothing when it names none. */
export function unitFromAmount(amount: string | null): MedicineUnit | undefined {
if (!amount) return undefined;
return UNIT_HINTS.find(([pattern]) => pattern.test(amount))?.[1];
}
/**
* The values of the latest treatment as a proposal for the form. They are
* shown, never saved by themselves: the beekeeper checks them against the
* package leaflet first.
*/
export function prefillFromSuggestion(suggestion: MedicineSuggestion): MedicineFormPrefill {
const { latest } = suggestion;
return {
name: suggestion.name,
unit: unitFromAmount(latest.amount),
activeIngredient: latest.activeIngredient,
withdrawalPeriodDays: latest.withdrawalPeriodDays,
applicationMethod: latest.method,
doseNote: latest.amount ? `Menge der letzten Behandlung: ${latest.amount}`.slice(0, MAX_DOSE_NOTE_LENGTH) : null,
};
}
+101
View File
@@ -0,0 +1,101 @@
export type MedicineUnit = "ML" | "G" | "PIECE";
export type MovementType = "TREATMENT" | "CORRECTION" | "DISPOSAL";
/** Days without a time of day travel as "YYYY-MM-DD". */
export interface MedicineView {
id: string;
name: string;
activeIngredient: string | null;
authorisationNumber: string | null;
prescriptionOnly: boolean;
/** Days of withdrawal for honey, 0 is a value. */
withdrawalPeriodDays: number | null;
unit: MedicineUnit;
dosePerColony: number | null;
doseNote: string | null;
applicationMethod: string | null;
minTemperatureC: number | null;
maxTemperatureC: number | null;
defaultSupplier: string | null;
notes: string | null;
active: boolean;
createdAt: string;
updatedAt: string;
/** Sum of the rest of all lots; a lot below zero counts negative. */
totalRemaining: number;
/** Earliest expiry of a lot that still has a rest. */
nextExpiry: string | null;
lotCount: number;
}
export interface LotView {
id: string;
medicineId: string;
medicineName: string;
unit: MedicineUnit;
batchNumber: string | null;
purchasedOn: string;
quantity: number;
/** Quantity plus all movements; may be negative. */
remaining: number;
expiresOn: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
notes: string | null;
movementCount: number;
}
export interface MovementView {
id: string;
type: MovementType;
/** Signed: negative takes medicine out of the lot. */
quantity: number;
treatmentId: string | null;
reason: string | null;
userId: string | null;
createdAt: string;
}
export interface LotDetailView extends LotView {
movements: MovementView[];
}
export interface MedicineSuggestion {
name: string;
treatmentCount: number;
latest: {
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
method: string | null;
amount: string | null;
};
}
export interface MedicineInput {
name: string;
unit: MedicineUnit;
activeIngredient?: string | null;
authorisationNumber?: string | null;
prescriptionOnly?: boolean;
withdrawalPeriodDays?: number | null;
dosePerColony?: number | null;
doseNote?: string | null;
applicationMethod?: string | null;
minTemperatureC?: number | null;
maxTemperatureC?: number | null;
defaultSupplier?: string | null;
notes?: string | null;
}
export interface LotInput {
medicineId: string;
batchNumber?: string | null;
purchasedOn: string;
quantity: number;
expiresOn?: string | null;
supplier?: string | null;
purchaseProof?: string | null;
prescribingVet?: string | null;
notes?: string | null;
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { ApiError } from "@/lib/auth/api-client";
import { useAttemptKey } from "./use-attempt-key";
describe("useAttemptKey", () => {
it("keeps the key for an unchanged retry after a network error", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new TypeError("Failed to fetch"));
expect(result.current.keyFor({ quantity: 1 })).toBe(first);
});
it("renews the key when the body changes", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new TypeError("Failed to fetch"));
expect(result.current.keyFor({ quantity: 2 })).not.toBe(first);
});
it("renews the key after an answer from the server, even for the same body", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.failed(new ApiError(409, "Gerade geändert.", {}));
expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
});
it("renews the key after a success", () => {
const { result } = renderHook(() => useAttemptKey());
const first = result.current.keyFor({ quantity: 1 });
result.current.done();
expect(result.current.keyFor({ quantity: 1 })).not.toBe(first);
});
});
@@ -0,0 +1,39 @@
import { useRef } from "react";
import { isNetworkError } from "@/lib/http/fetch-with-timeout";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
/**
* The Idempotency-Key of one booking form. The key is kept only for an
* unchanged retry after a network error (the server may have saved the first
* try). Any answer from the server that is not a success, and any change of
* the request body, starts a new key: a changed booking must never be taken
* for its earlier attempt.
*/
export function useAttemptKey(): {
keyFor: (body: unknown) => string;
failed: (error: unknown) => void;
done: () => void;
} {
const key = useRef(newIdempotencyKey());
const lastBody = useRef<string | null>(null);
return {
keyFor(body) {
const text = JSON.stringify(body);
if (lastBody.current !== null && lastBody.current !== text) {
key.current = newIdempotencyKey();
}
lastBody.current = text;
return key.current;
},
failed(error) {
if (!isNetworkError(error)) {
key.current = newIdempotencyKey();
lastBody.current = null;
}
},
done() {
key.current = newIdempotencyKey();
lastBody.current = null;
},
};
}
@@ -18,6 +18,7 @@ interface SwRoutes {
isShellRoute: (pathname: string) => boolean;
extractAssetUrls: (html: string) => string[];
isCacheableApiPath: (pathname: string) => boolean;
isKeepableApiAnswer: (answer: { ok: boolean; contentType: string | null; cacheControl: string | null }) => boolean;
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
apiOriginFrom: (config: unknown) => string | null;
isRoutePayload: (url: URL) => boolean;
@@ -224,6 +225,100 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/exporters")).toBe(true);
});
it.each([
"/api/v1/compliance/readiness",
"/api/v1/compliance",
"/api/v1/Compliance/readiness/summary",
"/api/v1/compliance/readiness/report.pdf",
"/api/v1/authority-notices",
"/api/v1/authority-notices/",
"/api/v1/authority-notices/n-1",
"/api/v1/apiary-restrictions",
"/api/v1/apiary-restrictions/r-1",
"/api/v1/apiaries/a-1/restrictions",
"/api/v1/apiaries/a-1/restrictions/",
"/api/v1/veterinary-medicines",
"/api/v1/veterinary-medicines/m-1",
"/api/v1/veterinary-medicine-lots",
"/api/v1/Veterinary-Medicine-Lots/l-1",
"/api/v1/label-templates/t-1/check/b-1",
"/api/v1/label-templates/t-1/check/b-1/",
"/api/v1/label-templates/check",
"/api/v1/label-templates/t-1/print/b-1",
"/api/v1/label-templates/t-1/print",
"/api/v1/compliance/readiness?x=1",
])("never caches %s", (path) => {
expect(cachesApi(path)).toBe(false);
});
it("never caches the documents of a medicine lot, a notice or a restriction", () => {
for (const entityType of ["MEDICINE_LOT", "AUTHORITY_NOTICE", "APIARY_RESTRICTION", "medicine_lot"]) {
expect(cachesApi(`/api/v1/documents?entityType=${entityType}&entityId=x-1`)).toBe(false);
expect(cachesApi(`/api/v1/documents/?entityId=x-1&entityType=${entityType}`)).toBe(false);
}
});
it("keeps caching the document lists of inspections, so their photos stay available offline", () => {
expect(cachesApi("/api/v1/documents?entityType=INSPECTION&entityId=i-1")).toBe(true);
expect(cachesApi("/api/v1/documents?entityType=COLONY&entityId=c-1")).toBe(true);
});
it("never caches a single document record, whose kind the path does not tell", () => {
expect(cachesApi("/api/v1/documents/d-1")).toBe(false);
expect(cachesApi("/api/v1/documents/d-1/")).toBe(false);
expect(cachesApi("/api/v1/Documents/d-1")).toBe(false);
// the download is a blob and is never kept anyway; its route is not touched
expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
});
it("keeps caching the unrelated routes it cached before", () => {
for (const path of [
"/api/v1/tasks",
"/api/v1/colonies/c-1",
"/api/v1/products",
"/api/v1/label-templates",
"/api/v1/label-templates/t-1",
"/api/v1/compliances",
"/api/v1/veterinary-medicines-overview",
"/api/v1/apiaries/a-1/weather",
]) {
expect(cachesApi(path)).toBe(true);
}
});
it("keeps a cacheable path whose answer carries the real header Cache-Control: no-store", () => {
// The backend marks every answer no-store; the offline packs depend on keeping them.
const answer = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: "no-store" };
for (const path of ["/api/v1/colonies/c-1", "/api/v1/tasks", "/api/v1/apiaries/a-1/weather"]) {
expect(cachesApi(path) && routes.isKeepableApiAnswer(answer)).toBe(true);
}
});
it("does not keep the never-cached paths, whatever the header says", () => {
const answer = { ok: true, contentType: "application/json", cacheControl: "no-store" };
expect(routes.isKeepableApiAnswer(answer)).toBe(true);
for (const path of [
"/api/v1/compliance/readiness",
"/api/v1/authority-notices/n-1",
"/api/v1/veterinary-medicine-lots/l-1",
"/api/v1/apiaries/a-1/restrictions",
"/api/v1/documents/d-1",
"/api/v1/documents?entityType=MEDICINE_LOT&entityId=l-1",
"/api/v1/apiaries/a-1/offline-pack",
"/api/v1/colonies",
]) {
expect(cachesApi(path)).toBe(false);
}
});
it("keeps only successful JSON answers", () => {
const json = { ok: true, contentType: "application/json; charset=utf-8", cacheControl: null };
expect(routes.isKeepableApiAnswer(json)).toBe(true);
expect(routes.isKeepableApiAnswer({ ...json, ok: false })).toBe(false);
expect(routes.isKeepableApiAnswer({ ...json, contentType: "application/pdf" })).toBe(false);
expect(routes.isKeepableApiAnswer({ ...json, contentType: null })).toBe(false);
});
it("caches only answers of the configured backend", () => {
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
@@ -13,6 +13,8 @@ export interface Product {
packagingSize: number | null;
/** Months the honey stays good; the bottling form derives its best-before day from it. */
shelfLifeMonths: number | null;
/** Name on the label, e.g. „Blütenhonig“. Up to 200 characters. */
legalName: string | null;
weight: number | null;
salesUnit: string | null;
status: ProductStatus;
@@ -30,6 +32,7 @@ export interface CreateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -44,6 +47,7 @@ export interface UpdateProductInput {
/** Grams per jar, like the bottling jar size. */
packagingSize?: number | null;
shelfLifeMonths?: number | null;
legalName?: string | null;
weight?: number | null;
salesUnit?: string | null;
kind?: ProductKind;
@@ -6,6 +6,10 @@ export interface SiteSettings {
websiteUrl: string | null;
holderName: string | null;
holderAddress: string | null;
/** Name on the label: the person who puts the honey on the market. Up to 200 characters. */
foodBusinessName: string | null;
/** Address on the label. Up to 300 characters. Never on public pages. */
foodBusinessAddress: string | null;
originCountry: string;
updatedAt: string;
}
@@ -17,5 +21,8 @@ export interface UpdateSiteSettingsInput {
websiteUrl?: string;
holderName?: string;
holderAddress?: string;
/** `null` clears the value. */
foodBusinessName?: string | null;
foodBusinessAddress?: string | null;
originCountry?: string;
}
@@ -73,4 +73,13 @@ describe("DOMAIN_EVENT_LABELS completeness", () => {
expect(types.length).toBeGreaterThan(20);
expect(types.filter((type) => !(type in DOMAIN_EVENT_LABELS))).toEqual([]);
});
it("DOMAIN_EVENT_LABELS names ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached", () => {
expect(DOMAIN_EVENT_LABELS).toMatchObject({
ColonyCreated: "Volk angelegt",
ColonyMoved: "Volk umgesetzt",
ApiaryChanged: "Stand geändert",
TskReferenceDayReached: "TSK-Stichtag erreicht",
});
});
});