fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6c390efe7b
commit
e712c5d4d1
64 files changed
+1497
-323
No files matched your search
@@ -347,6 +347,44 @@ describe('AuthorityNoticesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sent day in the future', () => {
|
||||||
|
const FUTURE = '2999-01-01';
|
||||||
|
const MESSAGE = 'Das Sendedatum darf nicht in der Zukunft liegen.';
|
||||||
|
|
||||||
|
it('create rejects it', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({ kind: 'REGISTRATION', sentOn: FUTURE }, 'u1'),
|
||||||
|
).rejects.toMatchObject({ message: MESSAGE });
|
||||||
|
expect(tx.authorityNotice.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mark-sent rejects it', async () => {
|
||||||
|
await expect(
|
||||||
|
service.markSent('n1', { sentOn: FUTURE }, 'u1'),
|
||||||
|
).rejects.toMatchObject({ message: MESSAGE });
|
||||||
|
expect(tx.authorityNotice.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects it', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('n1', { sentOn: FUTURE }, 'u1'),
|
||||||
|
).rejects.toMatchObject({ message: MESSAGE });
|
||||||
|
expect(tx.authorityNotice.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts today (Berlin day) and clearing with null stays a different error', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||||
|
try {
|
||||||
|
await service.markSent('n1', { sentOn: '2027-03-02' }, 'u1');
|
||||||
|
await expect(
|
||||||
|
service.markSent('n1', { sentOn: '2027-03-03' }, 'u1'),
|
||||||
|
).rejects.toMatchObject({ message: MESSAGE });
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
|
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
|
||||||
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||||
try {
|
try {
|
||||||
@@ -366,7 +404,7 @@ describe('AuthorityNoticesService', () => {
|
|||||||
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
|
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
|
||||||
await service.markSent(
|
await service.markSent(
|
||||||
'n1',
|
'n1',
|
||||||
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
|
{ sentOn: '2026-02-02', sentVia: 'E-Mail' },
|
||||||
'u1',
|
'u1',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -374,7 +412,7 @@ describe('AuthorityNoticesService', () => {
|
|||||||
where: { id: 'n1' },
|
where: { id: 'n1' },
|
||||||
data: expect.objectContaining({
|
data: expect.objectContaining({
|
||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
sentOn: new Date('2027-02-02T00:00:00.000Z'),
|
sentOn: new Date('2026-02-02T00:00:00.000Z'),
|
||||||
sentVia: 'E-Mail',
|
sentVia: 'E-Mail',
|
||||||
openKey: null,
|
openKey: null,
|
||||||
}) as unknown,
|
}) as unknown,
|
||||||
@@ -519,7 +557,7 @@ describe('AuthorityNoticesService', () => {
|
|||||||
|
|
||||||
it('a draft has no sent day', async () => {
|
it('a draft has no sent day', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
|
service.update('n1', { sentOn: '2026-01-01' }, 'u1'),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -528,7 +566,7 @@ describe('AuthorityNoticesService', () => {
|
|||||||
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
|
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
|
await service.update('n1', { sentOn: '2026-01-02' }, 'u1');
|
||||||
|
|
||||||
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||||
'ComplianceSettings',
|
'ComplianceSettings',
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A notice cannot have been sent tomorrow: sentOn must not lie after the Berlin day. */
|
||||||
|
function futureSentOnError(
|
||||||
|
sentOn: string | null | undefined,
|
||||||
|
): BadRequestException | undefined {
|
||||||
|
return sentOn && sentOn > berlinToday()
|
||||||
|
? new BadRequestException(
|
||||||
|
'Das Sendedatum darf nicht in der Zukunft liegen.',
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthorityNoticesService {
|
export class AuthorityNoticesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -178,6 +189,10 @@ export class AuthorityNoticesService {
|
|||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<AuthorityNotice> {
|
): Promise<AuthorityNotice> {
|
||||||
assertYearFits(dto.kind, dto.year);
|
assertYearFits(dto.kind, dto.year);
|
||||||
|
const futureError = futureSentOnError(dto.sentOn);
|
||||||
|
if (futureError) {
|
||||||
|
throw futureError;
|
||||||
|
}
|
||||||
const input = {
|
const input = {
|
||||||
kind: dto.kind,
|
kind: dto.kind,
|
||||||
year: dto.year,
|
year: dto.year,
|
||||||
@@ -234,6 +249,10 @@ export class AuthorityNoticesService {
|
|||||||
dto: MarkSentDto,
|
dto: MarkSentDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<AuthorityNotice> {
|
): Promise<AuthorityNotice> {
|
||||||
|
const futureError = futureSentOnError(dto.sentOn);
|
||||||
|
if (futureError) {
|
||||||
|
return Promise.reject(futureError);
|
||||||
|
}
|
||||||
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
||||||
if (before.status !== 'DRAFT') {
|
if (before.status !== 'DRAFT') {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
@@ -290,6 +309,10 @@ export class AuthorityNoticesService {
|
|||||||
): Promise<AuthorityNotice> {
|
): Promise<AuthorityNotice> {
|
||||||
// A new sent day can change which notice is the baseline: the mutex
|
// A new sent day can change which notice is the baseline: the mutex
|
||||||
// comes first, in lock order, whenever the sent day is touched.
|
// comes first, in lock order, whenever the sent day is touched.
|
||||||
|
const futureError = futureSentOnError(dto.sentOn);
|
||||||
|
if (futureError) {
|
||||||
|
return Promise.reject(futureError);
|
||||||
|
}
|
||||||
const mutex = dto.sentOn !== undefined;
|
const mutex = dto.sentOn !== undefined;
|
||||||
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
||||||
if (before.status === 'CANCELLED') {
|
if (before.status === 'CANCELLED') {
|
||||||
|
|||||||
@@ -228,6 +228,42 @@ describe('Authority notices (e2e)', () => {
|
|||||||
expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after));
|
expect(body.totalColonies).toBeLessThanOrEqual(Math.max(before, after));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a sent day in the future on create, mark-sent and patch', async () => {
|
||||||
|
const message = 'Das Sendedatum darf nicht in der Zukunft liegen.';
|
||||||
|
const future = '2999-01-01';
|
||||||
|
const create = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'REGISTRATION', sentOn: future })
|
||||||
|
.expect(400);
|
||||||
|
expect((create.body as { message: string }).message).toBe(message);
|
||||||
|
|
||||||
|
const draft = await request(server())
|
||||||
|
.post('/api/v1/authority-notices')
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ kind: 'TSK_REPORT', year: 2031 })
|
||||||
|
.expect(201);
|
||||||
|
const url = `/api/v1/authority-notices/${(draft.body as NoticeBody).id}`;
|
||||||
|
const markSent = await request(server())
|
||||||
|
.post(`${url}/mark-sent`)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ sentOn: future })
|
||||||
|
.expect(400);
|
||||||
|
expect((markSent.body as { message: string }).message).toBe(message);
|
||||||
|
const patch = await request(server())
|
||||||
|
.patch(url)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.send({ sentOn: future })
|
||||||
|
.expect(400);
|
||||||
|
expect((patch.body as { message: string }).message).toBe(message);
|
||||||
|
|
||||||
|
const unchanged = await request(server())
|
||||||
|
.get(url)
|
||||||
|
.set(as(beekeeperToken))
|
||||||
|
.expect(200);
|
||||||
|
expect(unchanged.body).toMatchObject({ status: 'DRAFT', sentOn: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('walks a draft through mark-sent, reopen and cancel', async () => {
|
it('walks a draft through mark-sent, reopen and cancel', async () => {
|
||||||
const created = await request(server())
|
const created = await request(server())
|
||||||
.post('/api/v1/authority-notices')
|
.post('/api/v1/authority-notices')
|
||||||
@@ -240,7 +276,7 @@ describe('Authority notices (e2e)', () => {
|
|||||||
await request(server())
|
await request(server())
|
||||||
.post(`${url}/mark-sent`)
|
.post(`${url}/mark-sent`)
|
||||||
.set(as(beekeeperToken))
|
.set(as(beekeeperToken))
|
||||||
.send({ sentOn: '2027-02-01', sentVia: 'E-Mail' })
|
.send({ sentOn: '2026-02-01', sentVia: 'E-Mail' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
await request(server())
|
await request(server())
|
||||||
.post(`${url}/recount`)
|
.post(`${url}/recount`)
|
||||||
@@ -291,7 +327,7 @@ describe('Authority notices (e2e)', () => {
|
|||||||
const sent = await request(server())
|
const sent = await request(server())
|
||||||
.post('/api/v1/authority-notices')
|
.post('/api/v1/authority-notices')
|
||||||
.set(as(beekeeperToken))
|
.set(as(beekeeperToken))
|
||||||
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
const draft = await request(server())
|
const draft = await request(server())
|
||||||
.post('/api/v1/authority-notices')
|
.post('/api/v1/authority-notices')
|
||||||
@@ -319,7 +355,7 @@ describe('Authority notices (e2e)', () => {
|
|||||||
await request(server())
|
await request(server())
|
||||||
.post('/api/v1/authority-notices')
|
.post('/api/v1/authority-notices')
|
||||||
.set(as(beekeeperToken))
|
.set(as(beekeeperToken))
|
||||||
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2027-02-01' })
|
.send({ kind: 'TSK_REPORT', year: 2027, sentOn: '2026-02-01' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
/\/api\/v1\/veterinary-medicine-lots(\/|$)/i,
|
/\/api\/v1\/veterinary-medicine-lots(\/|$)/i,
|
||||||
/\/api\/v1\/label-templates\/check(\/|$)/i,
|
/\/api\/v1\/label-templates\/check(\/|$)/i,
|
||||||
/\/api\/v1\/label-templates\/[^/]+\/(print|check)(\/|$)/i,
|
/\/api\/v1\/label-templates\/[^/]+\/(print|check)(\/|$)/i,
|
||||||
|
// the master data (holder and food business name and address): personal
|
||||||
|
// address data, read only with network
|
||||||
|
/\/api\/v1\/site-settings(\/|$)/i,
|
||||||
// One single document (metadata): its kind is not in the path, and it may
|
// One single document (metadata): its kind is not in the path, and it may
|
||||||
// belong to a pack, a notice or a restriction. The lists (with the kind
|
// belong to a pack, a notice or a restriction. The lists (with the kind
|
||||||
// in the query, see below) and the photo downloads use other routes.
|
// in the query, see below) and the photo downloads use other routes.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
|
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex
|
||||||
// characters; sw-routes.spec.ts checks it). A changed value changes this
|
// characters; sw-routes.spec.ts checks it). A changed value changes this
|
||||||
// file, so every browser - Safari too - installs the worker with new rules.
|
// file, so every browser - Safari too - installs the worker with new rules.
|
||||||
const SW_ROUTES_VERSION = "0681ff4b095d";
|
const SW_ROUTES_VERSION = "17503174a2a9";
|
||||||
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
|
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -200,6 +200,21 @@ describe("PrintLabelsCard", () => {
|
|||||||
expect(vi.mocked(downloadBlob).mock.calls[1][1]).not.toMatch(/muster/i);
|
expect(vi.mocked(downloadBlob).mock.calls[1][1]).not.toMatch(/muster/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("announces politely that the proof and the real PDF were downloaded", async () => {
|
||||||
|
renderCard();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Probedruck (MUSTER)" }));
|
||||||
|
|
||||||
|
const status = await screen.findByText(/Probedruck \(MUSTER\) heruntergeladen/);
|
||||||
|
expect(status.closest('[role="status"]')).toHaveAttribute("aria-live", "polite");
|
||||||
|
|
||||||
|
const main = screen.getByRole("button", { name: "Etiketten als PDF" });
|
||||||
|
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
|
||||||
|
await userEvent.click(main);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Etiketten-PDF heruntergeladen.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Probedruck \(MUSTER\) heruntergeladen/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("sends no second request while the first one is pending", async () => {
|
it("sends no second request while the first one is pending", async () => {
|
||||||
let finish: (blob: Blob) => void = () => {};
|
let finish: (blob: Blob) => void = () => {};
|
||||||
vi.mocked(printLabels).mockReturnValue(new Promise<Blob>((resolve) => (finish = resolve)));
|
vi.mocked(printLabels).mockReturnValue(new Promise<Blob>((resolve) => (finish = resolve)));
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { RecordSelect } from "@/components/form/record-select";
|
import { RecordSelect } from "@/components/form/record-select";
|
||||||
|
|
||||||
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
|
const PRINT_FALLBACK = "Etiketten konnten nicht erstellt werden.";
|
||||||
|
const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
||||||
|
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
||||||
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
||||||
const LINK_CLASS =
|
const LINK_CLASS =
|
||||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
|
||||||
@@ -140,6 +142,7 @@ export function PrintLabelsCard({
|
|||||||
const [startPosition, setStartPosition] = useState(1);
|
const [startPosition, setStartPosition] = useState(1);
|
||||||
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
|
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
|
||||||
const [printError, setPrintError] = useState<string | null>(null);
|
const [printError, setPrintError] = useState<string | null>(null);
|
||||||
|
const [printDone, setPrintDone] = useState<string | null>(null);
|
||||||
const gapsRef = useRef<HTMLElement>(null);
|
const gapsRef = useRef<HTMLElement>(null);
|
||||||
const focusGapsNext = useRef(false);
|
const focusGapsNext = useRef(false);
|
||||||
|
|
||||||
@@ -183,14 +186,17 @@ export function PrintLabelsCard({
|
|||||||
}, [serverGaps]);
|
}, [serverGaps]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (proof: boolean) =>
|
mutationFn: (proof: boolean) => {
|
||||||
printLabels(effectiveTemplateId, bottlingBatchId, {
|
setPrintDone(null);
|
||||||
|
return printLabels(effectiveTemplateId, bottlingBatchId, {
|
||||||
startPosition: startPosition > 1 ? startPosition : undefined,
|
startPosition: startPosition > 1 ? startPosition : undefined,
|
||||||
proof,
|
proof,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: (blob, proof) => {
|
onSuccess: (blob, proof) => {
|
||||||
setServerGaps(null);
|
setServerGaps(null);
|
||||||
setPrintError(null);
|
setPrintError(null);
|
||||||
|
setPrintDone(proof ? PROOF_DONE : PRINT_DONE);
|
||||||
downloadBlob(blob, proof ? "etiketten-muster.pdf" : "etiketten.pdf");
|
downloadBlob(blob, proof ? "etiketten-muster.pdf" : "etiketten.pdf");
|
||||||
},
|
},
|
||||||
onError: (cause) => {
|
onError: (cause) => {
|
||||||
@@ -198,10 +204,12 @@ export function PrintLabelsCard({
|
|||||||
if (missing) {
|
if (missing) {
|
||||||
// The server knows better than the last check: show its list and go there.
|
// The server knows better than the last check: show its list and go there.
|
||||||
setPrintError(null);
|
setPrintError(null);
|
||||||
|
setPrintDone(null);
|
||||||
focusGapsNext.current = true;
|
focusGapsNext.current = true;
|
||||||
setServerGaps({ templateId: effectiveTemplateId, at: Date.now(), gaps: missing.gaps });
|
setServerGaps({ templateId: effectiveTemplateId, at: Date.now(), gaps: missing.gaps });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPrintDone(null);
|
||||||
setPrintError(labelCheckErrorText(cause, PRINT_FALLBACK));
|
setPrintError(labelCheckErrorText(cause, PRINT_FALLBACK));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -355,6 +363,9 @@ export function PrintLabelsCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<OnlineOnlyHint id={hintId} />
|
<OnlineOnlyHint id={hintId} />
|
||||||
|
<div role="status" aria-live="polite" className="empty:hidden">
|
||||||
|
{printDone ? <p className="text-sm font-medium">{printDone}</p> : null}
|
||||||
|
</div>
|
||||||
{printError ? (
|
{printError ? (
|
||||||
<p role="alert" className="text-sm font-medium text-destructive">
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
{printError}
|
{printError}
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ describe("ColonyActions – Sperrbezirk beim Umsetzen", () => {
|
|||||||
|
|
||||||
async function chooseTarget(dialog: HTMLElement, name = "Hausgarten") {
|
async function chooseTarget(dialog: HTMLElement, name = "Hausgarten") {
|
||||||
await userEvent.click(within(dialog).getByRole("combobox"));
|
await userEvent.click(within(dialog).getByRole("combobox"));
|
||||||
await userEvent.click(await screen.findByRole("option", { name }));
|
await userEvent.click(await screen.findByRole("option", { name: new RegExp(name) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = (dialog: HTMLElement) => within(dialog).getByRole("button", { name: "Umsetzen" });
|
const submit = (dialog: HTMLElement) => within(dialog).getByRole("button", { name: "Umsetzen" });
|
||||||
@@ -309,6 +309,14 @@ describe("ColonyActions – Sperrbezirk beim Umsetzen", () => {
|
|||||||
expect(moveColony).toHaveBeenCalledWith("colony-1", { apiaryId: "apiary-2" });
|
expect(moveColony).toHaveBeenCalledWith("colony-1", { apiaryId: "apiary-2" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks apiaries in a restricted zone in the option list with a symbol and the word Sperrbezirk", async () => {
|
||||||
|
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
|
||||||
|
const dialog = await openMove();
|
||||||
|
await userEvent.click(within(dialog).getByRole("combobox"));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("option", { name: "⚠ Hausgarten (Sperrbezirk)" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("the move dialog shows the red notice and disables Umsetzen while the target apiary is restricted", async () => {
|
it("the move dialog shows the red notice and disables Umsetzen while the target apiary is restricted", async () => {
|
||||||
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
|
vi.mocked(fetchActiveRestrictions).mockResolvedValue([restriction("apiary-2", "Hausgarten")]);
|
||||||
const dialog = await openMove();
|
const dialog = await openMove();
|
||||||
@@ -468,7 +476,7 @@ describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
|
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await userEvent.click(within(dialog).getByRole("combobox"));
|
await userEvent.click(within(dialog).getByRole("combobox"));
|
||||||
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
|
await userEvent.click(await screen.findByRole("option", { name: /Hausgarten/ }));
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: "Umsetzen" }));
|
await userEvent.click(within(dialog).getByRole("button", { name: "Umsetzen" }));
|
||||||
return dialog;
|
return dialog;
|
||||||
}
|
}
|
||||||
@@ -502,7 +510,7 @@ describe("ColonyActions – Sperrbezirk, Fehlerfälle", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
|
await userEvent.click(screen.getByRole("button", { name: "Umsetzen" }));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
await userEvent.click(within(dialog).getByRole("combobox"));
|
await userEvent.click(within(dialog).getByRole("combobox"));
|
||||||
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
|
await userEvent.click(await screen.findByRole("option", { name: /Hausgarten/ }));
|
||||||
await within(dialog).findByText(/liegt im Sperrbezirk/);
|
await within(dialog).findByText(/liegt im Sperrbezirk/);
|
||||||
const button = within(dialog).getByRole("button", { name: "Umsetzen" });
|
const button = within(dialog).getByRole("button", { name: "Umsetzen" });
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { EnumSelect } from "@/components/form/enum-select";
|
|||||||
import { RecordSelect } from "@/components/form/record-select";
|
import { RecordSelect } from "@/components/form/record-select";
|
||||||
import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard";
|
import { RestrictionGuardPanel } from "@/components/compliance/restriction-guard";
|
||||||
import { BLOCKED_TEXT, MoveHint } from "@/components/compliance/restriction-notice";
|
import { BLOCKED_TEXT, MoveHint } from "@/components/compliance/restriction-notice";
|
||||||
|
import { useActiveRestrictions } from "@/lib/compliance/use-active-restrictions";
|
||||||
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
|
import { useFocusWhen, useRestrictionGuard } from "@/lib/compliance/use-restriction-guard";
|
||||||
|
|
||||||
function invalidateColony(
|
function invalidateColony(
|
||||||
@@ -111,6 +112,9 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
|||||||
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId);
|
const targets = (apiaries ?? []).filter((apiary) => apiary.id !== colony.apiaryId);
|
||||||
// Source and target: moving out of or into a restricted zone is blocked.
|
// Source and target: moving out of or into a restricted zone is blocked.
|
||||||
const guard = useRestrictionGuard(apiaryId ? [colony.apiaryId, apiaryId] : [colony.apiaryId]);
|
const guard = useRestrictionGuard(apiaryId ? [colony.apiaryId, apiaryId] : [colony.apiaryId]);
|
||||||
|
// Marked up front, so nobody finds out only after choosing.
|
||||||
|
const { restrictions } = useActiveRestrictions();
|
||||||
|
const restrictedIds = new Set(restrictions.map((restriction) => restriction.apiaryId));
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }),
|
mutationFn: () => moveColony(colony.id, { apiaryId, ...guard.overrideFields() }),
|
||||||
@@ -171,7 +175,7 @@ function MoveDialog({ colony }: { colony: Colony }) {
|
|||||||
value={apiaryId}
|
value={apiaryId}
|
||||||
onChange={setApiaryId}
|
onChange={setApiaryId}
|
||||||
items={apiaries ? targets : undefined}
|
items={apiaries ? targets : undefined}
|
||||||
getLabel={(apiary) => apiary.name}
|
getLabel={(apiary) => (restrictedIds.has(apiary.id) ? `⚠ ${apiary.name} (Sperrbezirk)` : apiary.name)}
|
||||||
placeholder="Bienenstand wählen"
|
placeholder="Bienenstand wählen"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -277,5 +277,45 @@ describe("TreatmentEditDialog", () => {
|
|||||||
expect(screen.getAllByText("aus Katalog").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("aus Katalog").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
|
expect(screen.getByRole("combobox", { name: /Packung/ })).toHaveTextContent("Ohne Packung");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears the old dose and says so when the new medicine has another unit", async () => {
|
||||||
|
await switchToApivar();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Dosis je Volk/)).toHaveValue("");
|
||||||
|
expect(await screen.findByText("Dosis war in ml – bitte neu eintragen.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dose when the new medicine has the same unit, and typing removes the note", async () => {
|
||||||
|
const THYMOVAR = medicine({ id: "m-thy", name: "Thymovar", unit: "ML", dosePerColony: null });
|
||||||
|
vi.mocked(fetchMedicines).mockResolvedValue([OXUVAR, APIVAR, THYMOVAR]);
|
||||||
|
await openDialog(FROM_PACK);
|
||||||
|
await screen.findByText(/^aus Packung/);
|
||||||
|
await userEvent.click(screen.getByRole("combobox", { name: /Aus dem Katalog/ }));
|
||||||
|
await userEvent.click(await screen.findByRole("option", { name: "Thymovar" }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Dosis je Volk/)).toHaveValue("30");
|
||||||
|
expect(screen.queryByText(/bitte neu eintragen/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the unit note once a new dose is typed", async () => {
|
||||||
|
await switchToApivar();
|
||||||
|
await screen.findByText(/bitte neu eintragen/);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/Dosis je Volk/), "2");
|
||||||
|
|
||||||
|
expect(screen.queryByText(/bitte neu eintragen/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a dose with four decimals on save", async () => {
|
||||||
|
await openDialog(FROM_PACK);
|
||||||
|
await screen.findByText(/^aus Packung/);
|
||||||
|
const dose = screen.getByLabelText(/Dosis je Volk/);
|
||||||
|
await userEvent.clear(dose);
|
||||||
|
await userEvent.type(dose, "1,2345");
|
||||||
|
|
||||||
|
expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Speichern/ }));
|
||||||
|
expect(updateTreatment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13,6 +13,7 @@ import { doseError, doseText } from "@/lib/treatments/dose";
|
|||||||
import { medicinePatch } from "@/lib/treatments/edit-patch";
|
import { medicinePatch } from "@/lib/treatments/edit-patch";
|
||||||
import { lotSourceLine } from "@/lib/treatments/lot-text";
|
import { lotSourceLine } from "@/lib/treatments/lot-text";
|
||||||
import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice";
|
import { useMedicineChoice } from "@/lib/treatments/use-medicine-choice";
|
||||||
|
import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
|
||||||
import type { MedicineView } from "@/lib/medicines/types";
|
import type { MedicineView } from "@/lib/medicines/types";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -117,6 +118,8 @@ export function TreatmentEditDialog({
|
|||||||
);
|
);
|
||||||
// Set when a switch left a withdrawal period of the previous medicine in place
|
// Set when a switch left a withdrawal period of the previous medicine in place
|
||||||
// (the value is the new catalogue value, or null when the catalogue has none).
|
// (the value is the new catalogue value, or null when the catalogue has none).
|
||||||
|
// Set when a switch to a medicine with another unit cleared the dose.
|
||||||
|
const [doseNotice, setDoseNotice] = useState<string | undefined>(undefined);
|
||||||
const [staleWithdrawal, setStaleWithdrawal] = useState<string | null | undefined>(undefined);
|
const [staleWithdrawal, setStaleWithdrawal] = useState<string | null | undefined>(undefined);
|
||||||
const [catalogueReady, setCatalogueReady] = useState(false);
|
const [catalogueReady, setCatalogueReady] = useState(false);
|
||||||
if (open && !catalogueReady) {
|
if (open && !catalogueReady) {
|
||||||
@@ -139,6 +142,17 @@ export function TreatmentEditDialog({
|
|||||||
setProduct(medicine ? medicine.name : medicineId ? "" : product);
|
setProduct(medicine ? medicine.name : medicineId ? "" : product);
|
||||||
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
|
setFieldErrors((current) => ({ ...current, product: undefined, doseQuantity: undefined }));
|
||||||
catalogue.apply(medicine);
|
catalogue.apply(medicine);
|
||||||
|
// A number is only meaningful in its unit: never read the old dose in the new one.
|
||||||
|
// (After `apply`, whose snapshot would otherwise write the old dose back. A dose that
|
||||||
|
// the catalogue filled was replaced by `apply` itself.)
|
||||||
|
const oldUnit = choice.medicine?.unit;
|
||||||
|
const unitChanged = hadMedicine && medicine !== null && oldUnit !== undefined && oldUnit !== medicine.unit;
|
||||||
|
if (unitChanged && doseQuantity.trim() !== "" && !catalogue.filled.doseQuantity) {
|
||||||
|
setDoseQuantity("");
|
||||||
|
setDoseNotice(`Dosis war in ${MEDICINE_UNIT_LABELS[oldUnit]} – bitte neu eintragen.`);
|
||||||
|
} else {
|
||||||
|
setDoseNotice(undefined);
|
||||||
|
}
|
||||||
const isStale = hadMedicine && oldWithdrawal !== "" && oldWithdrawal !== known;
|
const isStale = hadMedicine && oldWithdrawal !== "" && oldWithdrawal !== known;
|
||||||
setStaleWithdrawal(isStale ? (known ?? null) : undefined);
|
setStaleWithdrawal(isStale ? (known ?? null) : undefined);
|
||||||
}
|
}
|
||||||
@@ -250,6 +264,7 @@ export function TreatmentEditDialog({
|
|||||||
doseQuantity={doseQuantity}
|
doseQuantity={doseQuantity}
|
||||||
productError={fieldErrors.product}
|
productError={fieldErrors.product}
|
||||||
doseError={fieldErrors.doseQuantity}
|
doseError={fieldErrors.doseQuantity}
|
||||||
|
doseNotice={doseNotice}
|
||||||
filled={catalogue.filled}
|
filled={catalogue.filled}
|
||||||
onProductChange={(next) => {
|
onProductChange={(next) => {
|
||||||
setProduct(next);
|
setProduct(next);
|
||||||
@@ -259,6 +274,7 @@ export function TreatmentEditDialog({
|
|||||||
onLotChange={chooseLot}
|
onLotChange={chooseLot}
|
||||||
onDoseChange={(next) => {
|
onDoseChange={(next) => {
|
||||||
catalogue.edited("doseQuantity");
|
catalogue.edited("doseQuantity");
|
||||||
|
setDoseNotice(undefined);
|
||||||
setDoseQuantity(next);
|
setDoseQuantity(next);
|
||||||
setFieldErrors((current) => ({ ...current, doseQuantity: undefined }));
|
setFieldErrors((current) => ({ ...current, doseQuantity: undefined }));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ function AcknowledgeDialog({ item, disabled, describedBy, onDone }: AcknowledgeD
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Punkt quittieren</DialogTitle>
|
<DialogTitle>Punkt quittieren</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{item.title}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen.
|
{item.title.replace(/[.!?]+$/, "")}. Schreib kurz auf, warum das so in Ordnung ist. Ändert sich der Punkt, ist er wieder offen.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Field label="Notiz" required hint={`Mindestens ${MIN_NOTE_LENGTH} Zeichen.`} error={noteError}>
|
<Field label="Notiz" required hint={`Mindestens ${MIN_NOTE_LENGTH} Zeichen.`} error={noteError}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useRef, useState } from "react";
|
||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
@@ -28,6 +28,8 @@ function todayInput(): string {
|
|||||||
return berlinCalendarDay(new Date().toISOString());
|
return berlinCalendarDay(new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FUTURE_SENT_ON_ERROR = "Das Sendedatum darf nicht in der Zukunft liegen.";
|
||||||
|
|
||||||
/** Id of the trigger, so the page can move the focus here once it becomes the main action. */
|
/** Id of the trigger, so the page can move the focus here once it becomes the main action. */
|
||||||
export const MARK_SENT_TRIGGER_ID = "notice-mark-sent";
|
export const MARK_SENT_TRIGGER_ID = "notice-mark-sent";
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
|
|||||||
const [sentVia, setSentVia] = useState("");
|
const [sentVia, setSentVia] = useState("");
|
||||||
const [reference, setReference] = useState("");
|
const [reference, setReference] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dateError, setDateError] = useState<string | null>(null);
|
||||||
|
const sentOnRef = useRef<HTMLInputElement>(null);
|
||||||
|
const today = todayInput();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -90,6 +95,12 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
|
|||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (sentOn > today) {
|
||||||
|
setDateError(FUTURE_SENT_ON_ERROR);
|
||||||
|
sentOnRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDateError(null);
|
||||||
if (isOffline) return;
|
if (isOffline) return;
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
@@ -98,7 +109,21 @@ function MarkSentForm({ noticeId, onDone }: { noticeId: string; onDone: (notice:
|
|||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="sent-on">Gesendet am</Label>
|
<Label htmlFor="sent-on">Gesendet am</Label>
|
||||||
<Input id="sent-on" type="date" value={sentOn} onChange={(event) => setSentOn(event.target.value)} />
|
<Input
|
||||||
|
id="sent-on"
|
||||||
|
ref={sentOnRef}
|
||||||
|
type="date"
|
||||||
|
value={sentOn}
|
||||||
|
max={today}
|
||||||
|
aria-invalid={dateError ? true : undefined}
|
||||||
|
aria-describedby={dateError ? "sent-on-error" : undefined}
|
||||||
|
onChange={(event) => setSentOn(event.target.value)}
|
||||||
|
/>
|
||||||
|
{dateError ? (
|
||||||
|
<p id="sent-on-error" role="alert" className="text-sm text-destructive">
|
||||||
|
{dateError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="sent-via">Gesendet über</Label>
|
<Label htmlFor="sent-via">Gesendet über</Label>
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ const STEP_WORD: Record<StepState, string> = {
|
|||||||
upcoming: "Danach",
|
upcoming: "Danach",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Id of the heading of step 2, so the page can move the focus here after the PDF download. */
|
||||||
|
export const SEND_STEP_ID = "notice-step-send";
|
||||||
|
|
||||||
interface StepProps {
|
interface StepProps {
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Makes the title focusable (programmatic focus only). */
|
||||||
|
titleId?: string;
|
||||||
state: StepState;
|
state: StepState;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step({ number, title, state, children }: StepProps) {
|
function Step({ number, title, titleId, state, children }: StepProps) {
|
||||||
const Icon = STEP_ICON[state];
|
const Icon = STEP_ICON[state];
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@@ -31,7 +36,11 @@ function Step({ number, title, state, children }: StepProps) {
|
|||||||
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
|
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10"
|
||||||
>
|
>
|
||||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-heading text-base font-semibold">{`${number}. ${title}`}</span>
|
<span
|
||||||
|
id={titleId}
|
||||||
|
tabIndex={titleId ? -1 : undefined}
|
||||||
|
className="font-heading text-base font-semibold outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>{`${number}. ${title}`}</span>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||||
<Icon className="size-4" aria-hidden />
|
<Icon className="size-4" aria-hidden />
|
||||||
{STEP_WORD[state]}
|
{STEP_WORD[state]}
|
||||||
@@ -66,14 +75,14 @@ interface NoticeStepsProps {
|
|||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The way through a draft: open the PDF, send it, confirm here. Symbol plus word per step. */
|
/** The way through a draft: download the PDF, send it, confirm here. Symbol plus word per step. */
|
||||||
export function NoticeSteps({ pdfOpened, settings, canManage }: NoticeStepsProps) {
|
export function NoticeSteps({ pdfOpened, settings, canManage }: NoticeStepsProps) {
|
||||||
return (
|
return (
|
||||||
<ol aria-label="Schritte" className="flex flex-col gap-2">
|
<ol aria-label="Schritte" className="flex flex-col gap-2">
|
||||||
<Step number={1} title="PDF öffnen" state={pdfOpened ? "done" : "current"}>
|
<Step number={1} title="PDF herunterladen" state={pdfOpened ? "done" : "current"}>
|
||||||
Prüf die Angaben im PDF.
|
Prüf die Angaben im PDF.
|
||||||
</Step>
|
</Step>
|
||||||
<Step number={2} title="Ans Amt schicken" state={pdfOpened ? "current" : "upcoming"}>
|
<Step number={2} title="Ans Amt schicken" titleId={SEND_STEP_ID} state={pdfOpened ? "current" : "upcoming"}>
|
||||||
<OfficeText settings={settings} canManage={canManage} />
|
<OfficeText settings={settings} canManage={canManage} />
|
||||||
</Step>
|
</Step>
|
||||||
<Step number={3} title="Hier bestätigen" state="upcoming">
|
<Step number={3} title="Hier bestätigen" state="upcoming">
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ describe("NoticeDetailPage", () => {
|
|||||||
vi.mocked(openNoticePdf).mockResolvedValue(undefined);
|
vi.mocked(openNoticePdf).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows three steps: 1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step", async () => {
|
it("shows three steps: 1. PDF herunterladen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step", async () => {
|
||||||
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await screen.findByRole("list", { name: "Schritte" });
|
await screen.findByRole("list", { name: "Schritte" });
|
||||||
let items = steps();
|
let items = steps();
|
||||||
expect(items).toHaveLength(3);
|
expect(items).toHaveLength(3);
|
||||||
expect(items[0]).toHaveTextContent("1. PDF öffnen");
|
expect(items[0]).toHaveTextContent("1. PDF herunterladen");
|
||||||
expect(items[1]).toHaveTextContent("2. Ans Amt schicken");
|
expect(items[1]).toHaveTextContent("2. Ans Amt schicken");
|
||||||
expect(items[2]).toHaveTextContent("3. Hier bestätigen");
|
expect(items[2]).toHaveTextContent("3. Hier bestätigen");
|
||||||
// Symbol plus word per step, never colour alone.
|
// Symbol plus word per step, never colour alone.
|
||||||
@@ -72,15 +72,16 @@ describe("NoticeDetailPage", () => {
|
|||||||
|
|
||||||
// Step 1 is current: the primary button opens the PDF, marking as sent is only secondary.
|
// Step 1 is current: the primary button opens the PDF, marking as sent is only secondary.
|
||||||
expect(screen.getByRole("button", { name: "Als gesendet markieren" })).not.toHaveClass("bg-primary");
|
expect(screen.getByRole("button", { name: "Als gesendet markieren" })).not.toHaveClass("bg-primary");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
|
await userEvent.click(screen.getByRole("button", { name: "PDF herunterladen" }));
|
||||||
expect(openNoticePdf).toHaveBeenCalledWith("n1");
|
expect(openNoticePdf).toHaveBeenCalledWith("n1");
|
||||||
|
|
||||||
// Now the main action is step 3, it has the focus and a polite message says what to do.
|
// Now the main action is step 3, the focus is on step 2 and a polite message says what to do.
|
||||||
const mark = await screen.findByRole("button", { name: "Als gesendet markieren" });
|
const mark = await screen.findByRole("button", { name: "Als gesendet markieren" });
|
||||||
expect(mark).toHaveClass("bg-primary");
|
expect(mark).toHaveClass("bg-primary");
|
||||||
expect(mark).toHaveFocus();
|
expect(screen.getByText("2. Ans Amt schicken")).toHaveFocus();
|
||||||
|
expect(mark).not.toHaveFocus();
|
||||||
expect(screen.getByRole("status")).toHaveTextContent("PDF heruntergeladen. Schick es jetzt ans Amt.");
|
expect(screen.getByRole("status")).toHaveTextContent("PDF heruntergeladen. Schick es jetzt ans Amt.");
|
||||||
expect(screen.queryByRole("button", { name: "PDF öffnen" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "PDF herunterladen" })).not.toBeInTheDocument();
|
||||||
items = steps();
|
items = steps();
|
||||||
expect(items[0]).toHaveTextContent("Erledigt");
|
expect(items[0]).toHaveTextContent("Erledigt");
|
||||||
expect(items[1]).toHaveTextContent("Jetzt");
|
expect(items[1]).toHaveTextContent("Jetzt");
|
||||||
@@ -110,15 +111,51 @@ describe("NoticeDetailPage", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("limits Gesendet am to today and refuses a future day with a message and focus on the field", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
vi.setSystemTime(new Date("2026-10-15T10:00:00Z"));
|
||||||
|
try {
|
||||||
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
||||||
|
const field = screen.getByLabelText("Gesendet am");
|
||||||
|
expect(field).toHaveAttribute("max", "2026-10-15");
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: "2026-12-01" } });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Das Sendedatum darf nicht in der Zukunft liegen.")).toBeInTheDocument();
|
||||||
|
expect(field).toHaveFocus();
|
||||||
|
expect(noticeAction).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces the sent day after marking as sent and moves the focus to the Versand heading", async () => {
|
||||||
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
|
const sent = noticeFixture({ status: "SENT", sentOn: "2026-10-15T00:00:00.000Z" });
|
||||||
|
vi.mocked(noticeAction).mockImplementation(() => {
|
||||||
|
vi.mocked(fetchNotice).mockResolvedValue(sent);
|
||||||
|
return Promise.resolve(sent);
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Als gesendet markiert, am 15.10.2026.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Versand" })).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows German text when the PDF download fails with 403 or a server error", async () => {
|
it("shows German text when the PDF download fails with 403 or a server error", async () => {
|
||||||
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(403, "Forbidden resource"));
|
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(403, "Forbidden resource"));
|
||||||
renderPage();
|
renderPage();
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
|
await userEvent.click(await screen.findByRole("button", { name: "PDF herunterladen" }));
|
||||||
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
expect(await screen.findByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||||
|
|
||||||
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(500, "Internal server error"));
|
vi.mocked(openNoticePdf).mockRejectedValueOnce(new ApiError(500, "Internal server error"));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "PDF öffnen" }));
|
await userEvent.click(screen.getByRole("button", { name: "PDF herunterladen" }));
|
||||||
expect(await screen.findByText(/Der Server hat einen Fehler gemeldet/)).toBeInTheDocument();
|
expect(await screen.findByText(/Der Server hat einen Fehler gemeldet/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Dazu fehlt dir das Recht.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Dazu fehlt dir das Recht.")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -145,16 +182,16 @@ describe("NoticeDetailPage", () => {
|
|||||||
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
|
vi.mocked(noticeAction).mockResolvedValue(noticeFixture({ status: "SENT" }));
|
||||||
renderPage();
|
renderPage();
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
|
await userEvent.click(await screen.findByRole("button", { name: "PDF herunterladen" }));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2027-01-15" } });
|
fireEvent.change(screen.getByLabelText("Gesendet am"), { target: { value: "2026-01-15" } });
|
||||||
await userEvent.type(screen.getByLabelText("Gesendet über"), "E-Mail");
|
await userEvent.type(screen.getByLabelText("Gesendet über"), "E-Mail");
|
||||||
await userEvent.type(screen.getByLabelText("Aktenzeichen"), "AZ 12/27");
|
await userEvent.type(screen.getByLabelText("Aktenzeichen"), "AZ 12/27");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
||||||
|
|
||||||
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", {
|
expect(noticeAction).toHaveBeenCalledWith("n1", "mark-sent", {
|
||||||
sentOn: "2027-01-15",
|
sentOn: "2026-01-15",
|
||||||
sentVia: "E-Mail",
|
sentVia: "E-Mail",
|
||||||
reference: "AZ 12/27",
|
reference: "AZ 12/27",
|
||||||
});
|
});
|
||||||
@@ -164,7 +201,7 @@ describe("NoticeDetailPage", () => {
|
|||||||
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
vi.mocked(fetchNotice).mockResolvedValue(noticeFixture());
|
||||||
vi.mocked(noticeAction).mockRejectedValue(new ApiError(409, "Nur ein Entwurf kann gesendet werden.", { message: "Nur ein Entwurf kann gesendet werden." }));
|
vi.mocked(noticeAction).mockRejectedValue(new ApiError(409, "Nur ein Entwurf kann gesendet werden.", { message: "Nur ein Entwurf kann gesendet werden." }));
|
||||||
renderPage();
|
renderPage();
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "PDF öffnen" }));
|
await userEvent.click(await screen.findByRole("button", { name: "PDF herunterladen" }));
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Als gesendet markieren" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
|
||||||
|
|
||||||
@@ -247,6 +284,6 @@ describe("NoticeDetailPage", () => {
|
|||||||
}
|
}
|
||||||
expect(screen.getByText("Upload AUTHORITY_NOTICE n1 nur lesen")).toBeInTheDocument();
|
expect(screen.getByText("Upload AUTHORITY_NOTICE n1 nur lesen")).toBeInTheDocument();
|
||||||
// Reading the PDF stays possible.
|
// Reading the PDF stays possible.
|
||||||
expect(screen.getByRole("button", { name: "PDF öffnen" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "PDF herunterladen" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { BookOpen, FileText, RotateCcw } from "lucide-react";
|
import { BookOpen, FileText, RotateCcw } from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -19,10 +19,10 @@ import { fetchNotice, noticeAction, openNoticePdf } from "@/lib/compliance/notic
|
|||||||
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
|
import { fetchComplianceSettings } from "@/lib/compliance/settings-api";
|
||||||
import { complianceKey } from "@/lib/compliance/use-readiness";
|
import { complianceKey } from "@/lib/compliance/use-readiness";
|
||||||
import { formatDay } from "@/lib/medicines/format";
|
import { formatDay } from "@/lib/medicines/format";
|
||||||
import { MARK_SENT_TRIGGER_ID, MarkSentDialog } from "./mark-sent-dialog";
|
import { MarkSentDialog } from "./mark-sent-dialog";
|
||||||
import { NoticeEditForm } from "./notice-edit-form";
|
import { NoticeEditForm } from "./notice-edit-form";
|
||||||
import { NoticeHerd } from "./notice-herd";
|
import { NoticeHerd } from "./notice-herd";
|
||||||
import { NoticeSteps } from "./notice-steps";
|
import { NoticeSteps, SEND_STEP_ID } from "./notice-steps";
|
||||||
|
|
||||||
type SimpleAction = "recount" | "reopen" | "cancel";
|
type SimpleAction = "recount" | "reopen" | "cancel";
|
||||||
|
|
||||||
@@ -32,6 +32,10 @@ function doneMessage(action: SimpleAction, notice: AuthorityNotice): string {
|
|||||||
return "Anzeige storniert.";
|
return "Anzeige storniert.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sentMessage(notice: AuthorityNotice): string {
|
||||||
|
return `Als gesendet markiert, am ${formatDay(noticeDay(notice.sentOn))}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function sentText(notice: AuthorityNotice): string {
|
function sentText(notice: AuthorityNotice): string {
|
||||||
const parts = [`Gesendet am ${formatDay(noticeDay(notice.sentOn))}`];
|
const parts = [`Gesendet am ${formatDay(noticeDay(notice.sentOn))}`];
|
||||||
if (notice.sentVia) parts.push(`über ${notice.sentVia}`);
|
if (notice.sentVia) parts.push(`über ${notice.sentVia}`);
|
||||||
@@ -71,16 +75,24 @@ export default function NoticeDetailPage() {
|
|||||||
|
|
||||||
const pdf = useMutation({
|
const pdf = useMutation({
|
||||||
mutationFn: () => openNoticePdf(params.id),
|
mutationFn: () => openNoticePdf(params.id),
|
||||||
onMutate: () => setActionError(null),
|
onMutate: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setMessage("");
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setPdfOpened(true);
|
setPdfOpened(true);
|
||||||
setMessage("PDF heruntergeladen. Schick es jetzt ans Amt.");
|
setMessage("PDF heruntergeladen. Schick es jetzt ans Amt.");
|
||||||
|
// Step 2 is next: the focus goes there, not straight to the mark-sent button.
|
||||||
|
document.getElementById(SEND_STEP_ID)?.focus();
|
||||||
},
|
},
|
||||||
onError: (error) => setActionError(complianceErrorText(error, "Das PDF konnte nicht geladen werden.")),
|
onError: (error) => setActionError(complianceErrorText(error, "Das PDF konnte nicht geladen werden.")),
|
||||||
});
|
});
|
||||||
const simple = useMutation({
|
const simple = useMutation({
|
||||||
mutationFn: (action: SimpleAction) => noticeAction(params.id, action),
|
mutationFn: (action: SimpleAction) => noticeAction(params.id, action),
|
||||||
onMutate: () => setActionError(null),
|
onMutate: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setMessage("");
|
||||||
|
},
|
||||||
onSuccess: (next, action) => {
|
onSuccess: (next, action) => {
|
||||||
if (action === "reopen") setPdfOpened(false);
|
if (action === "reopen") setPdfOpened(false);
|
||||||
applyNotice(next, doneMessage(action, next));
|
applyNotice(next, doneMessage(action, next));
|
||||||
@@ -88,11 +100,6 @@ export default function NoticeDetailPage() {
|
|||||||
onError: (error) => setActionError(complianceErrorText(error, "Das hat nicht geklappt.")),
|
onError: (error) => setActionError(complianceErrorText(error, "Das hat nicht geklappt.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Once the PDF is out, "Als gesendet markieren" becomes the main action: the focus follows.
|
|
||||||
useEffect(() => {
|
|
||||||
if (pdfOpened) document.getElementById(MARK_SENT_TRIGGER_ID)?.focus();
|
|
||||||
}, [pdfOpened]);
|
|
||||||
|
|
||||||
if (!canRead) return <NoComplianceAccess />;
|
if (!canRead) return <NoComplianceAccess />;
|
||||||
if (query.isLoading || query.isError || !notice) {
|
if (query.isLoading || query.isError || !notice) {
|
||||||
return (
|
return (
|
||||||
@@ -107,7 +114,7 @@ export default function NoticeDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<Button size="touch" disabled={isOffline || pdf.isPending} aria-describedby={offlineReason} onClick={() => pdf.mutate()}>
|
<Button size="touch" disabled={isOffline || pdf.isPending} aria-describedby={offlineReason} onClick={() => pdf.mutate()}>
|
||||||
<FileText aria-hidden />
|
<FileText aria-hidden />
|
||||||
PDF öffnen
|
PDF herunterladen
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +126,7 @@ export default function NoticeDetailPage() {
|
|||||||
noticeId={notice!.id}
|
noticeId={notice!.id}
|
||||||
disabled={isOffline}
|
disabled={isOffline}
|
||||||
describedBy={offlineReason}
|
describedBy={offlineReason}
|
||||||
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
|
onDone={(next) => applyNotice(next, sentMessage(next))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,7 +145,7 @@ export default function NoticeDetailPage() {
|
|||||||
primaryAction={primaryAction()}
|
primaryAction={primaryAction()}
|
||||||
moreActions={
|
moreActions={
|
||||||
isDraft && pdfOpened
|
isDraft && pdfOpened
|
||||||
? [{ label: "PDF nochmal öffnen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }]
|
? [{ label: "PDF nochmal herunterladen", icon: FileText, onSelect: () => pdf.mutate(), disabled: isOffline || pdf.isPending }]
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
backHref="/compliance/notices"
|
backHref="/compliance/notices"
|
||||||
@@ -176,7 +183,7 @@ export default function NoticeDetailPage() {
|
|||||||
primary={false}
|
primary={false}
|
||||||
disabled={isOffline}
|
disabled={isOffline}
|
||||||
describedBy={offlineReason}
|
describedBy={offlineReason}
|
||||||
onDone={(next) => applyNotice(next, "Als gesendet markiert.")}
|
onDone={(next) => applyNotice(next, sentMessage(next))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import CompliancePage from "./page";
|
import CompliancePage from "./page";
|
||||||
@@ -176,4 +176,15 @@ describe("CompliancePage", () => {
|
|||||||
expect(main).toHaveAttribute("href", "#item-unknown-rule-global");
|
expect(main).toHaveAttribute("href", "#item-unknown-rule-global");
|
||||||
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("link", { name: /Beheben/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("announces Bericht heruntergeladen after the report PDF", async () => {
|
||||||
|
vi.mocked(fetchReadiness).mockResolvedValue(reportFixture([open({ key: "a" })]));
|
||||||
|
vi.mocked(downloadReadinessReport).mockResolvedValue(undefined);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Mehr" }));
|
||||||
|
await userEvent.click(await screen.findByRole("menuitem", { name: /Bericht \(PDF\)/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Bericht heruntergeladen.")).toBeInTheDocument());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -76,16 +76,25 @@ export default function CompliancePage() {
|
|||||||
const query = useReadiness();
|
const query = useReadiness();
|
||||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [actionDone, setActionDone] = useState("");
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const treatmentLog = useMutation({
|
const treatmentLog = useMutation({
|
||||||
mutationFn: () => exportTreatmentLogPdf(year),
|
mutationFn: () => exportTreatmentLogPdf(year),
|
||||||
onMutate: () => setActionError(null),
|
onMutate: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setActionDone("");
|
||||||
|
},
|
||||||
|
onSuccess: () => setActionDone("Bestandsbuch heruntergeladen."),
|
||||||
onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
|
onError: (error) => setActionError(complianceErrorText(error, "Das Bestandsbuch konnte nicht geladen werden.")),
|
||||||
});
|
});
|
||||||
const report = useMutation({
|
const report = useMutation({
|
||||||
mutationFn: downloadReadinessReport,
|
mutationFn: downloadReadinessReport,
|
||||||
onMutate: () => setActionError(null),
|
onMutate: () => {
|
||||||
|
setActionError(null);
|
||||||
|
setActionDone("");
|
||||||
|
},
|
||||||
|
onSuccess: () => setActionDone("Bericht heruntergeladen."),
|
||||||
onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
|
onError: (error) => setActionError(complianceErrorText(error, "Der Bericht konnte nicht geladen werden.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,6 +175,9 @@ export default function CompliancePage() {
|
|||||||
<div aria-live="assertive">
|
<div aria-live="assertive">
|
||||||
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
|
{actionError ? <p className="text-sm font-medium text-destructive">{actionError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<p role="status" className="text-sm text-muted-foreground empty:hidden">
|
||||||
|
{actionDone}
|
||||||
|
</p>
|
||||||
<div aria-live="polite" className="flex flex-col gap-6">
|
<div aria-live="polite" className="flex flex-col gap-6">
|
||||||
{query.isLoading ? <LoadingState what="Die Prüfpunkte" /> : null}
|
{query.isLoading ? <LoadingState what="Die Prüfpunkte" /> : null}
|
||||||
{!query.isLoading && data === undefined ? (
|
{!query.isLoading && data === undefined ? (
|
||||||
|
|||||||
@@ -92,4 +92,38 @@ describe("ReadinessItemCard", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute("href", "/compliance/settings");
|
expect(screen.getByRole("link", { name: /Beheben/ })).toHaveAttribute("href", "/compliance/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Beheben directly when every subject leads to the same page", () => {
|
||||||
|
renderCard(
|
||||||
|
itemFixture({
|
||||||
|
key: "holder-data",
|
||||||
|
subjects: [{ type: "SITE_SETTINGS", id: "singleton", label: "Stammdaten" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const direct = screen.getAllByRole("link", { name: /Beheben/, hidden: true }).filter((link) => !link.closest("details"));
|
||||||
|
expect(direct).toHaveLength(1);
|
||||||
|
expect(direct[0]).toHaveAttribute("href", "/settings/automation?tab=social-media");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Beheben inside the disclosure when the target depends on the subject", () => {
|
||||||
|
renderCard(
|
||||||
|
itemFixture({
|
||||||
|
key: "lot-expired",
|
||||||
|
subjects: [{ type: "lot", id: "l1", label: "Charge A" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const links = screen.getAllByRole("link", { name: /Beheben/ });
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0].closest("details")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the acknowledge dialog text without a double period", async () => {
|
||||||
|
renderCard(itemFixture({ acknowledgeable: true, title: "Eine Packung hat noch Bestand." }));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Quittieren" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Eine Packung hat noch Bestand\. Schreib kurz/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -61,6 +61,13 @@ function SubjectList({ item }: { item: ReadinessItem }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when every affected subject leads to the same page as the rule itself (no subject needed to choose it). */
|
||||||
|
function hasSubjectFreeFix(item: ReadinessItem): boolean {
|
||||||
|
if (item.subjects.length === 0) return true;
|
||||||
|
const rule = fixLinkFor(item.key, null);
|
||||||
|
return item.subjects.every((subject) => fixLinkFor(item.key, subject).href === rule.href);
|
||||||
|
}
|
||||||
|
|
||||||
/** Anchor of a card, so the main action can point at an item whose page is not there yet. */
|
/** Anchor of a card, so the main action can point at an item whose page is not there yet. */
|
||||||
export function cardId(item: Pick<ReadinessItem, "key" | "subjectId">): string {
|
export function cardId(item: Pick<ReadinessItem, "key" | "subjectId">): string {
|
||||||
return `item-${item.key}-${item.subjectId}`;
|
return `item-${item.key}-${item.subjectId}`;
|
||||||
@@ -124,7 +131,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
{isOpen ? <SubjectList item={item} /> : null}
|
{isOpen ? <SubjectList item={item} /> : null}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isOpen && item.subjects.length === 0 ? (
|
{isOpen && hasSubjectFreeFix(item) ? (
|
||||||
<FixLink itemKey={item.key} subject={null} context={item.title} />
|
<FixLink itemKey={item.key} subject={null} context={item.title} />
|
||||||
) : null}
|
) : null}
|
||||||
{canManage ? <AcknowledgementActions item={item} onDone={handleDone} /> : null}
|
{canManage ? <AcknowledgementActions item={item} onDone={handleDone} /> : null}
|
||||||
|
|||||||
+38
-1
@@ -3,7 +3,7 @@ import { render, screen, waitFor, within } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
import { createRestrictions } from "@/lib/compliance/restrictions-api";
|
import { createRestrictions, fetchRestrictions } from "@/lib/compliance/restrictions-api";
|
||||||
import { ApiError } from "@/lib/auth/api-client";
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
import { CreateRestrictionDialog } from "./create-restriction-dialog";
|
import { CreateRestrictionDialog } from "./create-restriction-dialog";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ vi.mock("@/lib/apiaries/api", () => ({ fetchApiaries: vi.fn() }));
|
|||||||
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
|
vi.mock("@/lib/compliance/restrictions-api", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
|
...(await importOriginal<typeof import("@/lib/compliance/restrictions-api")>()),
|
||||||
createRestrictions: vi.fn(),
|
createRestrictions: vi.fn(),
|
||||||
|
fetchRestrictions: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
|
vi.mock("@/lib/offline/connectivity", () => ({ useConnectivity: () => "online" }));
|
||||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
@@ -37,6 +38,42 @@ describe("CreateRestrictionDialog", () => {
|
|||||||
] as never);
|
] as never);
|
||||||
vi.mocked(createRestrictions).mockReset();
|
vi.mocked(createRestrictions).mockReset();
|
||||||
vi.mocked(createRestrictions).mockResolvedValue([]);
|
vi.mocked(createRestrictions).mockResolvedValue([]);
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hints at an overlapping restriction at the same apiary, but does not block", async () => {
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "r-old",
|
||||||
|
apiaryId: "a-1",
|
||||||
|
disease: "Amerikanische Faulbrut",
|
||||||
|
startsOn: "2026-10-01T00:00:00.000Z",
|
||||||
|
endsOn: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderDialog();
|
||||||
|
const dialog = await openDialog();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Obstwiese" }));
|
||||||
|
await userEvent.clear(within(dialog).getByLabelText(/Beginn/));
|
||||||
|
await userEvent.type(within(dialog).getByLabelText(/Beginn/), "2026-12-01");
|
||||||
|
|
||||||
|
const hint = await within(dialog).findByRole("status");
|
||||||
|
expect(hint).toHaveTextContent("Obstwiese: Amerikanische Faulbrut, 01.10.2026 bis auf Weiteres");
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Sperrbezirk eintragen" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no overlap hint for another apiary", async () => {
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([
|
||||||
|
{ id: "r-old", apiaryId: "a-1", disease: "Amerikanische Faulbrut", startsOn: "2026-10-01T00:00:00.000Z", endsOn: null },
|
||||||
|
]);
|
||||||
|
renderDialog();
|
||||||
|
const dialog = await openDialog();
|
||||||
|
|
||||||
|
await userEvent.click(await within(dialog).findByRole("checkbox", { name: "Hausgarten" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchRestrictions).toHaveBeenCalled());
|
||||||
|
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the disease as a ChoiceGroup with free text only for Andere", async () => {
|
it("offers the disease as a ChoiceGroup with free text only for Andere", async () => {
|
||||||
|
|||||||
+32
-1
@@ -5,12 +5,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
import { complianceErrorText } from "@/lib/compliance/errors";
|
import { complianceErrorText } from "@/lib/compliance/errors";
|
||||||
|
import { overlappingRestrictions } from "@/lib/compliance/restriction-overlap";
|
||||||
import {
|
import {
|
||||||
createRestrictions,
|
createRestrictions,
|
||||||
|
fetchRestrictions,
|
||||||
type CreateRestrictionsInput,
|
type CreateRestrictionsInput,
|
||||||
} from "@/lib/compliance/restrictions-api";
|
} from "@/lib/compliance/restrictions-api";
|
||||||
import { complianceKey } from "@/lib/compliance/use-readiness";
|
import { complianceKey } from "@/lib/compliance/use-readiness";
|
||||||
import { berlinCalendarDay } from "@/lib/dates";
|
import { restrictionListKey } from "@/lib/compliance/use-active-restrictions";
|
||||||
|
import { berlinCalendarDay, toCalendarDay } from "@/lib/dates";
|
||||||
|
import { formatDay } from "@/lib/medicines/format";
|
||||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||||
@@ -125,6 +129,18 @@ export function CreateRestrictionDialog() {
|
|||||||
|
|
||||||
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||||
const apiaries = apiariesQuery.data ?? [];
|
const apiaries = apiariesQuery.data ?? [];
|
||||||
|
// Only for the overlap hint: a failed load just means no hint.
|
||||||
|
const restrictionsQuery = useQuery({
|
||||||
|
queryKey: restrictionListKey,
|
||||||
|
queryFn: fetchRestrictions,
|
||||||
|
enabled: open,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const overlaps = overlappingRestrictions(restrictionsQuery.data ?? [], values.apiaryIds, {
|
||||||
|
startsOn: values.startsOn,
|
||||||
|
endsOn: values.endsOn,
|
||||||
|
});
|
||||||
|
const apiaryNames = new Map(apiaries.map((apiary) => [apiary.id, apiary.name]));
|
||||||
|
|
||||||
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
|
function set<K extends keyof FormValues>(key: K, value: FormValues[K]) {
|
||||||
setValues((current) => ({ ...current, [key]: value }));
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
@@ -255,6 +271,21 @@ export function CreateRestrictionDialog() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
{overlaps.length > 0 ? (
|
||||||
|
<div role="status" className="rounded-lg border border-border bg-muted/50 p-3 text-sm">
|
||||||
|
<p className="font-medium">Für diesen Zeitraum gibt es schon einen Eintrag:</p>
|
||||||
|
<ul className="mt-1 list-disc pl-5">
|
||||||
|
{overlaps.map((row) => (
|
||||||
|
<li key={row.id}>
|
||||||
|
{`${apiaryNames.get(row.apiaryId) ?? "Unbekannter Stand"}: ${row.disease}, ${formatDay(toCalendarDay(row.startsOn))} bis ${
|
||||||
|
row.endsOn === null ? "auf Weiteres" : formatDay(toCalendarDay(row.endsOn))
|
||||||
|
}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-1 text-muted-foreground">Du kannst trotzdem eintragen.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={3}>
|
<FormSection title="Weitere Angaben" collapsible defaultOpen={false} fieldCount={3}>
|
||||||
<Field label="Behörde">
|
<Field label="Behörde">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useRef, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { formatDay } from "@/lib/medicines/format";
|
import { formatDay } from "@/lib/medicines/format";
|
||||||
import { complianceErrorText } from "@/lib/compliance/errors";
|
import { complianceErrorText } from "@/lib/compliance/errors";
|
||||||
import { endRestriction, type ApiaryRestriction } from "@/lib/compliance/restrictions-api";
|
import { endRestriction, type ApiaryRestriction } from "@/lib/compliance/restrictions-api";
|
||||||
@@ -39,13 +38,40 @@ function stillHoldsText(endsOn: string): string {
|
|||||||
return `Die Sperre gilt noch bis einschließlich ${formatDay(endsOn)}.`;
|
return `Die Sperre gilt noch bis einschließlich ${formatDay(endsOn)}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RestrictionSection = "active" | "ended";
|
||||||
|
|
||||||
|
export interface EndedRestriction {
|
||||||
|
/** The list heading the focus belongs on: where the restriction is listed now. */
|
||||||
|
section: RestrictionSection;
|
||||||
|
/** What a screen reader hears. */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id of the heading of a section on the restrictions page. */
|
||||||
|
export function sectionHeadingId(section: RestrictionSection): string {
|
||||||
|
return `restrictions-${section}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A restriction that starts today cannot end "yesterday": say when it can end instead. */
|
||||||
|
function endDayHint(startsTodayOrLater: boolean, stillHolds: boolean, endsOn: string): string {
|
||||||
|
if (startsTodayOrLater) {
|
||||||
|
const earliest = "Die Sperre beginnt heute oder später und endet frühestens am Ende von heute.";
|
||||||
|
return stillHolds ? `${earliest} ${stillHoldsText(endsOn)}` : earliest;
|
||||||
|
}
|
||||||
|
return stillHolds
|
||||||
|
? stillHoldsText(endsOn)
|
||||||
|
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein.";
|
||||||
|
}
|
||||||
|
|
||||||
/** Ending sets the last day; the entry stays in the list as "Beendet". */
|
/** Ending sets the last day; the entry stays in the list as "Beendet". */
|
||||||
export function EndRestrictionDialog({
|
export function EndRestrictionDialog({
|
||||||
restriction,
|
restriction,
|
||||||
apiaryName,
|
apiaryName,
|
||||||
|
onEnded,
|
||||||
}: {
|
}: {
|
||||||
restriction: ApiaryRestriction;
|
restriction: ApiaryRestriction;
|
||||||
apiaryName: string;
|
apiaryName: string;
|
||||||
|
onEnded: (ended: EndedRestriction) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||||
@@ -55,13 +81,23 @@ export function EndRestrictionDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const today = berlinCalendarDay(new Date().toISOString());
|
const today = berlinCalendarDay(new Date().toISOString());
|
||||||
const stillHolds = endsOn !== "" && endsOn >= today;
|
const stillHolds = endsOn !== "" && endsOn >= today;
|
||||||
|
const startsTodayOrLater = startsOn >= today;
|
||||||
|
const focusAfterClose = useRef<string | null>(null);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => endRestriction(restriction.id, endsOn),
|
mutationFn: () => endRestriction(restriction.id, endsOn),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: complianceKey });
|
const section: RestrictionSection = stillHolds ? "active" : "ended";
|
||||||
toast.success(stillHolds ? stillHoldsText(endsOn) : "Sperrbezirk beendet");
|
focusAfterClose.current = sectionHeadingId(section);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: complianceKey });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
// The row may be gone with the refetch: the page moves the focus, not this dialog.
|
||||||
|
onEnded({
|
||||||
|
section,
|
||||||
|
message: stillHolds
|
||||||
|
? stillHoldsText(endsOn)
|
||||||
|
: `Sperrbezirk beendet. ${apiaryName} steht jetzt unter „Beendet“.`,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (mutationError) =>
|
onError: (mutationError) =>
|
||||||
setError(complianceErrorText(mutationError, "Der Sperrbezirk konnte nicht beendet werden.")),
|
setError(complianceErrorText(mutationError, "Der Sperrbezirk konnte nicht beendet werden.")),
|
||||||
@@ -90,7 +126,9 @@ export function EndRestrictionDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DialogContent>
|
<DialogContent
|
||||||
|
finalFocus={() => (focusAfterClose.current ? document.getElementById(focusAfterClose.current) : true)}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sperrbezirk beenden</DialogTitle>
|
<DialogTitle>Sperrbezirk beenden</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -101,11 +139,7 @@ export function EndRestrictionDialog({
|
|||||||
<Field
|
<Field
|
||||||
label="Letzter Tag der Sperre"
|
label="Letzter Tag der Sperre"
|
||||||
required
|
required
|
||||||
hint={
|
hint={endDayHint(startsTodayOrLater, stillHolds, endsOn)}
|
||||||
stillHolds
|
|
||||||
? stillHoldsText(endsOn)
|
|
||||||
: "An diesem Tag gilt die Sperre noch. Ist sie heute aufgehoben, trag gestern ein."
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{({ id, describedBy: fieldDescribedBy }) => (
|
{({ id, describedBy: fieldDescribedBy }) => (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +37,6 @@ vi.mock("@/components/document-upload-card", () => ({
|
|||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
|
||||||
|
|
||||||
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
|
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
|
||||||
return {
|
return {
|
||||||
@@ -167,7 +165,56 @@ describe("RestrictionsPage", () => {
|
|||||||
|
|
||||||
expect(within(dialog).getByText("Die Sperre gilt noch bis einschließlich 03.02.2999.")).toBeInTheDocument();
|
expect(within(dialog).getByText("Die Sperre gilt noch bis einschließlich 03.02.2999.")).toBeInTheDocument();
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
|
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
|
||||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Die Sperre gilt noch bis einschließlich 03.02.2999."));
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Die Sperre gilt noch bis einschließlich 03.02.2999."),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByRole("heading", { name: /Aktiv/ })).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a planned restriction as ab, an active one as seit", async () => {
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([
|
||||||
|
row({ id: "r-3", startsOn: "2999-01-01T00:00:00.000Z", endsOn: "2999-01-31T00:00:00.000Z" }),
|
||||||
|
row({ id: "r-1" }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const planned = await screen.findByRole("region", { name: /Geplant/ });
|
||||||
|
expect(within(planned).getByText("ab 01.01.2999 · bis 31.01.2999")).toBeInTheDocument();
|
||||||
|
const active = screen.getByRole("region", { name: /Aktiv/ });
|
||||||
|
expect(within(active).getByText(/seit 01\.10\.2020/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the focus to the Beendet heading and announces it after ending", async () => {
|
||||||
|
vi.mocked(endRestriction).mockImplementation(() => {
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([
|
||||||
|
row({ id: "r-1", endsOn: "2020-12-01T00:00:00.000Z" }),
|
||||||
|
row({ id: "r-2", apiaryId: "a-2", endsOn: "2020-11-01T00:00:00.000Z" }),
|
||||||
|
]);
|
||||||
|
return Promise.resolve(row({}));
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
const active = await screen.findByRole("region", { name: /Aktiv/ });
|
||||||
|
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/Letzter Tag/), { target: { value: "2020-12-01" } });
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Sperrbezirk beenden" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Sperrbezirk beendet. Obstwiese steht jetzt unter „Beendet“."),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByRole("heading", { name: /Beendet/ })).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains that a restriction that started today ends at the end of today at the earliest", async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
vi.mocked(fetchRestrictions).mockResolvedValue([row({ id: "r-1", startsOn: `${today}T00:00:00.000Z` })]);
|
||||||
|
renderPage();
|
||||||
|
const active = await screen.findByRole("region", { name: /Aktiv/ });
|
||||||
|
await userEvent.click(within(active).getByRole("button", { name: /Beenden/ }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
|
||||||
|
expect(within(dialog).queryByText(/trag gestern ein/)).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText(/endet frühestens am Ende von heute/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the live region for loading and error only", async () => {
|
it("keeps the live region for loading and error only", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { OctagonAlert } from "lucide-react";
|
import { OctagonAlert } from "lucide-react";
|
||||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||||
@@ -15,12 +15,12 @@ import {
|
|||||||
type ApiaryRestriction,
|
type ApiaryRestriction,
|
||||||
type RestrictionState,
|
type RestrictionState,
|
||||||
} from "@/lib/compliance/restrictions-api";
|
} from "@/lib/compliance/restrictions-api";
|
||||||
import { complianceKey } from "@/lib/compliance/use-readiness";
|
import { restrictionListKey } from "@/lib/compliance/use-active-restrictions";
|
||||||
import { berlinCalendarDay } from "@/lib/dates";
|
import { berlinCalendarDay } from "@/lib/dates";
|
||||||
import { CreateRestrictionDialog } from "./create-restriction-dialog";
|
import { CreateRestrictionDialog } from "./create-restriction-dialog";
|
||||||
|
import { sectionHeadingId, type EndedRestriction } from "./end-restriction-dialog";
|
||||||
import { RestrictionRow } from "./restriction-row";
|
import { RestrictionRow } from "./restriction-row";
|
||||||
|
|
||||||
const restrictionListKey = [...complianceKey, "restrictions", "list"] as const;
|
|
||||||
const UNKNOWN_APIARY = "Unbekannter Stand";
|
const UNKNOWN_APIARY = "Unbekannter Stand";
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
@@ -32,8 +32,9 @@ function Section({
|
|||||||
state,
|
state,
|
||||||
canManage,
|
canManage,
|
||||||
canRead,
|
canRead,
|
||||||
|
onEnded,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: "active" | "planned" | "ended";
|
||||||
title: string;
|
title: string;
|
||||||
rows: ApiaryRestriction[];
|
rows: ApiaryRestriction[];
|
||||||
emptyText: string;
|
emptyText: string;
|
||||||
@@ -41,11 +42,12 @@ function Section({
|
|||||||
state: RestrictionState;
|
state: RestrictionState;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
|
onEnded: (ended: EndedRestriction) => void;
|
||||||
}) {
|
}) {
|
||||||
const headingId = `restrictions-${id}`;
|
const headingId = `restrictions-${id}`;
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||||
<h2 id={headingId} className="font-heading text-lg font-semibold tracking-tight">
|
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none">
|
||||||
{`${title} (${rows.length})`}
|
{`${title} (${rows.length})`}
|
||||||
</h2>
|
</h2>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
@@ -60,6 +62,7 @@ function Section({
|
|||||||
state={state}
|
state={state}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canRead={canRead}
|
canRead={canRead}
|
||||||
|
onEnded={onEnded}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -87,6 +90,18 @@ export default function RestrictionsPage() {
|
|||||||
const by = (state: RestrictionState) => rows.filter((row) => restrictionState(row, today) === state);
|
const by = (state: RestrictionState) => rows.filter((row) => restrictionState(row, today) === state);
|
||||||
return { active: by("active"), planned: by("planned"), ended: by("ended") };
|
return { active: by("active"), planned: by("planned"), ended: by("ended") };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
const [announcement, setAnnouncement] = useState("");
|
||||||
|
const [focusRequest, setFocusRequest] = useState<{ section: EndedRestriction["section"]; count: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusRequest) document.getElementById(sectionHeadingId(focusRequest.section))?.focus();
|
||||||
|
}, [focusRequest]);
|
||||||
|
|
||||||
|
function handleEnded({ section, message }: EndedRestriction) {
|
||||||
|
setAnnouncement(message);
|
||||||
|
setFocusRequest((current) => ({ section, count: (current?.count ?? 0) + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
const nameOf = (apiaryId: string) => names.get(apiaryId) ?? UNKNOWN_APIARY;
|
const nameOf = (apiaryId: string) => names.get(apiaryId) ?? UNKNOWN_APIARY;
|
||||||
|
|
||||||
if (!canRead) return <NoComplianceAccess />;
|
if (!canRead) return <NoComplianceAccess />;
|
||||||
@@ -108,6 +123,9 @@ export default function RestrictionsPage() {
|
|||||||
<ErrorState what="Die Sperrbezirke" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
|
<ErrorState what="Die Sperrbezirke" onRetry={() => void query.refetch()} isRetrying={query.isFetching} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<p role="status" className="text-sm font-medium empty:hidden">
|
||||||
|
{announcement}
|
||||||
|
</p>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{data ? (
|
{data ? (
|
||||||
<>
|
<>
|
||||||
@@ -120,6 +138,7 @@ export default function RestrictionsPage() {
|
|||||||
state="active"
|
state="active"
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canRead={canRead}
|
canRead={canRead}
|
||||||
|
onEnded={handleEnded}
|
||||||
/>
|
/>
|
||||||
{planned.length > 0 ? (
|
{planned.length > 0 ? (
|
||||||
<Section
|
<Section
|
||||||
@@ -131,6 +150,7 @@ export default function RestrictionsPage() {
|
|||||||
state="planned"
|
state="planned"
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canRead={canRead}
|
canRead={canRead}
|
||||||
|
onEnded={handleEnded}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{ended.length > 0 ? (
|
{ended.length > 0 ? (
|
||||||
@@ -143,6 +163,7 @@ export default function RestrictionsPage() {
|
|||||||
state="ended"
|
state="ended"
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canRead={canRead}
|
canRead={canRead}
|
||||||
|
onEnded={handleEnded}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import type { ApiaryRestriction, RestrictionState } from "@/lib/compliance/restrictions-api";
|
import type { ApiaryRestriction, RestrictionState } from "@/lib/compliance/restrictions-api";
|
||||||
import { toCalendarDay } from "@/lib/dates";
|
import { toCalendarDay } from "@/lib/dates";
|
||||||
import { formatDay } from "@/lib/medicines/format";
|
import { formatDay } from "@/lib/medicines/format";
|
||||||
import { EndRestrictionDialog } from "./end-restriction-dialog";
|
import { EndRestrictionDialog, type EndedRestriction } from "./end-restriction-dialog";
|
||||||
|
|
||||||
interface RestrictionRowProps {
|
interface RestrictionRowProps {
|
||||||
restriction: ApiaryRestriction;
|
restriction: ApiaryRestriction;
|
||||||
@@ -17,17 +17,20 @@ interface RestrictionRowProps {
|
|||||||
state: RestrictionState;
|
state: RestrictionState;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
|
/** Called after the restriction was ended, with the section it now belongs to. */
|
||||||
|
onEnded: (ended: EndedRestriction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodOf(restriction: ApiaryRestriction): string {
|
function periodOf(restriction: ApiaryRestriction, state: RestrictionState): string {
|
||||||
const since = `seit ${formatDay(toCalendarDay(restriction.startsOn))}`;
|
// A planned restriction has not begun: "ab", not "seit".
|
||||||
|
const since = `${state === "planned" ? "ab" : "seit"} ${formatDay(toCalendarDay(restriction.startsOn))}`;
|
||||||
return restriction.endsOn === null
|
return restriction.endsOn === null
|
||||||
? `${since} · bis auf Weiteres`
|
? `${since} · bis auf Weiteres`
|
||||||
: `${since} · bis ${formatDay(toCalendarDay(restriction.endsOn))}`;
|
: `${since} · bis ${formatDay(toCalendarDay(restriction.endsOn))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One restriction: state as word and symbol, the Verfügung behind a button. */
|
/** One restriction: state as word and symbol, the Verfügung behind a button. */
|
||||||
export function RestrictionRow({ restriction, apiaryName, state, canManage, canRead }: RestrictionRowProps) {
|
export function RestrictionRow({ restriction, apiaryName, state, canManage, canRead, onEnded }: RestrictionRowProps) {
|
||||||
const [showDocuments, setShowDocuments] = useState(false);
|
const [showDocuments, setShowDocuments] = useState(false);
|
||||||
const details = [restriction.authority, restriction.reference].filter(Boolean).join(" · ");
|
const details = [restriction.authority, restriction.reference].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ export function RestrictionRow({ restriction, apiaryName, state, canManage, canR
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium">{restriction.disease}</p>
|
<p className="text-sm font-medium">{restriction.disease}</p>
|
||||||
<p className="text-sm text-muted-foreground">{periodOf(restriction)}</p>
|
<p className="text-sm text-muted-foreground">{periodOf(restriction, state)}</p>
|
||||||
{details ? <p className="text-sm text-muted-foreground">{details}</p> : null}
|
{details ? <p className="text-sm text-muted-foreground">{details}</p> : null}
|
||||||
{restriction.notes ? <p className="text-sm text-muted-foreground">{restriction.notes}</p> : null}
|
{restriction.notes ? <p className="text-sm text-muted-foreground">{restriction.notes}</p> : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -76,7 +79,7 @@ export function RestrictionRow({ restriction, apiaryName, state, canManage, canR
|
|||||||
Verfügung
|
Verfügung
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} /> : null}
|
{state !== "ended" && canManage ? <EndRestrictionDialog restriction={restriction} apiaryName={apiaryName} onEnded={onEnded} /> : null}
|
||||||
</div>
|
</div>
|
||||||
{canRead && showDocuments ? (
|
{canRead && showDocuments ? (
|
||||||
<DocumentUploadCard
|
<DocumentUploadCard
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { parseDecimal } from "@/lib/medicines/format";
|
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
|
||||||
import type { NetQuantityDigitRule } from "@/lib/compliance/notice-types";
|
import type { NetQuantityDigitRule } from "@/lib/compliance/notice-types";
|
||||||
|
|
||||||
/** A rule as typed: German decimals stay text until the form is saved. */
|
/** A rule as typed: German decimals stay text until the form is saved. */
|
||||||
@@ -41,20 +41,43 @@ export function digitRuleProblems(rows: readonly DigitRuleText[]): Record<number
|
|||||||
const problems: Record<number, string> = {};
|
const problems: Record<number, string> = {};
|
||||||
let previous = 0;
|
let previous = 0;
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
const minMm = parseDecimal(row.minMm);
|
const heightOk = isHeightOk(row);
|
||||||
const heightOk = Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM;
|
|
||||||
let limitOk = true;
|
let limitOk = true;
|
||||||
if (index < rows.length - 1) {
|
if (index < rows.length - 1) {
|
||||||
const maxGrams = parseDecimal(row.maxGrams);
|
const maxGrams = parseDecimal(row.maxGrams);
|
||||||
limitOk = Number.isFinite(maxGrams) && maxGrams > previous;
|
limitOk = Number.isFinite(maxGrams) && maxGrams > previous;
|
||||||
if (limitOk) previous = maxGrams;
|
if (limitOk) previous = maxGrams;
|
||||||
}
|
}
|
||||||
if (!heightOk) problems[index] = HEIGHT_PROBLEM;
|
// "1.000" gets the thousands-point hint, like every other number field.
|
||||||
else if (!limitOk) problems[index] = LIMIT_PROBLEM;
|
if (!heightOk) problems[index] = decimalMessage(row.minMm, HEIGHT_PROBLEM);
|
||||||
|
else if (!limitOk) problems[index] = decimalMessage(row.maxGrams, LIMIT_PROBLEM);
|
||||||
});
|
});
|
||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHeightOk(row: DigitRuleText): boolean {
|
||||||
|
const minMm = parseDecimal(row.minMm);
|
||||||
|
return Number.isFinite(minMm) && minMm >= MIN_DIGIT_MM && minMm <= MAX_DIGIT_MM;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digitMaxFieldId(index: number): string {
|
||||||
|
return `digit-max-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digitMinFieldId(index: number): string {
|
||||||
|
return `digit-min-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Id of the input of the first row with a problem: the height field when the height is wrong, else the limit. */
|
||||||
|
export function firstInvalidDigitFieldId(
|
||||||
|
rows: readonly DigitRuleText[],
|
||||||
|
problems: Record<number, string>,
|
||||||
|
): string | null {
|
||||||
|
const index = rows.findIndex((_, position) => problems[position] !== undefined);
|
||||||
|
if (index < 0) return null;
|
||||||
|
return isHeightOk(rows[index]) ? digitMaxFieldId(index) : digitMinFieldId(index);
|
||||||
|
}
|
||||||
|
|
||||||
/** The typed rules as numbers; call only when `digitRuleProblems` is empty. The last row has no limit. */
|
/** The typed rules as numbers; call only when `digitRuleProblems` is empty. The last row has no limit. */
|
||||||
export function digitRulesFromText(rows: readonly DigitRuleText[]): NetQuantityDigitRule[] {
|
export function digitRulesFromText(rows: readonly DigitRuleText[]): NetQuantityDigitRule[] {
|
||||||
return rows.map((row, index) => ({
|
return rows.map((row, index) => ({
|
||||||
@@ -99,27 +122,29 @@ export function DigitRulesField({ rows, readOnly, noteId, problems, onChange }:
|
|||||||
return (
|
return (
|
||||||
<li key={index} className="grid grid-cols-2 gap-3 rounded-lg bg-muted/50 p-3">
|
<li key={index} className="grid grid-cols-2 gap-3 rounded-lg bg-muted/50 p-3">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`digit-max-${index}`}>
|
<Label htmlFor={digitMaxFieldId(index)}>
|
||||||
{isLast ? `Über ${previous || "…"} g` : "Bis (g)"}
|
{isLast ? `Über ${previous || "…"} g` : "Bis (g)"}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`digit-max-${index}`}
|
id={digitMaxFieldId(index)}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={isLast ? "" : row.maxGrams}
|
value={isLast ? "" : row.maxGrams}
|
||||||
placeholder={isLast ? "alles darüber" : undefined}
|
placeholder={isLast ? "alles darüber" : undefined}
|
||||||
readOnly={readOnly || isLast}
|
readOnly={readOnly || isLast}
|
||||||
aria-invalid={problems[index] ? true : undefined}
|
aria-invalid={problems[index] && isHeightOk(row) ? true : undefined}
|
||||||
aria-describedby={problems[index] ? `digit-problem-${index}` : undefined}
|
aria-describedby={problems[index] && isHeightOk(row) ? `digit-problem-${index}` : undefined}
|
||||||
onChange={(event) => setRow(index, { maxGrams: event.target.value })}
|
onChange={(event) => setRow(index, { maxGrams: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`digit-min-${index}`}>Mindestens (mm)</Label>
|
<Label htmlFor={digitMinFieldId(index)}>Mindestens (mm)</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`digit-min-${index}`}
|
id={digitMinFieldId(index)}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={row.minMm}
|
value={row.minMm}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
aria-invalid={problems[index] && isHeightOk(row) === false ? true : undefined}
|
||||||
|
aria-describedby={problems[index] && isHeightOk(row) === false ? `digit-problem-${index}` : undefined}
|
||||||
onChange={(event) => setRow(index, { minMm: event.target.value })}
|
onChange={(event) => setRow(index, { minMm: event.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -171,6 +171,33 @@ describe("ComplianceSettingsPage", () => {
|
|||||||
expect(updateComplianceSettings).not.toHaveBeenCalled();
|
expect(updateComplianceSettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives \"1.000\" in Bis (g) the thousands-point hint and moves the focus to that field", async () => {
|
||||||
|
await openRules();
|
||||||
|
const limit = document.getElementById("digit-max-0") as HTMLElement;
|
||||||
|
await userEvent.clear(limit);
|
||||||
|
await userEvent.type(limit, "1.000");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
expect(within(limit.closest("li") as HTMLElement).getByRole("alert")).toHaveTextContent(
|
||||||
|
"Bitte ohne Tausenderpunkt eingeben",
|
||||||
|
);
|
||||||
|
expect(limit).toHaveFocus();
|
||||||
|
expect(limit).toHaveAttribute("aria-invalid", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the focus to the height field when the height is the wrong one, even if Erweitert was folded", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByLabelText("Stichtag (MM-TT)");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
|
||||||
|
const height = document.getElementById("digit-min-0") as HTMLElement;
|
||||||
|
await userEvent.clear(height);
|
||||||
|
await userEvent.type(height, "99");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
expect(document.getElementById("digit-min-0")).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
it("sends valid rules with the last one unlimited", async () => {
|
it("sends valid rules with the last one unlimited", async () => {
|
||||||
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
|
vi.mocked(updateComplianceSettings).mockResolvedValue(settingsFixture());
|
||||||
await openRules();
|
await openRules();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||||
@@ -26,12 +26,30 @@ import {
|
|||||||
DigitRulesField,
|
DigitRulesField,
|
||||||
type DigitRuleText,
|
type DigitRuleText,
|
||||||
digitRuleProblems,
|
digitRuleProblems,
|
||||||
|
firstInvalidDigitFieldId,
|
||||||
digitRulesFromText,
|
digitRulesFromText,
|
||||||
digitRulesToText,
|
digitRulesToText,
|
||||||
} from "./digit-rules-field";
|
} from "./digit-rules-field";
|
||||||
|
|
||||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||||
|
|
||||||
|
/** Input id per form field that can carry a validation error, in page order. */
|
||||||
|
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
|
||||||
|
tskReferenceDay: "tsk-reference-day",
|
||||||
|
tskReportDueDay: "tsk-due-day",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The id of the first input with a problem: plain fields come first on the page, then the digit rules. */
|
||||||
|
function firstInvalidInputId(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
rules: DigitRuleText[],
|
||||||
|
ruleProblems: Record<number, string>,
|
||||||
|
): string | null {
|
||||||
|
const fieldId = Object.keys(FIELD_INPUT_ID).find((name) => fields[name] !== undefined);
|
||||||
|
if (fieldId) return FIELD_INPUT_ID[fieldId];
|
||||||
|
return firstInvalidDigitFieldId(rules, ruleProblems);
|
||||||
|
}
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
veterinaryOfficeName: string;
|
veterinaryOfficeName: string;
|
||||||
veterinaryOfficeAddress: string;
|
veterinaryOfficeAddress: string;
|
||||||
@@ -129,8 +147,18 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
const [general, setGeneral] = useState<string | null>(null);
|
const [general, setGeneral] = useState<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
const [saved, setSaved] = useState("");
|
const [saved, setSaved] = useState("");
|
||||||
|
// Counts up per failed save, so the focus moves again even to the same field.
|
||||||
|
const [focusRequest, setFocusRequest] = useState<{ id: string; count: number } | null>(null);
|
||||||
const readOnly = !canManage;
|
const readOnly = !canManage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusRequest) document.getElementById(focusRequest.id)?.focus();
|
||||||
|
}, [focusRequest]);
|
||||||
|
|
||||||
|
function requestFocus(id: string | null) {
|
||||||
|
if (id) setFocusRequest((current) => ({ id, count: (current?.count ?? 0) + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
// A refetch must not wipe what is being typed: with unsaved input the person
|
// A refetch must not wipe what is being typed: with unsaved input the person
|
||||||
// is asked, without it the new data is taken over.
|
// is asked, without it the new data is taken over.
|
||||||
const serverChanged = JSON.stringify(settings) !== JSON.stringify(loaded);
|
const serverChanged = JSON.stringify(settings) !== JSON.stringify(loaded);
|
||||||
@@ -162,6 +190,7 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
const { fields } = describeSaveError(error, fallback);
|
const { fields } = describeSaveError(error, fallback);
|
||||||
setGeneral(Object.keys(fields).length > 0 ? null : complianceErrorText(error, fallback));
|
setGeneral(Object.keys(fields).length > 0 ? null : complianceErrorText(error, fallback));
|
||||||
setFieldErrors(fields);
|
setFieldErrors(fields);
|
||||||
|
requestFocus(firstInvalidInputId(fields, [], {}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +214,10 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
setRuleProblems(problems.rules);
|
setRuleProblems(problems.rules);
|
||||||
setFieldErrors(problems.fields);
|
setFieldErrors(problems.fields);
|
||||||
if (Object.keys(problems.rules).length > 0) setGeneral(DIGIT_RULES_PROBLEM);
|
if (Object.keys(problems.rules).length > 0) setGeneral(DIGIT_RULES_PROBLEM);
|
||||||
if (Object.keys(problems.fields).length > 0 || Object.keys(problems.rules).length > 0) return;
|
if (Object.keys(problems.fields).length > 0 || Object.keys(problems.rules).length > 0) {
|
||||||
|
requestFocus(firstInvalidInputId(problems.fields, values.rules, problems.rules));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isOffline) return;
|
if (isOffline) return;
|
||||||
mutation.mutate(bodyOf(values));
|
mutation.mutate(bodyOf(values));
|
||||||
}
|
}
|
||||||
@@ -318,7 +350,13 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
|||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection title="Erweitert" collapsible defaultOpen={false} fieldCount={2}>
|
<FormSection
|
||||||
|
title="Erweitert"
|
||||||
|
collapsible
|
||||||
|
defaultOpen={false}
|
||||||
|
fieldCount={2}
|
||||||
|
forceOpen={Object.keys(ruleProblems).length > 0}
|
||||||
|
>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<legend className="mb-1 text-sm font-medium">Gezählte Völkerarten</legend>
|
<legend className="mb-1 text-sm font-medium">Gezählte Völkerarten</legend>
|
||||||
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
||||||
|
|||||||
@@ -98,6 +98,27 @@ describe("AppLayout", () => {
|
|||||||
pushMock.mockClear();
|
pushMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts with a skip link that moves the focus to the page content", async () => {
|
||||||
|
render(
|
||||||
|
<AppLayout>
|
||||||
|
<main>
|
||||||
|
<p>Seiteninhalt</p>
|
||||||
|
</main>
|
||||||
|
</AppLayout>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Zum Inhalt springen" });
|
||||||
|
// First in the tab order: before the sidebar and every other link.
|
||||||
|
await userEvent.tab();
|
||||||
|
expect(link).toHaveFocus();
|
||||||
|
expect(link).toHaveAttribute("href", "#main-content");
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
const content = screen.getByText("Seiteninhalt").closest("#main-content");
|
||||||
|
expect(content).not.toBeNull();
|
||||||
|
expect(content).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
|
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
|
||||||
pathname = "/suggestions";
|
pathname = "/suggestions";
|
||||||
permissions = ["inspections:create"];
|
permissions = ["inspections:create"];
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import { BottomNav } from "./bottom-nav";
|
|||||||
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
|
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
|
||||||
import { PageTransition } from "./page-transition";
|
import { PageTransition } from "./page-transition";
|
||||||
|
|
||||||
|
/** Id of the page content, the target of the skip link. */
|
||||||
|
const MAIN_CONTENT_ID = "main-content";
|
||||||
|
|
||||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -69,6 +72,18 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
// `position: sticky` and the StickySubmit save bar would never stick.
|
// `position: sticky` and the StickySubmit save bar would never stick.
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
||||||
|
<a
|
||||||
|
href={`#${MAIN_CONTENT_ID}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
// The hash alone would not move the focus into a Next.js page reliably.
|
||||||
|
event.preventDefault();
|
||||||
|
pageContentRef.current?.focus();
|
||||||
|
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
||||||
|
}}
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)] focus:ring-3 focus:ring-ring/50 focus:outline-none"
|
||||||
|
>
|
||||||
|
Zum Inhalt springen
|
||||||
|
</a>
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
||||||
@@ -117,6 +132,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||||||
syncEnabled={sessionMode === "online"}
|
syncEnabled={sessionMode === "online"}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
id={MAIN_CONTENT_ID}
|
||||||
ref={pageContentRef}
|
ref={pageContentRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
|
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
|
||||||
|
|||||||
@@ -83,13 +83,30 @@ describe("DisposalDialog", () => {
|
|||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Die Menge ist zu klein");
|
expect(await screen.findByRole("alert")).toHaveTextContent("Die Menge ist zu klein");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the negative-rest warning with a symbol, the word Negativ and red text", async () => {
|
it("marks the negative-rest warning with a symbol, the resulting rest and red text", async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
await fill("20", "Verschüttet");
|
await fill("20", "Verschüttet");
|
||||||
|
|
||||||
const warning = screen.getByText(/mehr als der Rest/i);
|
const warning = screen.getByText(/mehr als der Rest/i);
|
||||||
expect(warning.closest("span.text-destructive")).not.toBeNull();
|
expect(warning.closest("span.text-destructive")).not.toBeNull();
|
||||||
expect(warning).toHaveTextContent("Negativ");
|
expect(warning).toHaveTextContent("Mehr als der Rest – der Bestand wird negativ (−8 ml).");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns before the reason is filled in, and not for a quantity within the rest", async () => {
|
||||||
|
renderDialog();
|
||||||
|
await fill("20", "");
|
||||||
|
expect(screen.getByText(/mehr als der Rest/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.clear(screen.getByLabelText(/^Menge/));
|
||||||
|
await userEvent.type(screen.getByLabelText(/^Menge/), "12");
|
||||||
|
expect(screen.queryByText(/mehr als der Rest/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the reason as Pflicht", async () => {
|
||||||
|
renderDialog();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Entsorgung" }));
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/^Grund/)).toHaveAccessibleName(/Pflicht/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses an ambiguous thousands dot", async () => {
|
it("refuses an ambiguous thousands dot", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { AlertTriangle, Trash2 } from "lucide-react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { bookDisposal } from "@/lib/medicines/api";
|
import { bookDisposal } from "@/lib/medicines/api";
|
||||||
import { decimalMessage, formatQuantity, MEDICINE_UNIT_LABELS, parseDecimal } from "@/lib/medicines/format";
|
import {
|
||||||
|
decimalMessage,
|
||||||
|
formatQuantity,
|
||||||
|
MEDICINE_UNIT_LABELS,
|
||||||
|
parseDecimal,
|
||||||
|
roundToThousandths,
|
||||||
|
} from "@/lib/medicines/format";
|
||||||
import type { MedicineUnit } from "@/lib/medicines/types";
|
import type { MedicineUnit } from "@/lib/medicines/types";
|
||||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||||
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
import { useAttemptKey } from "@/lib/medicines/use-attempt-key";
|
||||||
@@ -86,7 +92,9 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
|||||||
|
|
||||||
const problem = problemWith(quantityText, reason);
|
const problem = problemWith(quantityText, reason);
|
||||||
const quantity = parseDecimal(quantityText);
|
const quantity = parseDecimal(quantityText);
|
||||||
const exceedsRest = problem === null && quantity > remaining;
|
// Warn as soon as the quantity is valid, even before the reason is filled in.
|
||||||
|
const exceedsRest = Number.isFinite(quantity) && quantity > remaining;
|
||||||
|
const restAfter = roundToThousandths(remaining - quantity);
|
||||||
const note = problem ?? "";
|
const note = problem ?? "";
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -127,7 +135,10 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="disposal-reason">Grund</Label>
|
<Label htmlFor="disposal-reason">
|
||||||
|
Grund
|
||||||
|
<span className="text-xs font-normal text-foreground/80">Pflicht</span>
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="disposal-reason"
|
id="disposal-reason"
|
||||||
className="h-11 sm:h-11"
|
className="h-11 sm:h-11"
|
||||||
@@ -142,7 +153,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
|||||||
{exceedsRest ? (
|
{exceedsRest ? (
|
||||||
<span className="inline-flex items-start gap-1 font-semibold text-destructive">
|
<span className="inline-flex items-start gap-1 font-semibold text-destructive">
|
||||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
Negativ: Das ist mehr als der Rest ({formatQuantity(remaining, unit)}). Der Rest wird negativ.
|
Mehr als der Rest – der Bestand wird negativ ({formatQuantity(restAfter, unit)}).
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ function LotFacts({ lot }: { lot: LotDetailView }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below `sm` a row becomes a small card (two columns, the reason underneath)
|
||||||
|
* instead of a table that needs sideways scrolling.
|
||||||
|
*/
|
||||||
|
const BOOK_ROW = "max-sm:grid max-sm:grid-cols-2 max-sm:items-baseline max-sm:gap-x-3 max-sm:gap-y-1 max-sm:px-3 max-sm:py-3";
|
||||||
|
const BOOK_CELL = "max-sm:p-0 max-sm:whitespace-normal";
|
||||||
|
const BOOK_LABELLED =
|
||||||
|
"max-sm:before:mr-1 max-sm:before:text-xs max-sm:before:font-normal max-sm:before:text-muted-foreground max-sm:before:content-[attr(data-label)_':']";
|
||||||
|
|
||||||
function MovementBook({ lot }: { lot: LotDetailView }) {
|
function MovementBook({ lot }: { lot: LotDetailView }) {
|
||||||
const rows = buildBook(lot);
|
const rows = buildBook(lot);
|
||||||
return (
|
return (
|
||||||
@@ -151,8 +160,8 @@ function MovementBook({ lot }: { lot: LotDetailView }) {
|
|||||||
Bewegungen
|
Bewegungen
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-x-auto rounded-xl border">
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
<Table aria-label="Bewegungsbuch">
|
<Table aria-label="Bewegungsbuch" className="max-sm:block">
|
||||||
<TableHeader>
|
<TableHeader className="max-sm:sr-only">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Datum</TableHead>
|
<TableHead>Datum</TableHead>
|
||||||
<TableHead>Art</TableHead>
|
<TableHead>Art</TableHead>
|
||||||
@@ -161,16 +170,23 @@ function MovementBook({ lot }: { lot: LotDetailView }) {
|
|||||||
<TableHead>Grund</TableHead>
|
<TableHead>Grund</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody className="max-sm:block">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<TableRow key={row.key}>
|
<TableRow key={row.key} className={BOOK_ROW}>
|
||||||
<TableCell>{formatDay(row.day)}</TableCell>
|
<TableCell className={BOOK_CELL}>{formatDay(row.day)}</TableCell>
|
||||||
<TableCell>{row.label}</TableCell>
|
<TableCell className={`${BOOK_CELL} max-sm:text-right`}>{row.label}</TableCell>
|
||||||
<TableCell className="text-right">{formatSignedQuantity(row.quantity, lot.unit)}</TableCell>
|
<TableCell className={`${BOOK_CELL} text-right`}>
|
||||||
<TableCell className="text-right">
|
{formatSignedQuantity(row.quantity, lot.unit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={`${BOOK_CELL} text-right ${BOOK_LABELLED}`} data-label="Stand danach">
|
||||||
<RestText value={row.balance} unit={lot.unit} />
|
<RestText value={row.balance} unit={lot.unit} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-normal break-words">{row.reason ?? "—"}</TableCell>
|
<TableCell
|
||||||
|
className={`${BOOK_CELL} whitespace-normal break-words max-sm:col-span-2 ${BOOK_LABELLED}`}
|
||||||
|
data-label="Grund"
|
||||||
|
>
|
||||||
|
{row.reason ?? "—"}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -47,6 +47,25 @@ describe("MedicinesPage", () => {
|
|||||||
expect(within(card).getByText("31.01.2028")).toBeInTheDocument();
|
expect(within(card).getByText("31.01.2028")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flags an expired next expiry with symbol, the word abgelaufen and the destructive colour", async () => {
|
||||||
|
vi.mocked(fetchMedicines).mockResolvedValue([
|
||||||
|
medicine({ totalRemaining: 10, nextExpiry: "2020-09-01" }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const flag = await screen.findByText(/abgelaufen/);
|
||||||
|
expect(flag).toHaveTextContent("01.09.2020 · abgelaufen");
|
||||||
|
expect(flag.closest("span.text-destructive")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a still valid expiry in plain text", async () => {
|
||||||
|
vi.mocked(fetchMedicines).mockResolvedValue([medicine({ nextExpiry: "2999-01-31" })]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("31.01.2999")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/abgelaufen/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a negative rest in red with the word Negativ", async () => {
|
it("shows a negative rest in red with the word Negativ", async () => {
|
||||||
vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]);
|
vi.mocked(fetchMedicines).mockResolvedValue([medicine({ totalRemaining: -2, unit: "ML" })]);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Pill } from "lucide-react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { fetchMedicines } from "@/lib/medicines/api";
|
import { fetchMedicines } from "@/lib/medicines/api";
|
||||||
import { formatDay } from "@/lib/medicines/format";
|
|
||||||
import type { MedicineView } from "@/lib/medicines/types";
|
import type { MedicineView } from "@/lib/medicines/types";
|
||||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
@@ -13,6 +12,7 @@ import { EntityCard } from "@/components/entity-card";
|
|||||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||||
import { PageHeader } from "@/components/page-header";
|
import { PageHeader } from "@/components/page-header";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
|
import { ExpiryText } from "@/components/medicines/expiry-text";
|
||||||
import { RestText } from "@/components/medicines/rest-text";
|
import { RestText } from "@/components/medicines/rest-text";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { NoReadAccess } from "@/components/medicines/no-read-access";
|
import { NoReadAccess } from "@/components/medicines/no-read-access";
|
||||||
@@ -96,7 +96,7 @@ function MedicineGrid({ medicines }: { medicines: MedicineView[] }) {
|
|||||||
badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>}
|
badge={medicine.active ? undefined : <Badge variant="secondary">Deaktiviert</Badge>}
|
||||||
fields={[
|
fields={[
|
||||||
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
|
{ label: "Rest", value: <RestText value={medicine.totalRemaining} unit={medicine.unit} /> },
|
||||||
{ label: "Nächster Verfall", value: formatDay(medicine.nextExpiry) },
|
{ label: "Nächster Verfall", value: <ExpiryText day={medicine.nextExpiry} /> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { SocialMediaTab } from "./social-media-tab";
|
import { SocialMediaTab } from "./social-media-tab";
|
||||||
@@ -137,4 +137,21 @@ describe("SocialMediaTab food business", () => {
|
|||||||
|
|
||||||
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null });
|
expect(vi.mocked(updateSiteSettings).mock.calls[0][0]).toMatchObject({ foodBusinessName: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("after a successful save the adopt hint is gone and focus is on the confirmation", async () => {
|
||||||
|
vi.mocked(fetchSiteSettings).mockResolvedValue({
|
||||||
|
...settings,
|
||||||
|
holderName: "Max Imker",
|
||||||
|
holderAddress: "Dorfstraße 1, 12345 Ort",
|
||||||
|
});
|
||||||
|
renderTab();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Vom Bienenhalter übernehmen" }));
|
||||||
|
expect(screen.getByText(/noch nicht gespeichert/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
const confirmation = await screen.findByText(/^Gespeichert\./);
|
||||||
|
expect(screen.queryByText(/noch nicht gespeichert/)).not.toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(confirmation).toHaveFocus());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { UserRound } from "lucide-react";
|
import { UserRound } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -28,12 +28,17 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
settings.foodBusinessAddress ?? "",
|
settings.foodBusinessAddress ?? "",
|
||||||
);
|
);
|
||||||
const [adoptNote, setAdoptNote] = useState<string | null>(null);
|
const [adoptNote, setAdoptNote] = useState<string | null>(null);
|
||||||
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
const savedRef = useRef<HTMLParagraphElement>(null);
|
||||||
const [originCountry, setOriginCountry] = useState(settings.originCountry);
|
const [originCountry, setOriginCountry] = useState(settings.originCountry);
|
||||||
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
|
const [originCountryError, setOriginCountryError] = useState<string | null>(null);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: updateSiteSettings,
|
mutationFn: updateSiteSettings,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// The "adopted, not saved yet" hint is true no longer.
|
||||||
|
setAdoptNote(null);
|
||||||
|
setIsSaved(true);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
|
void queryClient.invalidateQueries({ queryKey: ["site-settings"] });
|
||||||
toast.success("Website-Einstellungen gespeichert");
|
toast.success("Website-Einstellungen gespeichert");
|
||||||
},
|
},
|
||||||
@@ -41,6 +46,11 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")),
|
toast.error(saveErrorText(cause, "Einstellungen konnten nicht gespeichert werden")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// After saving, focus moves to the confirmation: the button keeps no focus on a changed page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSaved) savedRef.current?.focus();
|
||||||
|
}, [isSaved]);
|
||||||
|
|
||||||
// Fills the form fields only; nothing is saved until „Speichern“.
|
// Fills the form fields only; nothing is saved until „Speichern“.
|
||||||
function adoptFromHolder() {
|
function adoptFromHolder() {
|
||||||
if (holderName.trim() === "" && holderAddress.trim() === "") {
|
if (holderName.trim() === "" && holderAddress.trim() === "") {
|
||||||
@@ -49,11 +59,13 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
}
|
}
|
||||||
setFoodBusinessName(holderName);
|
setFoodBusinessName(holderName);
|
||||||
setFoodBusinessAddress(holderAddress);
|
setFoodBusinessAddress(holderAddress);
|
||||||
|
setIsSaved(false);
|
||||||
setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern.");
|
setAdoptNote("Übernommen, noch nicht gespeichert. Prüfe die Angaben und klicke auf Speichern.");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setIsSaved(false);
|
||||||
// NOT NULL in the database: it can be changed, not emptied.
|
// NOT NULL in the database: it can be changed, not emptied.
|
||||||
if (originCountry.trim().length < 2) {
|
if (originCountry.trim().length < 2) {
|
||||||
setOriginCountryError("Bitte ein Land angeben.");
|
setOriginCountryError("Bitte ein Land angeben.");
|
||||||
@@ -76,6 +88,16 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) {
|
|||||||
<Card className="max-w-lg">
|
<Card className="max-w-lg">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
{isSaved ? (
|
||||||
|
<p
|
||||||
|
ref={savedRef}
|
||||||
|
role="status"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="rounded-lg bg-muted px-3 py-2 text-sm font-medium focus:outline-2 focus:outline-ring"
|
||||||
|
>
|
||||||
|
Gespeichert. Die Angaben gelten ab jetzt.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link
|
Wird auf jeder Herkunftsseite (/herkunft/…) angezeigt. Feld leer lassen, um einen Link
|
||||||
zu entfernen.
|
zu entfernen.
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import type { LabelElement } from "@/lib/label-templates/types";
|
||||||
|
import { LabelCanvas } from "./label-canvas";
|
||||||
|
|
||||||
|
function textElement(overrides: Partial<Extract<LabelElement, { type: "text" }>>): LabelElement {
|
||||||
|
return {
|
||||||
|
id: "t1",
|
||||||
|
type: "text",
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
width: 40,
|
||||||
|
height: 8,
|
||||||
|
text: "Honig",
|
||||||
|
placeholderKey: null,
|
||||||
|
fontSize: 8,
|
||||||
|
fontFamily: "sans",
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
color: "#000000",
|
||||||
|
align: "left",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCanvas(elements: LabelElement[]) {
|
||||||
|
render(
|
||||||
|
<LabelCanvas
|
||||||
|
widthMm={90}
|
||||||
|
heightMm={35}
|
||||||
|
elements={elements}
|
||||||
|
selectedId={null}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onChangeElement={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LabelCanvas", () => {
|
||||||
|
it("shows a sample value for a placeholder, not the raw token", () => {
|
||||||
|
renderCanvas([textElement({ placeholderKey: "jarSize" })]);
|
||||||
|
|
||||||
|
const shown = screen.getByText("500 g");
|
||||||
|
expect(shown).toHaveAttribute("data-placeholder", "jarSize");
|
||||||
|
expect(shown).toHaveAttribute("title", expect.stringContaining("{{jarSize}}"));
|
||||||
|
expect(screen.queryByText("{{jarSize}}")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the typed text of a plain text element", () => {
|
||||||
|
renderCanvas([textElement({ text: "Honig" })]);
|
||||||
|
|
||||||
|
expect(screen.getByText("Honig")).not.toHaveAttribute("data-placeholder");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
LabelElement,
|
LabelElement,
|
||||||
LabelFontFamily,
|
LabelFontFamily,
|
||||||
} from "@/lib/label-templates/types";
|
} from "@/lib/label-templates/types";
|
||||||
|
import { PLACEHOLDER_SAMPLES } from "@/lib/label-templates/placeholder-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const BASE_SCALE = 4; // px per mm at 100% zoom
|
export const BASE_SCALE = 4; // px per mm at 100% zoom
|
||||||
@@ -182,12 +183,17 @@ function boxFor(
|
|||||||
function renderContent(element: LabelElement, scale: number) {
|
function renderContent(element: LabelElement, scale: number) {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text": {
|
case "text": {
|
||||||
const display = element.placeholderKey
|
// A placeholder shows a sample value (dotted underline, token in the tooltip).
|
||||||
? `{{${element.placeholderKey}}}`
|
const key = element.placeholderKey;
|
||||||
: element.text || "Text";
|
const display = key ? PLACEHOLDER_SAMPLES[key] : element.text || "Text";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="size-full overflow-hidden whitespace-pre-wrap"
|
className={cn(
|
||||||
|
"size-full overflow-hidden whitespace-pre-wrap",
|
||||||
|
key && "underline decoration-dotted decoration-1 underline-offset-2",
|
||||||
|
)}
|
||||||
|
title={key ? `Platzhalter {{${key}}} – Beispielwert` : undefined}
|
||||||
|
data-placeholder={key ?? undefined}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: FONT_CSS[element.fontFamily],
|
fontFamily: FONT_CSS[element.fontFamily],
|
||||||
fontSize: element.fontSize * PT_TO_MM * scale,
|
fontSize: element.fontSize * PT_TO_MM * scale,
|
||||||
|
|||||||
+30
@@ -223,3 +223,33 @@ describe("LabelDesigner compliance check", () => {
|
|||||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 }));
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ minXHeightMm: 0.9 }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LabelDesigner compliance panel toggle", () => {
|
||||||
|
const listeners: (() => void)[] = [];
|
||||||
|
const query = {
|
||||||
|
matches: false,
|
||||||
|
addEventListener: vi.fn((_type: string, listener: () => void) => listeners.push(listener)),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
listeners.length = 0;
|
||||||
|
query.matches = false;
|
||||||
|
vi.mocked(apiFetch).mockReset();
|
||||||
|
vi.mocked(apiFetch).mockResolvedValue(RESULT);
|
||||||
|
vi.stubGlobal("matchMedia", vi.fn(() => query));
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("reopens a panel collapsed on a phone once the window reaches lg", async () => {
|
||||||
|
const { container } = render(<LabelDesigner initial={INITIAL} onSubmit={vi.fn()} submitLabel="Speichern" />);
|
||||||
|
const details = container.querySelector("details") as HTMLDetailsElement;
|
||||||
|
await userEvent.click(screen.getByText("Pflichtangaben prüfen"));
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
|
||||||
|
query.matches = true;
|
||||||
|
act(() => listeners.forEach((listener) => listener()));
|
||||||
|
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+222
-186
@@ -157,8 +157,31 @@ interface LabelDesignerProps {
|
|||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tailwind `lg`: from here the compliance panel sits in the side column and has no toggle. */
|
||||||
|
const LG_QUERY = "(min-width: 64rem)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open state of the collapsible compliance panel. The toggle exists only below
|
||||||
|
* lg, so a panel collapsed on a phone reopens once the window reaches lg
|
||||||
|
* (rotation, resize); otherwise it would stay hidden with no way back.
|
||||||
|
*/
|
||||||
|
function useCompliancePanelOpen(): [boolean, (open: boolean) => void] {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
const query = window.matchMedia(LG_QUERY);
|
||||||
|
const reopen = () => {
|
||||||
|
if (query.matches) setOpen(true);
|
||||||
|
};
|
||||||
|
query.addEventListener("change", reopen);
|
||||||
|
return () => query.removeEventListener("change", reopen);
|
||||||
|
}, []);
|
||||||
|
return [open, setOpen];
|
||||||
|
}
|
||||||
|
|
||||||
export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerProps) {
|
export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [complianceOpen, setComplianceOpen] = useCompliancePanelOpen();
|
||||||
const [name, setName] = useState(initial?.name ?? "");
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
|
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
|
||||||
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
|
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
|
||||||
@@ -432,8 +455,168 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">Elemente</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-11"
|
||||||
|
render={
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Importieren
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportFile}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Exportieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-11"
|
||||||
|
onClick={() => addElement(newTextElement())}
|
||||||
|
>
|
||||||
|
<Type className="size-4" />
|
||||||
|
Text
|
||||||
|
</Button>
|
||||||
|
{/* An action menu, not a field: it never holds a value, so the
|
||||||
|
trigger keeps saying what it does instead of showing the key
|
||||||
|
just inserted. */}
|
||||||
|
<Select
|
||||||
|
value={null}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const key = PLACEHOLDER_KEYS.find((candidate) => candidate === value);
|
||||||
|
if (key) handlePlaceholderInsert(key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
|
||||||
|
<SelectValue>Platzhalter einfügen</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLACEHOLDER_KEYS.map((key) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{PLACEHOLDER_LABELS[key]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-11"
|
||||||
|
onClick={() =>
|
||||||
|
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QrCode className="size-4" />
|
||||||
|
QR-Code
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-11"
|
||||||
|
onClick={() =>
|
||||||
|
addElement({
|
||||||
|
id: newId(),
|
||||||
|
type: "line",
|
||||||
|
x: 5,
|
||||||
|
y: 5,
|
||||||
|
length: 40,
|
||||||
|
thickness: 1,
|
||||||
|
color: "#c9a34e",
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Minus className="size-4" />
|
||||||
|
Linie
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
Bild/Logo
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
|
||||||
|
</label>
|
||||||
|
} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="min-h-11"
|
||||||
|
aria-disabled={canInsertBlock ? undefined : true}
|
||||||
|
aria-describedby={canInsertBlock ? undefined : insertHintId}
|
||||||
|
onClick={() => {
|
||||||
|
if (canInsertBlock) insertComplianceBlock();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
Pflichtangaben-Block einfügen
|
||||||
|
</Button>
|
||||||
|
{canInsertBlock ? null : (
|
||||||
|
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
|
||||||
|
Geht, sobald die erste Prüfung da ist.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={zoom <= ZOOM_MIN}
|
||||||
|
onClick={() => setZoom((current) => nextZoomStep(current, -1))}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
>
|
||||||
|
<ZoomOut className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<span className="w-12 text-center text-sm text-muted-foreground">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={zoom >= ZOOM_MAX}
|
||||||
|
onClick={() => setZoom((current) => nextZoomStep(current, 1))}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
>
|
||||||
|
<ZoomIn className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
|
||||||
|
<LabelCanvas
|
||||||
|
widthMm={widthMm}
|
||||||
|
heightMm={heightMm}
|
||||||
|
elements={elements}
|
||||||
|
selectedId={selectedId}
|
||||||
|
zoom={zoom}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onChangeElement={changeElement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Vorlage</CardTitle>
|
<CardTitle className="text-base">Vorlage</CardTitle>
|
||||||
@@ -622,196 +805,49 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
|
||||||
<CardTitle className="text-base">Elemente</CardTitle>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="min-h-11"
|
|
||||||
render={
|
|
||||||
<label className="cursor-pointer">
|
|
||||||
<Upload className="size-4" />
|
|
||||||
Importieren
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="application/json"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleImportFile}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button type="button" variant="ghost" size="sm" className="min-h-11" onClick={handleExport}>
|
|
||||||
<Download className="size-4" />
|
|
||||||
Exportieren
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="min-h-11"
|
|
||||||
onClick={() => addElement(newTextElement())}
|
|
||||||
>
|
|
||||||
<Type className="size-4" />
|
|
||||||
Text
|
|
||||||
</Button>
|
|
||||||
{/* An action menu, not a field: it never holds a value, so the
|
|
||||||
trigger keeps saying what it does instead of showing the key
|
|
||||||
just inserted. */}
|
|
||||||
<Select
|
|
||||||
value={null}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const key = PLACEHOLDER_KEYS.find((candidate) => candidate === value);
|
|
||||||
if (key) handlePlaceholderInsert(key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-h-11 w-auto" size="sm" aria-label="Platzhalter einfügen">
|
|
||||||
<SelectValue>Platzhalter einfügen</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PLACEHOLDER_KEYS.map((key) => (
|
|
||||||
<SelectItem key={key} value={key}>
|
|
||||||
{PLACEHOLDER_LABELS[key]}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="min-h-11"
|
|
||||||
onClick={() =>
|
|
||||||
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<QrCode className="size-4" />
|
|
||||||
QR-Code
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="min-h-11"
|
|
||||||
onClick={() =>
|
|
||||||
addElement({
|
|
||||||
id: newId(),
|
|
||||||
type: "line",
|
|
||||||
x: 5,
|
|
||||||
y: 5,
|
|
||||||
length: 40,
|
|
||||||
thickness: 1,
|
|
||||||
color: "#c9a34e",
|
|
||||||
orientation: "horizontal",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Minus className="size-4" />
|
|
||||||
Linie
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" className="min-h-11" render={
|
|
||||||
<label className="cursor-pointer">
|
|
||||||
<ImageIcon className="size-4" />
|
|
||||||
Bild/Logo
|
|
||||||
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
|
|
||||||
</label>
|
|
||||||
} />
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="min-h-11"
|
|
||||||
aria-disabled={canInsertBlock ? undefined : true}
|
|
||||||
aria-describedby={canInsertBlock ? undefined : insertHintId}
|
|
||||||
onClick={() => {
|
|
||||||
if (canInsertBlock) insertComplianceBlock();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="size-4" />
|
|
||||||
Pflichtangaben-Block einfügen
|
|
||||||
</Button>
|
|
||||||
{canInsertBlock ? null : (
|
|
||||||
<p id={insertHintId} className="w-full text-xs text-muted-foreground">
|
|
||||||
Geht, sobald die erste Prüfung da ist.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<LabelCompliancePanel
|
|
||||||
result={check.result}
|
|
||||||
pending={check.pending}
|
|
||||||
error={check.error}
|
|
||||||
stale={check.stale}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={zoom <= ZOOM_MIN}
|
|
||||||
onClick={() => setZoom((current) => nextZoomStep(current, -1))}
|
|
||||||
>
|
|
||||||
<ZoomOut className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<span className="w-12 text-center text-sm text-muted-foreground">
|
|
||||||
{Math.round(zoom * 100)}%
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={zoom >= ZOOM_MAX}
|
|
||||||
onClick={() => setZoom((current) => nextZoomStep(current, 1))}
|
|
||||||
>
|
|
||||||
<ZoomIn className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
|
|
||||||
<LabelCanvas
|
|
||||||
widthMm={widthMm}
|
|
||||||
heightMm={heightMm}
|
|
||||||
elements={elements}
|
|
||||||
selectedId={selectedId}
|
|
||||||
zoom={zoom}
|
|
||||||
onSelect={setSelectedId}
|
|
||||||
onChangeElement={changeElement}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
|
<Button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
|
||||||
{isSubmitting ? "Speichert…" : submitLabel}
|
{isSubmitting ? "Speichert…" : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="sticky top-(--header-clearance)">
|
<div className="flex flex-col gap-4 lg:sticky lg:top-(--header-clearance) lg:max-h-[calc(100dvh-var(--header-clearance)-1rem)] lg:overflow-y-auto">
|
||||||
<CardHeader>
|
<details
|
||||||
<CardTitle className="text-base">Eigenschaften</CardTitle>
|
open={complianceOpen}
|
||||||
</CardHeader>
|
onToggle={(event) => setComplianceOpen(event.currentTarget.open)}
|
||||||
<CardContent>
|
className="group rounded-xl"
|
||||||
{selectedElement ? (
|
>
|
||||||
<ElementPropertiesPanel
|
<summary className="min-h-11 cursor-pointer list-none rounded-xl border bg-card px-4 py-3 text-sm font-medium group-open:mb-3 lg:hidden">
|
||||||
element={selectedElement}
|
Pflichtangaben prüfen
|
||||||
onChange={(patch) => changeElement(selectedElement.id, patch)}
|
</summary>
|
||||||
onDelete={() => deleteElement(selectedElement.id)}
|
<LabelCompliancePanel
|
||||||
onDuplicate={() => duplicateElement(selectedElement.id)}
|
result={check.result}
|
||||||
/>
|
pending={check.pending}
|
||||||
) : (
|
error={check.error}
|
||||||
<p className="text-sm text-muted-foreground">
|
stale={check.stale}
|
||||||
Element im Etikett anklicken, um es zu bearbeiten. Entf/Backspace löscht das
|
/>
|
||||||
ausgewählte Element – „Rückgängig“ im Hinweis holt es zurück.
|
|
||||||
</p>
|
</details>
|
||||||
)}
|
<Card>
|
||||||
</CardContent>
|
<CardHeader>
|
||||||
</Card>
|
<CardTitle className="text-base">Eigenschaften</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{selectedElement ? (
|
||||||
|
<ElementPropertiesPanel
|
||||||
|
element={selectedElement}
|
||||||
|
onChange={(patch) => changeElement(selectedElement.id, patch)}
|
||||||
|
onDelete={() => deleteElement(selectedElement.id)}
|
||||||
|
onDuplicate={() => duplicateElement(selectedElement.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Element im Etikett anklicken, um es zu bearbeiten. Entf/Backspace löscht das
|
||||||
|
ausgewählte Element – „Rückgängig“ im Hinweis holt es zurück.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -883,6 +883,41 @@ describe("NewTreatmentPage", () => {
|
|||||||
expect(createTreatment).not.toHaveBeenCalled();
|
expect(createTreatment).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a dose with four decimals in single and bulk mode before anything is sent", async () => {
|
||||||
|
renderPage();
|
||||||
|
await chooseMedicine("Oxuvar");
|
||||||
|
await screen.findByText(SOURCE_LINE);
|
||||||
|
await userEvent.clear(screen.getByLabelText(/Dosis/));
|
||||||
|
await userEvent.type(screen.getByLabelText(/Dosis/), "1,2345");
|
||||||
|
|
||||||
|
expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/^Bucht/)).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", SAVE));
|
||||||
|
expect(createTreatment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a dose with four decimals in bulk mode", async () => {
|
||||||
|
renderPage();
|
||||||
|
await chooseBulkApiary();
|
||||||
|
await chooseMedicine("Oxuvar");
|
||||||
|
await screen.findByText(SOURCE_LINE);
|
||||||
|
await userEvent.clear(screen.getByLabelText(/Dosis/));
|
||||||
|
await userEvent.type(screen.getByLabelText(/Dosis/), "1,2345");
|
||||||
|
await userEvent.click(screen.getByRole("button", SAVE));
|
||||||
|
|
||||||
|
expect(screen.getByText("Höchstens 3 Nachkommastellen.")).toBeInTheDocument();
|
||||||
|
expect(createTreatmentsBulk).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("words the withdrawal hint in the plural when several colonies are chosen", async () => {
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByText(/aus diesem Volk kein Honig/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await chooseBulkApiary();
|
||||||
|
|
||||||
|
expect(screen.getByText(/aus diesen Völkern kein Honig/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("a changed body after an error gets a new Idempotency-Key", async () => {
|
it("a changed body after an error gets a new Idempotency-Key", async () => {
|
||||||
vi.mocked(createTreatment).mockRejectedValue(
|
vi.mocked(createTreatment).mockRejectedValue(
|
||||||
new ApiError(400, "Diese Angabe kommt aus der Packung"),
|
new ApiError(400, "Diese Angabe kommt aus der Packung"),
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ export default function NewTreatmentPage() {
|
|||||||
)}
|
)}
|
||||||
<Field
|
<Field
|
||||||
label="Wartezeit (Tage)"
|
label="Wartezeit (Tage)"
|
||||||
hint="Laut Packungsbeilage des Mittels – solange darf aus diesem Volk kein Honig geerntet werden."
|
hint={`Laut Packungsbeilage des Mittels – solange darf aus ${isBulk && values.bulkColonyIds.length > 1 ? "diesen Völkern" : "diesem Volk"} kein Honig geerntet werden.`}
|
||||||
>
|
>
|
||||||
{({ id, describedBy }) => (
|
{({ id, describedBy }) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { NoComplianceAccess } from "./no-compliance-access";
|
||||||
|
|
||||||
|
describe("NoComplianceAccess", () => {
|
||||||
|
it("names the area in a heading above the missing-right text", () => {
|
||||||
|
render(<NoComplianceAccess />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "Prüfbereitschaft" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: "Zurück zum Start" })).toHaveAttribute("href", "/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
export function NoComplianceAccess() {
|
export function NoComplianceAccess() {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
||||||
|
<h1 className="font-heading text-xl font-semibold tracking-tight">Prüfbereitschaft</h1>
|
||||||
<p role="status" className="text-sm">
|
<p role="status" className="text-sm">
|
||||||
Dazu fehlt dir das Recht.
|
Dazu fehlt dir das Recht.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function Field({
|
|||||||
const Caption = group ? "span" : "label";
|
const Caption = group ? "span" : "label";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
<div className={cn("flex min-w-0 flex-col gap-1.5", className)}>
|
||||||
<Caption
|
<Caption
|
||||||
id={`${id}-label`}
|
id={`${id}-label`}
|
||||||
htmlFor={group ? undefined : id}
|
htmlFor={group ? undefined : id}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface FormSectionProps {
|
|||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
fieldCount?: number;
|
fieldCount?: number;
|
||||||
|
/** Shows the content although the section is folded, e.g. while a field inside has a problem. */
|
||||||
|
forceOpen?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +29,11 @@ export function FormSection({
|
|||||||
collapsible = false,
|
collapsible = false,
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
fieldCount,
|
fieldCount,
|
||||||
|
forceOpen = false,
|
||||||
children,
|
children,
|
||||||
}: FormSectionProps) {
|
}: FormSectionProps) {
|
||||||
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
|
const [openState, setOpen] = useState(collapsible ? defaultOpen : true);
|
||||||
|
const open = openState || forceOpen;
|
||||||
const heading = (
|
const heading = (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{step !== undefined ? (
|
{step !== undefined ? (
|
||||||
@@ -45,7 +49,7 @@ export function FormSection({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className="flex flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
|
<fieldset className="flex min-w-0 flex-col gap-4 border-t border-border/60 pt-4 first:border-t-0 first:pt-0">
|
||||||
{collapsible ? (
|
{collapsible ? (
|
||||||
<legend className="contents">
|
<legend className="contents">
|
||||||
<button
|
<button
|
||||||
@@ -77,7 +81,7 @@ export function FormSection({
|
|||||||
{description && open ? (
|
{description && open ? (
|
||||||
<p className="-mt-2 text-sm text-muted-foreground">{description}</p>
|
<p className="-mt-2 text-sm text-muted-foreground">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{open ? <div className="flex flex-col gap-4">{children}</div> : null}
|
{open ? <div className="flex min-w-0 flex-col gap-4">{children}</div> : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,4 +124,14 @@ describe("RecordSelect", () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith("");
|
expect(onChange).toHaveBeenCalledWith("");
|
||||||
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
|
expect(screen.getByRole("combobox")).toHaveTextContent("Kein Bienenstand");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("truncates a long chosen label in the trigger instead of widening the form", () => {
|
||||||
|
render(<Harness initial="cmg3xabc" />);
|
||||||
|
|
||||||
|
const trigger = screen.getByRole("combobox");
|
||||||
|
expect(trigger.className).toContain("min-w-0");
|
||||||
|
const label = screen.getByText("Obstwiese");
|
||||||
|
expect(label.className).toContain("truncate");
|
||||||
|
expect(label.className).toContain("min-w-0");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -82,12 +82,15 @@ export function RecordSelect<T extends { id: string }>({
|
|||||||
id={id}
|
id={id}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
data-field={dataField}
|
data-field={dataField}
|
||||||
className={cn("h-11 w-full", className)}
|
className={cn("h-11 w-full min-w-0", className)}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={placeholder}>
|
<SelectValue placeholder={placeholder} className="min-w-0">
|
||||||
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
|
{/* A long label is cut with an ellipsis; the list shows it in full. */}
|
||||||
|
<span className="min-w-0 truncate" data-slot="record-select-label">
|
||||||
|
{triggerLabel({ value, labelItems, getLabel, placeholder, noneLabel })}
|
||||||
|
</span>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { todayLocalIsoDate } from "@/lib/dates";
|
||||||
|
import { formatDay } from "@/lib/medicines/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "best before" day of a medicine or pack. A day in the past is red AND
|
||||||
|
* carries a symbol and the word "abgelaufen", so the state does not rely on
|
||||||
|
* colour alone. The day itself still counts as valid.
|
||||||
|
*/
|
||||||
|
export function ExpiryText({ day, today }: { day: string | null; today?: string }) {
|
||||||
|
if (day && day < (today ?? todayLocalIsoDate())) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex max-w-full items-start gap-1 font-semibold whitespace-normal text-destructive">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
{formatDay(day)} · abgelaufen
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{formatDay(day)}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { NoReadAccess } from "./no-read-access";
|
||||||
|
|
||||||
|
describe("NoReadAccess", () => {
|
||||||
|
it("names the area in a heading above the missing-right text", () => {
|
||||||
|
render(<NoReadAccess />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("heading", { level: 1, name: "Tierarzneimittel" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Dazu fehlt dir das Recht.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
export function NoReadAccess() {
|
export function NoReadAccess() {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
<main className="flex flex-col gap-3 p-4 sm:p-6">
|
||||||
|
<h1 className="font-heading text-xl font-semibold tracking-tight">Tierarzneimittel</h1>
|
||||||
<p role="status" className="text-sm">
|
<p role="status" className="text-sm">
|
||||||
Dazu fehlt dir das Recht.
|
Dazu fehlt dir das Recht.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MEDICINE_UNIT_LABELS } from "@/lib/medicines/format";
|
|||||||
import type { LotView, MedicineView } from "@/lib/medicines/types";
|
import type { LotView, MedicineView } from "@/lib/medicines/types";
|
||||||
import { fetchApiaryWeather } from "@/lib/weather/api";
|
import { fetchApiaryWeather } from "@/lib/weather/api";
|
||||||
import { bookingLine } from "@/lib/treatments/booking-line";
|
import { bookingLine } from "@/lib/treatments/booking-line";
|
||||||
import { parsedDose } from "@/lib/treatments/dose";
|
import { doseFormatError, parsedDose } from "@/lib/treatments/dose";
|
||||||
import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
|
import { lotLabel, lotWarnings } from "@/lib/treatments/lot-text";
|
||||||
import { temperatureHint } from "@/lib/treatments/temperature-hint";
|
import { temperatureHint } from "@/lib/treatments/temperature-hint";
|
||||||
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
|
import type { CatalogueFilled } from "@/lib/treatments/use-catalogue-prefill";
|
||||||
@@ -51,6 +51,8 @@ export interface MedicineLotFieldsProps {
|
|||||||
doseQuantity: string;
|
doseQuantity: string;
|
||||||
productError?: string;
|
productError?: string;
|
||||||
doseError?: string;
|
doseError?: string;
|
||||||
|
/** A note under the dose, e.g. that it was cleared because the unit changed. */
|
||||||
|
doseNotice?: string;
|
||||||
/** Which fields carry the note „aus Katalog“. */
|
/** Which fields carry the note „aus Katalog“. */
|
||||||
filled: CatalogueFilled;
|
filled: CatalogueFilled;
|
||||||
onProductChange: (product: string) => void;
|
onProductChange: (product: string) => void;
|
||||||
@@ -75,6 +77,7 @@ export function MedicineLotFields({
|
|||||||
doseQuantity,
|
doseQuantity,
|
||||||
productError,
|
productError,
|
||||||
doseError,
|
doseError,
|
||||||
|
doseNotice,
|
||||||
filled,
|
filled,
|
||||||
onProductChange,
|
onProductChange,
|
||||||
onMedicineChange,
|
onMedicineChange,
|
||||||
@@ -148,6 +151,7 @@ export function MedicineLotFields({
|
|||||||
hasLot={medicineLotId !== ""}
|
hasLot={medicineLotId !== ""}
|
||||||
value={doseQuantity}
|
value={doseQuantity}
|
||||||
error={doseError}
|
error={doseError}
|
||||||
|
notice={doseNotice}
|
||||||
isFromCatalogue={filled.doseQuantity}
|
isFromCatalogue={filled.doseQuantity}
|
||||||
onChange={onDoseChange}
|
onChange={onDoseChange}
|
||||||
/>
|
/>
|
||||||
@@ -226,6 +230,7 @@ function DoseField({
|
|||||||
hasLot,
|
hasLot,
|
||||||
value,
|
value,
|
||||||
error,
|
error,
|
||||||
|
notice,
|
||||||
isFromCatalogue,
|
isFromCatalogue,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -234,6 +239,7 @@ function DoseField({
|
|||||||
hasLot: boolean;
|
hasLot: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
|
notice: string | undefined;
|
||||||
isFromCatalogue: boolean | undefined;
|
isFromCatalogue: boolean | undefined;
|
||||||
onChange: (dose: string) => void;
|
onChange: (dose: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -242,7 +248,12 @@ function DoseField({
|
|||||||
? "Je Volk. Ohne Dosis wird nichts abgebucht."
|
? "Je Volk. Ohne Dosis wird nichts abgebucht."
|
||||||
: "Je Volk, wie auf der Packungsbeilage.";
|
: "Je Volk, wie auf der Packungsbeilage.";
|
||||||
return (
|
return (
|
||||||
<Field label="Dosis je Volk" id={`${idPrefix}-dose`} hint={hint} error={error}>
|
<Field
|
||||||
|
label="Dosis je Volk"
|
||||||
|
id={`${idPrefix}-dose`}
|
||||||
|
hint={hint}
|
||||||
|
error={error ?? doseFormatError(value)}
|
||||||
|
>
|
||||||
{({ id, describedBy, invalid }) => (
|
{({ id, describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -260,6 +271,11 @@ function DoseField({
|
|||||||
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
|
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<CatalogueNote id={id} shown={isFromCatalogue} />
|
<CatalogueNote id={id} shown={isFromCatalogue} />
|
||||||
|
<div role="status" aria-live="polite" className="empty:hidden">
|
||||||
|
{notice ? (
|
||||||
|
<p className="rounded-lg bg-warning/20 px-3 py-2 text-sm">{notice}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ describe("api-client", () => {
|
|||||||
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
|
expect((apiError.body as { invalidColonyIds: string[] }).invalidColonyIds).toEqual(["c1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("empty success bodies", () => {
|
||||||
|
it.each([200, 201, 202])("resolves undefined for a %i with an empty body", async (status) => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status })));
|
||||||
|
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves undefined for a 204", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 })));
|
||||||
|
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves undefined when Content-Length is 0", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(new Response("", { status: 201, headers: { "Content-Length": "0" } })),
|
||||||
|
);
|
||||||
|
await expect(apiFetch("/x", { method: "POST" })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still parses a JSON body and still rejects invalid JSON", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(201, { id: "a" })));
|
||||||
|
await expect(apiFetch("/x", { method: "POST" })).resolves.toEqual({ id: "a" });
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("<html>", { status: 200 })));
|
||||||
|
await expect(apiFetch("/x")).rejects.toBeInstanceOf(SyntaxError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
|
it("reports network-error on a failed fetch and reached-server on any HTTP answer", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||||
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
|
await expect(apiFetch("/x")).rejects.toBeInstanceOf(TypeError);
|
||||||
|
|||||||
@@ -159,9 +159,23 @@ export async function apiFetch<T>(
|
|||||||
throw await apiErrorFrom(response);
|
throw await apiErrorFrom(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
return parseSuccessBody<T>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 204, 205 and any 2xx with an empty body (e.g. a 201 from a void controller)
|
||||||
|
* yield undefined; everything else is parsed as JSON.
|
||||||
|
*/
|
||||||
|
async function parseSuccessBody<T>(response: Response): Promise<T> {
|
||||||
|
if (response.status === 204 || response.status === 205) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
}
|
}
|
||||||
|
if (response.headers.get('Content-Length') === '0') {
|
||||||
return response.json() as Promise<T>;
|
return undefined as T;
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.trim() === '') {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return JSON.parse(text) as T;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { overlappingRestrictions } from "./restriction-overlap";
|
||||||
|
import type { ApiaryRestriction } from "./restrictions-api";
|
||||||
|
|
||||||
|
function row(overrides: Partial<ApiaryRestriction>): ApiaryRestriction {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
apiaryId: "a1",
|
||||||
|
disease: "Amerikanische Faulbrut",
|
||||||
|
startsOn: "2026-10-01T00:00:00.000Z",
|
||||||
|
endsOn: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("overlappingRestrictions", () => {
|
||||||
|
it("finds an open-ended restriction at the same apiary", () => {
|
||||||
|
expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "2026-12-01" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores other apiaries", () => {
|
||||||
|
expect(overlappingRestrictions([row({ apiaryId: "a2" })], ["a1"], { startsOn: "2026-12-01" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a restriction that ended before the new start, but counts the end day itself", () => {
|
||||||
|
const ended = row({ endsOn: "2026-11-30T00:00:00.000Z" });
|
||||||
|
expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-12-01" })).toEqual([]);
|
||||||
|
expect(overlappingRestrictions([ended], ["a1"], { startsOn: "2026-11-30" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a restriction that starts after the new end", () => {
|
||||||
|
const later = row({ startsOn: "2027-01-01T00:00:00.000Z" });
|
||||||
|
expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2026-12-31" })).toEqual([]);
|
||||||
|
expect(overlappingRestrictions([later], ["a1"], { startsOn: "2026-12-01", endsOn: "2027-01-01" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing without a start day", () => {
|
||||||
|
expect(overlappingRestrictions([row({})], ["a1"], { startsOn: "" })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { toCalendarDay } from "@/lib/dates";
|
||||||
|
import type { ApiaryRestriction } from "./restrictions-api";
|
||||||
|
|
||||||
|
interface NewPeriod {
|
||||||
|
/** Calendar day "YYYY-MM-DD". */
|
||||||
|
startsOn: string;
|
||||||
|
/** Calendar day; empty or undefined = until further notice. */
|
||||||
|
endsOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The existing restrictions at the chosen apiaries whose period overlaps the
|
||||||
|
* new one (days are inclusive, no end day = open-ended). Only a hint: the
|
||||||
|
* server accepts overlapping restrictions.
|
||||||
|
*/
|
||||||
|
export function overlappingRestrictions(
|
||||||
|
existing: readonly ApiaryRestriction[],
|
||||||
|
apiaryIds: readonly string[],
|
||||||
|
period: NewPeriod,
|
||||||
|
): ApiaryRestriction[] {
|
||||||
|
if (!period.startsOn) return [];
|
||||||
|
const wanted = new Set(apiaryIds);
|
||||||
|
const newEnd = period.endsOn ? period.endsOn : null;
|
||||||
|
return existing.filter((row) => {
|
||||||
|
if (!wanted.has(row.apiaryId)) return false;
|
||||||
|
const rowEnd = row.endsOn === null ? null : toCalendarDay(row.endsOn);
|
||||||
|
const startsBeforeRowEnds = rowEnd === null || period.startsOn <= rowEnd;
|
||||||
|
const rowStartsBeforeNewEnds = newEnd === null || toCalendarDay(row.startsOn) <= newEnd;
|
||||||
|
return startsBeforeRowEnds && rowStartsBeforeNewEnds;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,3 +25,6 @@ export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; is
|
|||||||
});
|
});
|
||||||
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
|
return { restrictions: query.data ?? NONE, isLoading: query.isLoading };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The full restriction list (all states) of the restrictions page. */
|
||||||
|
export const restrictionListKey = [...complianceKey, "restrictions", "list"] as const;
|
||||||
@@ -35,3 +35,23 @@ export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
|
|||||||
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
|
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
|
||||||
lotLine: "Zeile „L Losnummer“",
|
lotLine: "Zeile „L Losnummer“",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the designer preview shows for a placeholder instead of the raw
|
||||||
|
* `{{key}}`: a plausible value, so sizes and line breaks look like the print.
|
||||||
|
*/
|
||||||
|
export const PLACEHOLDER_SAMPLES: Record<PlaceholderKey, string> = {
|
||||||
|
batchNumber: "WH-2026-0001",
|
||||||
|
honeyType: "Blütenhonig",
|
||||||
|
lotNumber: "L-0001",
|
||||||
|
bottlingDate: "15.10.2026",
|
||||||
|
bestBeforeDate: "10/2028",
|
||||||
|
jarSize: "500 g",
|
||||||
|
productName: "Wabenhain Blütenhonig",
|
||||||
|
legalName: "Blütenhonig",
|
||||||
|
originCountry: "Herkunft: Deutschland",
|
||||||
|
foodBusinessName: "Max Mustermann",
|
||||||
|
foodBusinessAddress: "Musterstr. 1\n86450 Altenmünster",
|
||||||
|
bestBeforeLine: "Mindestens haltbar bis Ende 10/2028",
|
||||||
|
lotLine: "L 0001",
|
||||||
|
};
|
||||||
@@ -247,6 +247,9 @@ describe("service worker routes", () => {
|
|||||||
"/api/v1/label-templates/t-1/print/b-1",
|
"/api/v1/label-templates/t-1/print/b-1",
|
||||||
"/api/v1/label-templates/t-1/print",
|
"/api/v1/label-templates/t-1/print",
|
||||||
"/api/v1/compliance/readiness?x=1",
|
"/api/v1/compliance/readiness?x=1",
|
||||||
|
"/api/v1/site-settings",
|
||||||
|
"/api/v1/site-settings/",
|
||||||
|
"/api/v1/Site-Settings/public",
|
||||||
])("never caches %s", (path) => {
|
])("never caches %s", (path) => {
|
||||||
expect(cachesApi(path)).toBe(false);
|
expect(cachesApi(path)).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -271,6 +274,17 @@ describe("service worker routes", () => {
|
|||||||
expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
|
expect(cachesApi("/api/v1/documents/d-1/download")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps caching the photo document lists and the bottling and treatment answers offline", () => {
|
||||||
|
for (const path of [
|
||||||
|
"/api/v1/documents?entityType=INSPECTION&entityId=i-1",
|
||||||
|
"/api/v1/bottling-batches",
|
||||||
|
"/api/v1/colonies/c-1/treatments",
|
||||||
|
"/api/v1/treatments/withdrawal-status",
|
||||||
|
]) {
|
||||||
|
expect(cachesApi(path)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps caching the unrelated routes it cached before", () => {
|
it("keeps caching the unrelated routes it cached before", () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
"/api/v1/tasks",
|
"/api/v1/tasks",
|
||||||
@@ -280,6 +294,7 @@ describe("service worker routes", () => {
|
|||||||
"/api/v1/label-templates/t-1",
|
"/api/v1/label-templates/t-1",
|
||||||
"/api/v1/compliances",
|
"/api/v1/compliances",
|
||||||
"/api/v1/veterinary-medicines-overview",
|
"/api/v1/veterinary-medicines-overview",
|
||||||
|
"/api/v1/site-settings-overview",
|
||||||
"/api/v1/apiaries/a-1/weather",
|
"/api/v1/apiaries/a-1/weather",
|
||||||
]) {
|
]) {
|
||||||
expect(cachesApi(path)).toBe(true);
|
expect(cachesApi(path)).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { doseError, doseText, parsedDose } from "./dose";
|
import { doseError, doseFormatError, doseText, parsedDose } from "./dose";
|
||||||
|
|
||||||
describe("doseError", () => {
|
describe("doseError", () => {
|
||||||
it("allows an empty dose and valid ones with up to three decimals", () => {
|
it("allows an empty dose and valid ones with up to three decimals", () => {
|
||||||
@@ -11,6 +11,12 @@ describe("doseError", () => {
|
|||||||
it("rejects more than three decimal places in German", () => {
|
it("rejects more than three decimal places in German", () => {
|
||||||
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
|
expect(doseError("0,0004")).toBe("Höchstens 3 Nachkommastellen.");
|
||||||
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
|
expect(doseError("2,5001")).toBe("Höchstens 3 Nachkommastellen.");
|
||||||
|
expect(doseError("1,2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||||
|
expect(doseError("1.2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the thousands dot", () => {
|
||||||
|
expect(doseError("1.000")).toMatch(/Tausenderpunkt/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects zero and text", () => {
|
it("rejects zero and text", () => {
|
||||||
@@ -19,7 +25,21 @@ describe("doseError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("doseFormatError", () => {
|
||||||
|
it("reports only the way the dose is written, so it can show while typing", () => {
|
||||||
|
expect(doseFormatError("1,2345")).toBe("Höchstens 3 Nachkommastellen.");
|
||||||
|
expect(doseFormatError("1.000")).toMatch(/Tausenderpunkt/);
|
||||||
|
expect(doseFormatError("0")).toBeNull();
|
||||||
|
expect(doseFormatError("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parsedDose / doseText", () => {
|
describe("parsedDose / doseText", () => {
|
||||||
|
it("never rounds a dose with more than three decimals (single and bulk booking use it)", () => {
|
||||||
|
expect(parsedDose("1,2345")).toBeNull();
|
||||||
|
expect(parsedDose("1,234")).toBe(1.234);
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips a German decimal", () => {
|
it("round-trips a German decimal", () => {
|
||||||
expect(parsedDose(doseText(2.5))).toBe(2.5);
|
expect(parsedDose(doseText(2.5))).toBe(2.5);
|
||||||
expect(doseText(null)).toBe("");
|
expect(doseText(null)).toBe("");
|
||||||
|
|||||||
@@ -1,24 +1,49 @@
|
|||||||
import { decimalMessage, parseDecimal } from "@/lib/medicines/format";
|
import {
|
||||||
|
decimalMessage,
|
||||||
|
isAmbiguousDecimal,
|
||||||
|
parseDecimal,
|
||||||
|
THOUSANDS_SEPARATOR_MESSAGE,
|
||||||
|
} from "@/lib/medicines/format";
|
||||||
|
|
||||||
/** The smallest dose the server accepts. */
|
/** The smallest dose the server accepts. */
|
||||||
export const MIN_DOSE = 0.001;
|
export const MIN_DOSE = 0.001;
|
||||||
const MAX_DECIMALS = 3;
|
const MAX_DECIMALS = 3;
|
||||||
const DECIMALS = /[.,](\d+)$/;
|
const DECIMALS = /[.,](\d+)$/;
|
||||||
|
|
||||||
|
function decimalPlaces(text: string): number {
|
||||||
|
return DECIMALS.exec(text.trim())?.[1].length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is wrong with the way the dose is written (thousands dot, too many
|
||||||
|
* decimals), or `null`. Cheap and final enough to show while typing.
|
||||||
|
*/
|
||||||
|
export function doseFormatError(text: string): string | null {
|
||||||
|
if (text.trim() === "") return null;
|
||||||
|
if (isAmbiguousDecimal(text)) return THOUSANDS_SEPARATOR_MESSAGE;
|
||||||
|
if (decimalPlaces(text) > MAX_DECIMALS) return "Höchstens 3 Nachkommastellen.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
|
/** What is wrong with the typed dose, or `null` (an empty dose is allowed: nothing is booked). */
|
||||||
export function doseError(text: string): string | null {
|
export function doseError(text: string): string | null {
|
||||||
if (text.trim() === "") return null;
|
if (text.trim() === "") return null;
|
||||||
|
const formatError = doseFormatError(text);
|
||||||
|
if (formatError) return formatError;
|
||||||
const dose = parseDecimal(text);
|
const dose = parseDecimal(text);
|
||||||
if (Number.isNaN(dose)) return decimalMessage(text, "Bitte die Dosis als Zahl angeben, z. B. 2,5.");
|
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.";
|
if (!(dose >= MIN_DOSE)) return "Die Dosis muss mindestens 0,001 sein.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The typed dose as a number, `null` when empty or not a valid number. */
|
/**
|
||||||
|
* The typed dose as a number, `null` when empty or not a valid dose. A dose
|
||||||
|
* with more than three decimals is never rounded or cut here: it is `null`,
|
||||||
|
* so no booking line, payload or patch is ever built from it.
|
||||||
|
*/
|
||||||
export function parsedDose(text: string): number | null {
|
export function parsedDose(text: string): number | null {
|
||||||
if (text.trim() === "") return null;
|
if (text.trim() === "") return null;
|
||||||
|
if (decimalPlaces(text) > MAX_DECIMALS) return null;
|
||||||
const dose = parseDecimal(text);
|
const dose = parseDecimal(text);
|
||||||
return Number.isFinite(dose) ? dose : null;
|
return Number.isFinite(dose) ? dose : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ Plan: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, Annahmen ab 59 in AR
|
|||||||
|
|
||||||
- [x] T6a (Tasks 1–13, Backend): Schema und Rechte, Tierarzneimittel mit Packungen und Bewegungsbuch, Dokumente, Etikett-Pflichtangaben mit Druck-Sperre und Probedruck, Sperrbezirk, Behördenpaket (Anzeigen, PDFs), Hinweise und Tagesauswertung, Prüfbereitschaft
|
- [x] T6a (Tasks 1–13, Backend): Schema und Rechte, Tierarzneimittel mit Packungen und Bewegungsbuch, Dokumente, Etikett-Pflichtangaben mit Druck-Sperre und Probedruck, Sperrbezirk, Behördenpaket (Anzeigen, PDFs), Hinweise und Tagesauswertung, Prüfbereitschaft
|
||||||
- [x] T6b (Tasks 14–19, Frontend): Prüfbereitschaft, Tierarzneimittel, Behandlungsmaske mit Mittel/Packung/Dosis, Etikett-Prüfung, Anzeigen und Einstellungen, Sperrbezirk mit Wächtern
|
- [x] T6b (Tasks 14–19, Frontend): Prüfbereitschaft, Tierarzneimittel, Behandlungsmaske mit Mittel/Packung/Dosis, Etikett-Prüfung, Anzeigen und Einstellungen, Sperrbezirk mit Wächtern
|
||||||
- [ ] Browserprüfung aller T6-Seiten (Desktop und 390 px, Fehler- und Leerzustände, Tastatur, `prefers-reduced-motion`)
|
- [x] Browserprüfung aller T6-Seiten (Desktop und 390 px, Fehler- und Leerzustände, Tastatur, `prefers-reduced-motion`): 0 kritisch, 3 wichtig, 19 klein, alle behoben (2026-10-15). Layout bei 390 px und 1280 px nach den Fixes noch einmal im Browser ansehen.
|
||||||
- [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt` prüfen, nach Datenprüfung in Produktion (braucht Freigabe)
|
- [ ] Folgepunkt: `packagingSize` im Backend als `@IsInt` prüfen, nach Datenprüfung in Produktion (braucht Freigabe)
|
||||||
- [ ] Frage an den Inhaber: „Wer darf Etiketten drucken?“ (heute nur ADMIN, Annahme 77)
|
- [ ] Frage an den Inhaber: „Wer darf Etiketten drucken?“ (heute nur ADMIN, Annahme 77)
|
||||||
- [ ] Rechtswerte in `ComplianceSettings` vom Inhaber prüfen lassen (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern; gegen gesetze-im-internet.de und EUR-Lex)
|
- [ ] Rechtswerte in `ComplianceSettings` vom Inhaber prüfen lassen (Fristen, Paragraphen, Ziffernhöhen, x-Höhe, Landesrecht Bayern; gegen gesetze-im-internet.de und EUR-Lex)
|
||||||
|
|||||||
Reference in new issue
Block a user