Compare commits
13
Commits
62ab080725
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda3e02a31 | ||
|
|
7025a6b589 | ||
|
|
fce9d10192 | ||
|
|
759d85dc67 | ||
|
|
6eecd57df4 | ||
|
|
476770c1ed | ||
|
|
848ce168da | ||
|
|
f307e7da66 | ||
|
|
11bccbeb50 | ||
|
|
2f95696efb | ||
|
|
208e9baaf0 | ||
|
|
6ac9eef93b | ||
|
|
ce68729233 |
No files matched your search
@@ -107,4 +107,6 @@ jobs:
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: E2E-Tests
|
||||
run: npm run test:e2e -- --ci
|
||||
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||
@@ -105,6 +105,7 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 20000
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,17 @@ describe('loadLots', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out a lot whose medicine vanished between the two reads', async () => {
|
||||
const { db } = fakeDb(
|
||||
[lotRow({ id: 'l1' }), lotRow({ id: 'gone', medicine: null })],
|
||||
[],
|
||||
);
|
||||
|
||||
const { lots } = await loadLots(db);
|
||||
|
||||
expect(lots.map((lot) => lot.id)).toEqual(['l1']);
|
||||
});
|
||||
|
||||
it('reads only active medicines', async () => {
|
||||
const { db } = fakeDb(
|
||||
[],
|
||||
|
||||
@@ -57,12 +57,16 @@ export async function loadLots(db: DbClient): Promise<ReadinessLots> {
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
// Prisma reads the medicine in a second query: a lot deleted together with
|
||||
// its medicine in between comes back with medicine null (CI run 836, 500).
|
||||
// Such a lot is gone, so it is left out.
|
||||
const present = rows.filter((row) => (row.medicine as unknown) !== null);
|
||||
const totals = await lotTotals(
|
||||
db,
|
||||
rows.map((row) => row.id),
|
||||
present.map((row) => row.id),
|
||||
);
|
||||
return {
|
||||
lots: rows.map((row): ReadinessLot => ({
|
||||
lots: present.map((row): ReadinessLot => ({
|
||||
id: row.id,
|
||||
medicineId: row.medicineId,
|
||||
medicineName: row.medicine.name,
|
||||
|
||||
@@ -91,7 +91,9 @@ describe('Notifications (e2e)', () => {
|
||||
ids[who] = user.id;
|
||||
tokens[who] = (await login(app, emails[who], password)).accessToken;
|
||||
}
|
||||
});
|
||||
// App start plus four bcrypt-12 logins exceeded the 20 s default on a
|
||||
// busy CI runner (run 826).
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const userIds = Object.values(ids);
|
||||
|
||||
@@ -13,6 +13,8 @@ RUN npm run lint && npx tsc --noEmit && npm test
|
||||
RUN npm run build
|
||||
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
|
||||
RUN npm run check:3d-budget
|
||||
# Bundle-Budget (Spec T5 §17.6): schlaegt fehl, wenn motion zusammen > 30 kB gzip ist.
|
||||
RUN npm run check:motion-budget
|
||||
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check:3d-budget": "node scripts/check-3d-budget.mjs",
|
||||
"check:motion-budget": "node scripts/check-motion-budget.mjs",
|
||||
"icons": "node scripts/generate-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Bundle-Budget fuer `motion` im DOM (Spec T5 §17.6: hoechstens 30 kB gzip).
|
||||
// Laeuft nach `next build` wie check-3d-budget.mjs; Exit 1, wenn die motion-Chunks
|
||||
// zusammen das Budget ueberschreiten oder gar kein motion-Chunk gefunden wird.
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
// Spec §17.6: hoechstens 30 kB gzip (1 kB = 1000 Byte) fuer motion insgesamt:
|
||||
// den Teil in der App-Huelle und die asynchron geladenen Features.
|
||||
export const BUDGET_BYTES = 30_000;
|
||||
|
||||
// String-Literale, die motion auch minifiziert behaelt und die kein App-Code schreibt:
|
||||
// der Stil-Schluessel der Render-Basis, das Attribut von AnimatePresence (popLayout)
|
||||
// und die Gesten-Props der Features.
|
||||
export const MOTION_MARKERS = ["transformPerspective", "data-motion-pop-id", "whileHover"];
|
||||
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
|
||||
|
||||
export function isMotionChunk(source) {
|
||||
if (THREE_MARKERS.every((marker) => source.includes(marker))) return false;
|
||||
return MOTION_MARKERS.some((marker) => source.includes(marker));
|
||||
}
|
||||
|
||||
/**
|
||||
* Misst alle motion-Chunks unter chunksDir (gzip level 9) und zaehlt sie zusammen.
|
||||
* @returns {{ ok: boolean, files: { file: string, gzipBytes: number }[], gzipBytes: number, budget: number, message: string }}
|
||||
*/
|
||||
export function checkMotionBudget(chunksDir, budget = BUDGET_BYTES) {
|
||||
const files = [];
|
||||
for (const name of readdirSync(chunksDir)) {
|
||||
if (!name.endsWith(".js")) continue;
|
||||
const source = readFileSync(path.join(chunksDir, name));
|
||||
if (!isMotionChunk(source.toString("latin1"))) continue;
|
||||
files.push({ file: name, gzipBytes: gzipSync(source, { level: 9 }).length });
|
||||
}
|
||||
const gzipBytes = files.reduce((sum, entry) => sum + entry.gzipBytes, 0);
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `Kein motion-Chunk in ${chunksDir} gefunden (Marker: ${MOTION_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
|
||||
};
|
||||
}
|
||||
const ok = gzipBytes <= budget;
|
||||
const list = files.map((entry) => `${entry.file} ${entry.gzipBytes}`).join(", ");
|
||||
return {
|
||||
ok,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `motion: ${gzipBytes} Byte gzip in ${files.length} Chunks (${list}), Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
|
||||
let result;
|
||||
try {
|
||||
result = checkMotionBudget(chunksDir);
|
||||
} catch (error) {
|
||||
console.error(`check-motion-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
(result.ok ? console.log : console.error)(result.message);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BUDGET_BYTES, MOTION_MARKERS, checkMotionBudget, isMotionChunk } from "./check-motion-budget.mjs";
|
||||
|
||||
const motionChunk = (filler: string, marker = MOTION_MARKERS[0]) => `/*${marker}*/var a="${marker}";${filler}`;
|
||||
// Hex of random bytes is barely compressible by gzip (about 50 %).
|
||||
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
|
||||
|
||||
describe("check-motion-budget (spec 17.6: motion at most 30 kB gzip)", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "motion-budget-"));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("adds up every motion chunk (the shell part and the lazily loaded features)", () => {
|
||||
writeFileSync(path.join(dir, "core.js"), motionChunk(noise(500), MOTION_MARKERS[0]));
|
||||
writeFileSync(path.join(dir, "features.js"), motionChunk(noise(500), MOTION_MARKERS.at(-1)));
|
||||
writeFileSync(path.join(dir, "app.js"), "console.log('Außerdem heute')");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.files.map(({ file }) => file).sort()).toEqual(["core.js", "features.js"]);
|
||||
expect(result.gzipBytes).toBe(result.files.reduce((sum, { gzipBytes }) => sum + gzipBytes, 0));
|
||||
});
|
||||
|
||||
it("fails when the motion chunks together are over budget", () => {
|
||||
writeFileSync(path.join(dir, "a.js"), motionChunk(noise(400)));
|
||||
writeFileSync(path.join(dir, "b.js"), motionChunk(noise(400)));
|
||||
const result = checkMotionBudget(dir, 600);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.gzipBytes).toBeGreaterThan(600);
|
||||
});
|
||||
|
||||
it("fails when no motion chunk exists (check broken or bundle rebuilt)", () => {
|
||||
writeFileSync(path.join(dir, "app.js"), "export const a = 1;");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not count the three.js chunk", () => {
|
||||
expect(isMotionChunk(`/*WebGLRenderer*/window.__THREE__="186";${MOTION_MARKERS[0]}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the spec budget of 30 kB by default", () => {
|
||||
expect(BUDGET_BYTES).toBe(30_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 focus and size codemod (spec 4.5, 4.6 and 10.8 step 5). Pure and idempotent:
|
||||
*
|
||||
* - One focus style: the base rule `:where(:focus-visible)` in globals.css draws it. Component
|
||||
* classes that remove the outline or draw an own ring go: `outline-none` (also behind
|
||||
* `focus:`/`focus-visible:`), `focus-visible:ring-3`, `focus-visible:ring-ring/50`,
|
||||
* `focus-visible:border-ring`, own outline rings (`focus-visible:outline-2 …-ring`, the hive
|
||||
* glass edge ring, which `.holo-stage` now provides) and variant focus colours.
|
||||
* - A row whose focus target sits inside (label with its input) keeps one ring on the row:
|
||||
* `has-[:focus-visible]:outline-*`/`ring-*` become the utility `focus-ring-within`.
|
||||
* - Compact sizes follow the pointer, not the viewport: `sm:h-*`/`sm:min-h-*`/`sm:size-*` (also
|
||||
* behind a further variant) become `pointer-fine:…`.
|
||||
* - `focus:outline-2 focus:outline-ring` stays: it marks elements focused by script after a
|
||||
* click, where :focus-visible does not match (guarded with an allow-list).
|
||||
* - A class string that held nothing but focus classes is dropped together with its line.
|
||||
*
|
||||
* Selection outlines (`outline-2 outline-foreground`) and menu items' `outline-hidden` stay.
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>
|
||||
* --check lists the files that would change and exits 1 if there are any.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Characters that belong to a class token (so a match never starts or ends inside one). */
|
||||
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||
|
||||
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
||||
|
||||
/** Classes removed outright. */
|
||||
const REMOVED = [
|
||||
"outline-none",
|
||||
"focus:outline-none",
|
||||
"focus-visible:outline-none",
|
||||
"focus-visible:ring-3",
|
||||
"focus-visible:ring-[3px]",
|
||||
"focus-visible:ring-ring/50",
|
||||
"focus-visible:border-ring",
|
||||
"focus:ring-3",
|
||||
"focus:ring-ring/50",
|
||||
"focus-visible:outline-1",
|
||||
"focus-visible:outline-2",
|
||||
"focus-visible:outline-ring",
|
||||
"focus-visible:outline-offset-2",
|
||||
"focus-visible:outline-holo-edge-strong",
|
||||
"focus-visible:ring-destructive/20",
|
||||
"focus-visible:border-destructive/40",
|
||||
"focus-visible:ring-warning/30",
|
||||
];
|
||||
|
||||
/** Own rings on a row with an inner focus target; replaced by one `focus-ring-within`. */
|
||||
const WITHIN = [
|
||||
"has-[:focus-visible]:outline-2",
|
||||
"has-[:focus-visible]:outline-ring",
|
||||
"has-[:focus-visible]:ring-3",
|
||||
"has-[:focus-visible]:ring-ring/50",
|
||||
"has-focus-visible:ring-3",
|
||||
"has-focus-visible:ring-ring/50",
|
||||
];
|
||||
const WITHIN_UTILITY = "focus-ring-within";
|
||||
|
||||
function alternativesOf(classes) {
|
||||
return `(?:${classes.map(escape).join("|")})(?!${TOKEN_CHAR})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the classes so that exactly one separator stays: a class after a space goes with
|
||||
* that space, a class at the start of a string goes with the space after it.
|
||||
*/
|
||||
function removeClasses(line, classes) {
|
||||
const alternatives = alternativesOf(classes);
|
||||
return line
|
||||
.replace(new RegExp(` ${alternatives}`, "g"), "")
|
||||
.replace(new RegExp(`(?<!${TOKEN_CHAR})${alternatives} ?`, "g"), "");
|
||||
}
|
||||
|
||||
const WITHIN_FIRST = new RegExp(`(?<!${TOKEN_CHAR})${alternativesOf(WITHIN)}`);
|
||||
/** `sm:` plus any further variants (`sm:data-[size=default]:`) in front of `h-`/`min-h-`/`size-`. */
|
||||
const SM_HEIGHT = new RegExp(String.raw`(?<!${TOKEN_CHAR})sm:((?:[^\s:"'\x60]+:)*)((?:min-)?h-|size-)`, "g");
|
||||
const EMPTY_CLASS_LINE = /^\s*(["'`])\1,?\s*\r?$/;
|
||||
|
||||
function transformLine(line) {
|
||||
let out = removeClasses(line, REMOVED);
|
||||
if (WITHIN_FIRST.test(out)) {
|
||||
out = removeClasses(out.replace(WITHIN_FIRST, WITHIN_UTILITY), WITHIN);
|
||||
}
|
||||
return out.replace(SM_HEIGHT, "pointer-fine:$1$2");
|
||||
}
|
||||
|
||||
/** Applies the focus and size rules to one source text; line endings are kept. */
|
||||
export function transform(source) {
|
||||
const lines = source.split("\n");
|
||||
const result = [];
|
||||
for (const line of lines) {
|
||||
const out = transformLine(line);
|
||||
if (out !== line && EMPTY_CLASS_LINE.test(out)) continue;
|
||||
result.push(out);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
function sourceFiles(path) {
|
||||
if (statSync(path).isDirectory()) {
|
||||
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
|
||||
}
|
||||
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const check = args.includes("--check");
|
||||
const targets = args.filter((arg) => arg !== "--check");
|
||||
if (targets.length === 0) {
|
||||
console.error("usage: node scripts/codemod-t5-focus.mjs [--check] <file or directory…>");
|
||||
return 2;
|
||||
}
|
||||
const changed = targets.flatMap(sourceFiles).filter((file) => {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = transform(before);
|
||||
if (after === before) return false;
|
||||
if (!check) writeFileSync(file, after);
|
||||
return true;
|
||||
});
|
||||
for (const file of changed) console.log(file);
|
||||
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
|
||||
return check && changed.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-focus.mjs";
|
||||
|
||||
describe("codemod t5 focus and sizes (spec 4.5, 4.6, 10.8 step 5)", () => {
|
||||
it("drops outline-none and the component focus ring classes", () => {
|
||||
expect(
|
||||
transform(
|
||||
'"min-h-11 rounded-lg px-2 outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"',
|
||||
),
|
||||
).toBe('"min-h-11 rounded-lg px-2 hover:bg-muted disabled:opacity-60"');
|
||||
expect(transform('className="px-2 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"')).toBe(
|
||||
'className="px-2"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the own outline rings, including the stage edge ring of the hive glass", () => {
|
||||
expect(transform('"rounded-full focus-visible:outline-2 focus-visible:outline-ring"')).toBe('"rounded-full"');
|
||||
expect(
|
||||
transform("`h-11 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-holo-edge-strong ${X}`"),
|
||||
).toBe("`h-11 ${X}`");
|
||||
});
|
||||
|
||||
it("keeps the focus: outline of elements focused by script after a click", () => {
|
||||
// :focus-visible does not match a script focus that follows a pointer click.
|
||||
const scripted = '"rounded-xl border p-3 focus:outline-2 focus:outline-ring"';
|
||||
expect(transform(scripted)).toBe(scripted);
|
||||
});
|
||||
|
||||
it("drops the skip link's own focus ring", () => {
|
||||
expect(transform('"sr-only focus:not-sr-only focus:ring-3 focus:ring-ring/50 focus:outline-none"')).toBe(
|
||||
'"sr-only focus:not-sr-only"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops variant focus colours on the destructive button and badges", () => {
|
||||
expect(transform('"bg-destructive/10 text-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20"')).toBe(
|
||||
'"bg-destructive/10 text-destructive"',
|
||||
);
|
||||
expect(transform('"bg-warning/15 focus-visible:ring-warning/30 [a]:hover:bg-warning/25"')).toBe(
|
||||
'"bg-warning/15 [a]:hover:bg-warning/25"',
|
||||
);
|
||||
});
|
||||
|
||||
it("a row with an inner focus target keeps one ring on the outer element", () => {
|
||||
expect(transform('"flex gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"')).toBe(
|
||||
'"flex gap-3 rounded-xl border p-3 focus-ring-within"',
|
||||
);
|
||||
expect(transform('"flex min-h-14 px-3 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50"')).toBe(
|
||||
'"flex min-h-14 px-3 focus-ring-within"',
|
||||
);
|
||||
expect(transform('"flex min-h-11 px-1 has-focus-visible:ring-3 has-focus-visible:ring-ring/50"')).toBe(
|
||||
'"flex min-h-11 px-1 focus-ring-within"',
|
||||
);
|
||||
});
|
||||
|
||||
it("selection outlines that are not focus styles stay", () => {
|
||||
const calendar = 'isSelected && "outline-2 -outline-offset-2 outline-foreground",';
|
||||
expect(transform(calendar)).toBe(calendar);
|
||||
const canvas = 'selected && "outline outline-2 outline-foreground",';
|
||||
expect(transform(canvas)).toBe(canvas);
|
||||
const menuItem = '"relative flex rounded-md text-sm outline-hidden select-none focus:bg-accent"';
|
||||
expect(transform(menuItem)).toBe(menuItem);
|
||||
});
|
||||
|
||||
it("a class string that only held focus classes disappears with its line", () => {
|
||||
expect(transform('cn(\n "min-h-11 px-2",\n "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",\n x,\n)')).toBe(
|
||||
'cn(\n "min-h-11 px-2",\n x,\n)',
|
||||
);
|
||||
});
|
||||
|
||||
it("sm:h-* and sm:min-h-* become pointer-fine, also behind a further variant", () => {
|
||||
expect(transform('"h-10 sm:h-8 gap-1.5"')).toBe('"h-10 pointer-fine:h-8 gap-1.5"');
|
||||
expect(transform('"min-h-11 sm:min-h-9 rounded-lg"')).toBe('"min-h-11 pointer-fine:min-h-9 rounded-lg"');
|
||||
expect(transform('"data-[size=default]:h-11 sm:data-[size=default]:h-9"')).toBe(
|
||||
'"data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9"',
|
||||
);
|
||||
});
|
||||
|
||||
it("sm:size-* becomes pointer-fine:size-*", () => {
|
||||
expect(transform('className="size-11 sm:size-8"')).toBe('className="size-11 pointer-fine:size-8"');
|
||||
});
|
||||
|
||||
it("other sm: classes stay", () => {
|
||||
const layout = '"hidden sm:flex sm:hidden sm:gap-2 sm:px-4 sm:max-w-xs sm:max-h-96"';
|
||||
expect(transform(layout)).toBe(layout);
|
||||
});
|
||||
|
||||
it("keeps CRLF line endings", () => {
|
||||
expect(transform('a\r\nclassName="px-2 outline-none"\r\n')).toBe('a\r\nclassName="px-2"\r\n');
|
||||
});
|
||||
|
||||
it("the focus codemod is idempotent and keeps pointer-fine classes", () => {
|
||||
const source = [
|
||||
'"h-11 sm:h-8 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"',
|
||||
'"pointer-fine:h-9 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring"',
|
||||
].join("\n");
|
||||
const once = transform(source);
|
||||
expect(once).toBe('"h-11 pointer-fine:h-8"\n"pointer-fine:h-9 focus-ring-within"');
|
||||
expect(transform(once)).toBe(once);
|
||||
});
|
||||
|
||||
it("leaves nothing to migrate in src", () => {
|
||||
const files = sourceFiles(join(process.cwd(), "src"));
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
const pending = files.filter((file) => {
|
||||
const text = readFileSync(file, "utf8");
|
||||
return transform(text) !== text;
|
||||
});
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function sourceFiles(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 motion codemod (spec 17.2, 17.4, 17.7, Task A11). Pure and idempotent:
|
||||
*
|
||||
* - `motion-safe:` only asks the system and skips the level chosen in the app. Movement behind it
|
||||
* (translate, scale, rotate, slide-, zoom-, animate-, transition-transform, also behind further
|
||||
* variants) moves to `motion-full:`.
|
||||
* - Colour and opacity transitions need no variant ("Aus" sets every duration to zero):
|
||||
* `motion-safe:transition-colors`/`-opacity` lose the variant.
|
||||
* - Pulsing and spinning are full motion: a bare `animate-pulse`/`animate-spin` gets
|
||||
* `motion-full:`, and `motion-reduce:animate-none` (the old opt-out) goes.
|
||||
*
|
||||
* Hover lifts, `transition-all` and overlay timings are changed by hand (they need a judgement).
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-motion.mjs [--check] <file or directory…>
|
||||
* --check lists the files that would change and exits 1 if there are any.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Characters that belong to a class token (so a match never starts inside one). */
|
||||
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||
/** Further variants between the motion variant and the utility (`data-open:`, `group-hover:`). */
|
||||
const VARIANTS = String.raw`(?:[^\s"'\`:]+:)*`;
|
||||
const MOVEMENT = String.raw`-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)`;
|
||||
|
||||
const SAFE_MOVEMENT = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=${VARIANTS}${MOVEMENT})`, "g");
|
||||
const SAFE_FADE = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=transition-(?:colors|opacity)(?!${TOKEN_CHAR}))`, "g");
|
||||
const REDUCE_NONE = new RegExp(String.raw` ?(?<!${TOKEN_CHAR})motion-reduce:animate-none(?!${TOKEN_CHAR})`, "g");
|
||||
const BARE_PULSE_SPIN = new RegExp(String.raw`(?<!${TOKEN_CHAR})animate-(pulse|spin)(?![\w-])`, "g");
|
||||
|
||||
/** Rewrites the motion classes of one source text. */
|
||||
export function transform(text) {
|
||||
return text
|
||||
.replace(SAFE_MOVEMENT, "motion-full:")
|
||||
.replace(SAFE_FADE, "")
|
||||
.replace(REDUCE_NONE, "")
|
||||
.replace(BARE_PULSE_SPIN, "motion-full:animate-$1");
|
||||
}
|
||||
|
||||
function filesOf(path) {
|
||||
if (statSync(path).isDirectory()) {
|
||||
return readdirSync(path).flatMap((name) => filesOf(join(path, name)));
|
||||
}
|
||||
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.includes("--check");
|
||||
const changed = args
|
||||
.filter((arg) => arg !== "--check")
|
||||
.flatMap((arg) => filesOf(resolve(arg)))
|
||||
.filter((file) => {
|
||||
const before = readFileSync(file, "utf8");
|
||||
const after = transform(before);
|
||||
if (after === before) return false;
|
||||
if (!check) writeFileSync(file, after);
|
||||
return true;
|
||||
});
|
||||
for (const file of changed) console.log(file);
|
||||
if (check && changed.length > 0) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-motion.mjs";
|
||||
|
||||
describe("codemod t5 motion (spec 17.2, 17.4, 17.7)", () => {
|
||||
it("puts movement behind motion-full: instead of motion-safe:", () => {
|
||||
expect(transform('cn("size-4 motion-safe:transition-transform", open && "rotate-180")')).toBe(
|
||||
'cn("size-4 motion-full:transition-transform", open && "rotate-180")',
|
||||
);
|
||||
expect(transform('"holo-stage bg-holo-bg motion-safe:animate-pulse"')).toBe('"holo-stage bg-holo-bg motion-full:animate-pulse"');
|
||||
expect(transform('" motion-safe:animate-spin"')).toBe('" motion-full:animate-spin"');
|
||||
expect(transform('"motion-safe:data-open:zoom-in-95 motion-safe:slide-in-from-top-2"')).toBe(
|
||||
'"motion-full:data-open:zoom-in-95 motion-full:slide-in-from-top-2"',
|
||||
);
|
||||
expect(transform('"motion-safe:-translate-y-1 motion-safe:scale-95 motion-safe:rotate-90"')).toBe(
|
||||
'"motion-full:-translate-y-1 motion-full:scale-95 motion-full:rotate-90"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the variant from colour and opacity transitions (off sets their duration to zero)", () => {
|
||||
expect(transform('"rounded-lg px-2 motion-safe:transition-colors hover:bg-muted"')).toBe(
|
||||
'"rounded-lg px-2 transition-colors hover:bg-muted"',
|
||||
);
|
||||
expect(transform('"motion-safe:transition-opacity"')).toBe('"transition-opacity"');
|
||||
});
|
||||
|
||||
it("gates bare pulse and spin behind full motion and drops motion-reduce:animate-none", () => {
|
||||
expect(transform('"size-4 animate-spin motion-reduce:animate-none"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
expect(transform('"h-14 animate-pulse rounded-xl"')).toBe('"h-14 motion-full:animate-pulse rounded-xl"');
|
||||
expect(transform('"size-4 animate-spin"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
});
|
||||
|
||||
it("leaves other animate classes, tokens and identifiers alone", () => {
|
||||
const untouched = [
|
||||
'"data-open:animate-in data-closed:animate-out"',
|
||||
'"motion-full:animate-pulse"',
|
||||
"const isAnimating = animate-spinner;",
|
||||
'"animate-page-in"',
|
||||
"// motion-safe: is forbidden for movement",
|
||||
];
|
||||
for (const text of untouched) expect(transform(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = transform('"a motion-safe:transition-transform animate-spin motion-reduce:animate-none motion-safe:transition-colors"');
|
||||
expect(transform(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ export function ColonyOverviewLinks({ overview, hrefFor }: ColonyOverviewLinksPr
|
||||
<Link
|
||||
key={row.id}
|
||||
href={hrefFor(row.id)}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
|
||||
);
|
||||
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
||||
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
|
||||
const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const apiaries = bucket === "active" ? activeApiaries : historyApiaries;
|
||||
return (
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
apiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={apiaries} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -63,35 +81,23 @@ export default function ApiariesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
/>
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
visibleApiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={tab}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={visibleApiaries} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
toolbar={
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,4 +281,30 @@ describe("PrintLabelsCard", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Berechtigung/);
|
||||
});
|
||||
it("label print start position uses NumberStepper", async () => {
|
||||
renderCard();
|
||||
const main = await screen.findByRole("button", { name: "Etiketten als PDF" });
|
||||
const input = screen.getByLabelText(/Startposition/);
|
||||
expect(input).toHaveValue("1");
|
||||
expect(input).toHaveAttribute("inputmode", "numeric");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Position weiter/ }));
|
||||
expect(input).toHaveValue("3");
|
||||
await waitFor(() => expect(main).not.toHaveAttribute("aria-disabled", "true"));
|
||||
await userEvent.click(main);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(printLabels).toHaveBeenCalledWith("tpl-1", "b-1", { startPosition: 3, proof: false }),
|
||||
);
|
||||
});
|
||||
it("caps the start position at the server limit of 1000 when the template has no sheet size", async () => {
|
||||
vi.mocked(fetchLabelTemplates).mockResolvedValue([{ ...TEMPLATE, columns: null, rows: null }]);
|
||||
renderCard();
|
||||
await screen.findByRole("button", { name: "Etiketten als PDF" });
|
||||
const input = screen.getByLabelText(/Startposition/);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "5000");
|
||||
|
||||
expect(input).toHaveValue("1000");
|
||||
});
|
||||
});
|
||||
@@ -17,16 +17,19 @@ import { PARTICULAR_LABELS } from "@/components/labels/label-compliance-panel";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
|
||||
/** Highest start position the server takes (label-templates.controller.ts). */
|
||||
const MAX_START_POSITION = 1000;
|
||||
|
||||
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 LINK_CLASS =
|
||||
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink 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-ink underline underline-offset-4";
|
||||
|
||||
type FixTarget = "master" | "product" | "designer";
|
||||
|
||||
@@ -138,8 +141,10 @@ export function PrintLabelsCard({
|
||||
const canCheck = canReadTemplates && hasPermission("bottling:read");
|
||||
const { isOffline, hintId, describedBy: offlineDescribedBy } = useOnlineOnly();
|
||||
const reasonId = useId();
|
||||
const startHintId = useId();
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const [startPosition, setStartPosition] = useState(1);
|
||||
// "" while the field is emptied: printing then starts at the first label.
|
||||
const [startPositionText, setStartPositionText] = useState("1");
|
||||
const [serverGaps, setServerGaps] = useState<ServerGaps | null>(null);
|
||||
const [printError, setPrintError] = useState<string | null>(null);
|
||||
const [printDone, setPrintDone] = useState<string | null>(null);
|
||||
@@ -160,6 +165,7 @@ export function PrintLabelsCard({
|
||||
selectedTemplate?.columns && selectedTemplate?.rows
|
||||
? selectedTemplate.columns * selectedTemplate.rows
|
||||
: undefined;
|
||||
const startPosition = Math.max(1, Number(startPositionText) || 1);
|
||||
|
||||
// The answer holds the food business address: no persistence, dropped as
|
||||
// soon as nobody looks at it any more.
|
||||
@@ -314,16 +320,18 @@ export function PrintLabelsCard({
|
||||
<Label htmlFor="print-start-position">
|
||||
Startposition auf dem Bogen (1 = oben links)
|
||||
</Label>
|
||||
<Input
|
||||
<NumberStepper
|
||||
id="print-start-position"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
describedBy={startHintId}
|
||||
value={startPositionText}
|
||||
onChange={setStartPositionText}
|
||||
min={1}
|
||||
max={slotsPerSheet}
|
||||
value={startPosition}
|
||||
onChange={(event) => setStartPosition(Math.max(1, Number(event.target.value)))}
|
||||
max={slotsPerSheet ?? MAX_START_POSITION}
|
||||
unitLabel={slotsPerSheet ? `von ${slotsPerSheet}` : "Position"}
|
||||
decrementLabel="Eine Position zurück"
|
||||
incrementLabel="Eine Position weiter"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p id={startHintId} className="text-xs text-muted-foreground">
|
||||
Für bereits angebrochene Bögen: Position der ersten noch freien Etikette.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -192,6 +192,20 @@ describe("CalendarPage entries", () => {
|
||||
expect(link).toHaveTextContent("Wartezeit bis einschließlich 12.08. – ab 13.08. wieder erntefähig");
|
||||
});
|
||||
|
||||
it("marks the chosen day with an inset ring and keeps the focus ring inside the clipped grid", async () => {
|
||||
renderPage();
|
||||
await screen.findAllByText("Wartezeit B3 · Oxuvar", { selector: "span.truncate" });
|
||||
const day = screen.getByRole("button", { name: /Dienstag, 11\. August/ });
|
||||
await userEvent.click(day);
|
||||
|
||||
const classes = day.className.split(/\s+/);
|
||||
expect(classes).toEqual(expect.arrayContaining(["ring-2", "ring-inset", "ring-foreground"]));
|
||||
// A selection outline would replace the focus ring; the grid clips (overflow-hidden), so
|
||||
// the focus ring of every day is drawn inside the cell, next to the selection ring.
|
||||
expect(day.className).not.toMatch(/(^|\s)outline-(2|foreground)|-outline-offset-2/);
|
||||
expect(classes).toContain("focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]");
|
||||
});
|
||||
|
||||
it("counts a day with several entries in the plural", async () => {
|
||||
vi.mocked(fetchCalendarEvents).mockResolvedValue([
|
||||
{ id: "a", type: "TASK", date: "2026-08-12T00:00:00.000Z", title: "A", relatedType: "Task", relatedId: "1" },
|
||||
|
||||
@@ -146,7 +146,7 @@ function EventRow({ event, isContinuation }: { event: CalendarEvent; isContinuat
|
||||
</span>
|
||||
);
|
||||
return href ? (
|
||||
<Link href={href} className="block rounded-lg focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<Link href={href} className="block rounded-lg">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -353,9 +353,11 @@ export default function CalendarPage() {
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`${dayHeading(day)}${isToday ? ", heute" : ""}: ${entryCountText(dayEvents.length)}`}
|
||||
className={cn(
|
||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
|
||||
// The grid clips (overflow-hidden): the focus ring sits inside the cell,
|
||||
// next to the inset selection ring, so edge days keep it whole.
|
||||
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0 focus-visible:outline-offset-[calc(-2px_-_var(--focus-width))]",
|
||||
isCurrentMonth ? "bg-card" : "bg-muted/40",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-foreground",
|
||||
isSelected && "ring-2 ring-inset ring-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -83,7 +83,7 @@ function ActionFooter({
|
||||
ref={errorRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
|
||||
@@ -283,7 +283,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
||||
ref={conflictRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{conflictText}
|
||||
</p>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
|
||||
);
|
||||
}
|
||||
if (!colony.data || !hive.data || !catalog) {
|
||||
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
||||
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-full:animate-pulse" />;
|
||||
}
|
||||
return (
|
||||
<HiveEditor
|
||||
|
||||
@@ -55,4 +55,20 @@ describe("SwarmControlEditDialog", () => {
|
||||
notes: null,
|
||||
});
|
||||
});
|
||||
it("swarm control edit counts queen cells with NumberStepper", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SwarmControlEditDialog check={check} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Bearbeiten" }));
|
||||
await screen.findByRole("dialog");
|
||||
|
||||
expect(screen.getByLabelText(/^Weiselzellen gefunden/)).toHaveValue("3");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle weniger/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateSwarmControlCheck).mock.calls[0][1]).toMatchObject({ queenCellsFound: 2 });
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,9 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -115,22 +116,22 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
<FormSection step={2} title="Befund">
|
||||
<Field label="Weiselzellen gefunden" hint="Anzahl">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
aria-describedby={describedBy}
|
||||
describedBy={describedBy}
|
||||
value={queenCellsFound}
|
||||
onChange={(event) => setQueenCellsFound(event.target.value)}
|
||||
onChange={setQueenCellsFound}
|
||||
unitLabel="Weiselzellen"
|
||||
decrementLabel="Eine Weiselzelle weniger"
|
||||
incrementLabel="Eine Weiselzelle mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Weiselzellen bereits verdeckelt (akut)"
|
||||
checked={queenCellsCapped}
|
||||
onChange={setQueenCellsCapped}
|
||||
onCheckedChange={setQueenCellsCapped}
|
||||
/>
|
||||
<Field label="Schwarmstimmung" required>
|
||||
{({ id }) => (
|
||||
|
||||
@@ -135,4 +135,15 @@ describe("VarroaEditDialog", () => {
|
||||
await openDialog();
|
||||
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
|
||||
});
|
||||
it("the varroa edit dialog counts mites with NumberStepper", async () => {
|
||||
await openDialog();
|
||||
|
||||
const miteCount = screen.getByLabelText(/^Milbenzahl/);
|
||||
expect(miteCount).toHaveValue("12");
|
||||
expect(miteCount).toHaveAttribute("inputmode", "numeric");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ miteCount: 13 });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -146,17 +147,18 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
||||
{isNaturalFall ? (
|
||||
<VarroaPeriodField minutes={durationMinutes} onChange={setDurationMinutes} />
|
||||
) : null}
|
||||
<div className={isNaturalFall ? undefined : "grid grid-cols-2 gap-3"}>
|
||||
{/* One column: the stepper needs the width of the dialog on a phone. */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Field label="Milbenzahl">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
value={miteCount}
|
||||
onChange={(event) => setMiteCount(event.target.value)}
|
||||
onChange={setMiteCount}
|
||||
unitLabel="Milben"
|
||||
decrementLabel="Eine Milbe weniger"
|
||||
incrementLabel="Eine Milbe mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function NewColonyPage() {
|
||||
ref={errorRef}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm font-medium text-destructive outline-none"
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{submitError}
|
||||
</p>
|
||||
|
||||
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
|
||||
const colonies = coloniesQuery.data ?? [];
|
||||
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
||||
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
|
||||
const bucketColonies = tab === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
|
||||
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketColonies = bucket === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status
|
||||
? bucketColonies.filter((colony) => colony.status === status)
|
||||
: bucketColonies;
|
||||
return (
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
@@ -82,40 +102,28 @@ export default function ColoniesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
|
||||
<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"
|
||||
className="font-heading text-base font-semibold"
|
||||
>{`${number}. ${title}`}</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||
<Icon className="size-4" aria-hidden />
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function NoticeDetailPage() {
|
||||
id="notice-flow-heading"
|
||||
ref={flowHeading}
|
||||
tabIndex={-1}
|
||||
className="font-heading text-lg font-semibold tracking-tight outline-none"
|
||||
className="font-heading text-lg font-semibold tracking-tight"
|
||||
>
|
||||
{flowTitle}
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CreateNoticeDialog } from "./create-notice-dialog";
|
||||
@@ -97,4 +97,14 @@ describe("CreateNoticeDialog", () => {
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Erst die Erstanzeige erfassen.");
|
||||
});
|
||||
it("offers the kind of notice as cards with their hint", async () => {
|
||||
renderDialog();
|
||||
await open();
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: "Art der Anzeige" });
|
||||
const first = within(group).getByRole("radio", { name: "Erstanzeige" });
|
||||
expect(first).toHaveAttribute("aria-checked", "true");
|
||||
expect(first).toHaveAccessibleDescription("Deine erste Meldung an das Veterinäramt.");
|
||||
expect(first).toHaveClass("min-h-14");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -28,6 +29,12 @@ const KIND_HINT: Record<AuthorityNoticeKind, string> = {
|
||||
TSK_REPORT: "Die jährliche Zählung für die Tierseuchenkasse.",
|
||||
};
|
||||
|
||||
const KIND_OPTIONS: readonly ChoiceOption<AuthorityNoticeKind>[] = NOTICE_KINDS.map((kind) => ({
|
||||
value: kind,
|
||||
label: NOTICE_KIND_LABEL[kind],
|
||||
description: KIND_HINT[kind],
|
||||
}));
|
||||
|
||||
interface CreateNoticeDialogProps {
|
||||
/** Switched off from outside, e.g. offline; `describedBy` names the reason. */
|
||||
disabled?: boolean;
|
||||
@@ -75,6 +82,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const attempt = useAttemptKey();
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
const kindLabelId = useId();
|
||||
const [kind, setKind] = useState<AuthorityNoticeKind>("REGISTRATION");
|
||||
const [year, setYear] = useState(String(new Date().getFullYear()));
|
||||
const [alreadySent, setAlreadySent] = useState(false);
|
||||
@@ -131,34 +139,18 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Art der Anzeige</legend>
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
<label
|
||||
key={entry}
|
||||
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-foreground has-[:checked]:bg-primary/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="notice-kind"
|
||||
value={entry}
|
||||
checked={kind === entry}
|
||||
onChange={() => setKind(entry)}
|
||||
aria-labelledby={`notice-kind-${entry}`}
|
||||
aria-describedby={`notice-kind-hint-${entry}`}
|
||||
className="mt-1 size-4"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span id={`notice-kind-${entry}`} className="text-sm font-medium">
|
||||
{NOTICE_KIND_LABEL[entry]}
|
||||
</span>
|
||||
<span id={`notice-kind-hint-${entry}`} className="text-xs text-muted-foreground">
|
||||
{KIND_HINT[entry]}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={kindLabelId} className="text-base font-medium">
|
||||
Art der Anzeige
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={KIND_OPTIONS}
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
labelledBy={kindLabelId}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === "TSK_REPORT" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -173,7 +165,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
|
||||
<CheckboxField appearance="tile" label="Bereits angezeigt" checked={alreadySent} onCheckedChange={setAlreadySent} />
|
||||
{alreadySent ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
|
||||
@@ -197,7 +189,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-destructive">
|
||||
{showProblem ? problem : null}
|
||||
</p>
|
||||
<div ref={errorRef} tabIndex={-1} role="alert" className="outline-none">
|
||||
<div ref={errorRef} tabIndex={-1} role="alert">
|
||||
{error ? (
|
||||
<div className="flex flex-col gap-2 text-sm text-destructive">
|
||||
<p>{error.text}</p>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function NoticesPage() {
|
||||
id="notice-kind-filter"
|
||||
value={kind}
|
||||
onChange={(event) => setKind(event.target.value as KindFilter)}
|
||||
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs md:text-sm"
|
||||
className="h-11 w-full rounded-xl border border-input bg-card px-3 text-base sm:max-w-xs"
|
||||
>
|
||||
<option value="ALL">Alle</option>
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
@@ -85,7 +85,7 @@ export default function NoticesPage() {
|
||||
aria-invalid={yearInvalid ? true : undefined}
|
||||
aria-describedby={yearInvalid ? "notice-year-problem" : undefined}
|
||||
onChange={(event) => setYearText(event.target.value)}
|
||||
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base md:text-sm"
|
||||
className="h-11 w-28 rounded-xl border border-input bg-card px-3 text-base"
|
||||
/>
|
||||
{yearInvalid ? (
|
||||
<p id="notice-year-problem" className="text-sm text-destructive">
|
||||
@@ -131,7 +131,7 @@ function NoticeLink({ notice }: { notice: AuthorityNotice }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/compliance/notices/${encodeURIComponent(notice.id)}`}
|
||||
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 flex-col gap-1 rounded-xl border border-border/60 bg-card p-3 hover:bg-muted"
|
||||
>
|
||||
<span className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-base font-semibold">{noticeTitle(notice)}</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
||||
};
|
||||
|
||||
const FIX_LINK_CLASS =
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline";
|
||||
|
||||
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
|
||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||
@@ -117,7 +117,7 @@ export function ReadinessItemCard({ item, canManage }: ReadinessItemCardProps) {
|
||||
<h3
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="min-w-0 font-heading text-base font-semibold break-words outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="min-w-0 font-heading text-base font-semibold break-words"
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
+4
-3
@@ -20,7 +20,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -207,11 +207,12 @@ export function CreateRestrictionDialog() {
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{apiaries.map((apiary) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={apiary.id}
|
||||
label={apiary.name}
|
||||
checked={values.apiaryIds.includes(apiary.id)}
|
||||
onChange={(checked) => toggleApiary(apiary.id, checked)}
|
||||
onCheckedChange={(checked) => toggleApiary(apiary.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ function Section({
|
||||
const headingId = `restrictions-${id}`;
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight outline-none">
|
||||
<h2 id={headingId} tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
|
||||
{`${title} (${rows.length})`}
|
||||
</h2>
|
||||
{rows.length === 0 ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { settingsFixture } from "@/lib/compliance/notice-fixtures";
|
||||
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
|
||||
|
||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||
const READ_ONLY_TEXT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||
|
||||
let permissions = ["compliance:read", "compliance:manage"];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
@@ -250,6 +251,26 @@ describe("ComplianceSettingsPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read-only tiles say why they are off (hint text, not grey only)", async () => {
|
||||
permissions = ["compliance:read"];
|
||||
renderPage();
|
||||
const trigger = await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
|
||||
const counted = screen.getByRole("checkbox", { name: "Ableger" });
|
||||
|
||||
// Both trigger tiles and the counted colony types show the reason next to them.
|
||||
expect(screen.getAllByText(READ_ONLY_TEXT)).toHaveLength(3);
|
||||
for (const hint of screen.getAllByText(READ_ONLY_TEXT)) expect(hint).toBeVisible();
|
||||
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(READ_ONLY_TEXT));
|
||||
expect(counted).toHaveAccessibleDescription(READ_ONLY_TEXT);
|
||||
});
|
||||
|
||||
it("editable tiles show no read-only hint", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||
expect(screen.queryByText(READ_ONLY_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so without the read permission and loads nothing", () => {
|
||||
permissions = [];
|
||||
renderPage();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from "./digit-rules-field";
|
||||
|
||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||
/** Why a read-only tile is off (spec 10.3: hint text, not grey only). */
|
||||
const READ_ONLY_HINT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||
|
||||
/** Input id per form field that can carry a validation error, in page order. */
|
||||
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
|
||||
@@ -277,22 +279,26 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
||||
|
||||
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Bei geänderter Völkerzahl"
|
||||
checked={values.noticeOnColonyCountChange}
|
||||
disabled={readOnly}
|
||||
disabledHint={READ_ONLY_HINT}
|
||||
describedBy="note-trigger-count"
|
||||
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
||||
onCheckedChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
||||
/>
|
||||
<AssumptionNote id="note-trigger-count" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Bei geändertem Stand"
|
||||
checked={values.noticeOnApiaryChange}
|
||||
disabled={readOnly}
|
||||
disabledHint={READ_ONLY_HINT}
|
||||
describedBy="note-trigger-apiary"
|
||||
onChange={(checked) => set("noticeOnApiaryChange", checked)}
|
||||
onCheckedChange={(checked) => set("noticeOnApiaryChange", checked)}
|
||||
/>
|
||||
<AssumptionNote id="note-trigger-apiary" />
|
||||
</div>
|
||||
@@ -361,15 +367,22 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
||||
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLONY_TYPES.map((type) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={type}
|
||||
label={COLONY_TYPE_LABELS[type]}
|
||||
checked={values.counted.includes(type)}
|
||||
disabled={readOnly}
|
||||
onChange={(checked) => toggleCounted(type, checked)}
|
||||
describedBy={readOnly ? "note-counted-read-only" : undefined}
|
||||
onCheckedChange={(checked) => toggleCounted(type, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<p id="note-counted-read-only" className="text-sm font-medium text-muted-foreground">
|
||||
{READ_ONLY_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<DigitRulesField
|
||||
rows={values.rules}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import DashboardPage from "./page";
|
||||
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
@@ -229,6 +230,27 @@ describe("DashboardPage – Heute", () => {
|
||||
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ticking a task on the Base UI honey-check cell completes it (Space works too)", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
// The first task is the next step; the second one is a row with the tick cell.
|
||||
vi.mocked(fetchTasks).mockResolvedValue([
|
||||
overdueTask(),
|
||||
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
|
||||
]);
|
||||
vi.mocked(completeTask).mockResolvedValue({ ...overdueTask(), status: "DONE" } as never);
|
||||
renderPage();
|
||||
|
||||
const cell = await screen.findByRole("checkbox", { name: "„Zweite Aufgabe“ als erledigt markieren" });
|
||||
expect(cell).toHaveAttribute("data-slot", "checkbox");
|
||||
expect(cell).toHaveClass("honey-check");
|
||||
expect(cell).not.toBeChecked();
|
||||
cell.focus();
|
||||
await userEvent.keyboard(" ");
|
||||
|
||||
expect(cell).toBeChecked();
|
||||
await waitFor(() => expect(completeTask).toHaveBeenCalledWith("t2"), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("gives the dashboard text links a 44 px target", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
renderPage();
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function DashboardPage() {
|
||||
<li key={tile.label}>
|
||||
<Link
|
||||
href={tile.href}
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted"
|
||||
>
|
||||
<Icon
|
||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
@@ -202,16 +202,17 @@ export default function NewExtractionPage() {
|
||||
data-field="harvestIds"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={describedBy}
|
||||
className="flex flex-col gap-2 outline-none"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{harvests?.map((harvest) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={harvest.id}
|
||||
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
|
||||
.map((entry) => entry.colony.code)
|
||||
.join(", ")}`}
|
||||
checked={values.harvestIds.includes(harvest.id)}
|
||||
onChange={(checked) =>
|
||||
onCheckedChange={(checked) =>
|
||||
toggleHarvest(harvest.id, checked)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import {
|
||||
DATE_REQUIRED_MESSAGE,
|
||||
DialogSubmitBar,
|
||||
@@ -177,11 +177,12 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{selectableColonies.map((colony) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={colony.id}
|
||||
label={colony.code}
|
||||
checked={colonyIds.includes(colony.id)}
|
||||
onChange={(checked) => toggleColony(colony.id, checked)}
|
||||
onCheckedChange={(checked) => toggleColony(colony.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
|
||||
@@ -253,16 +253,17 @@ export default function NewHarvestPage() {
|
||||
data-field="colonyIds"
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={describedBy}
|
||||
className="grid grid-cols-2 gap-2 outline-none"
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{apiaryColonies.map((colony) => {
|
||||
const withdrawal = withdrawalByColony.get(colony.id);
|
||||
return (
|
||||
<div key={colony.id} className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label={colonyOptionLabel(colony)}
|
||||
checked={values.colonyIds.includes(colony.id)}
|
||||
onChange={(checked) =>
|
||||
onCheckedChange={(checked) =>
|
||||
toggleColony(colony.id, checked)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
|
||||
const honeyBatches = honeyBatchesQuery.data ?? [];
|
||||
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
||||
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
|
||||
const visibleBatches = tab === "active" ? activeBatches : historyBatches;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const batches = bucket === "active" ? activeBatches : historyBatches;
|
||||
return (
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
batches.length === 0 ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={batches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeBatches.length}
|
||||
historyCount={historyBatches.length}
|
||||
/>
|
||||
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
visibleBatches.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function InspectionHiveSection({
|
||||
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
|
||||
<div
|
||||
data-testid="hive-section"
|
||||
className="flex flex-col gap-4 outline-none"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 ref={headingRef} tabIndex={-1} className="sr-only">
|
||||
|
||||
@@ -63,12 +63,14 @@ vi.mock("@/components/offline/offline-band", () => ({
|
||||
userId,
|
||||
pageContentRef,
|
||||
syncEnabled,
|
||||
inert,
|
||||
}: {
|
||||
userId: string;
|
||||
pageContentRef: { current: HTMLElement | null };
|
||||
syncEnabled?: boolean;
|
||||
inert?: boolean;
|
||||
}) => (
|
||||
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
|
||||
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)} inert={inert}>
|
||||
{userId}
|
||||
<button type="button" onClick={() => pageContentRef.current?.focus()}>
|
||||
Fokus auf den Seiteninhalt
|
||||
@@ -92,6 +94,15 @@ vi.mock("@/lib/photos/store", () => ({
|
||||
// and since the page itself scrolls on the window, the bar would never stick.
|
||||
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
|
||||
|
||||
const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/** Tab stops in document order as a browser sees them: jsdom and user-event ignore `inert`. */
|
||||
function tabStops(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).filter(
|
||||
(element) => element.closest("[inert]") === null,
|
||||
);
|
||||
}
|
||||
|
||||
function expectNoScrollContainerAbove(element: HTMLElement) {
|
||||
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
||||
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
|
||||
@@ -127,6 +138,47 @@ describe("AppLayout", () => {
|
||||
expect(content).toHaveFocus();
|
||||
});
|
||||
|
||||
it("hides the whole shell from the keyboard and screen readers behind the full-screen Beute editor", () => {
|
||||
pathname = "/colonies/c1/hive";
|
||||
permissions = ["inspections:create"];
|
||||
render(
|
||||
<AppLayout>
|
||||
<main>
|
||||
<button type="button">Zurück</button>
|
||||
<h1>Umbauen</h1>
|
||||
</main>
|
||||
</AppLayout>,
|
||||
);
|
||||
|
||||
const back = screen.getByRole("button", { name: "Zurück" });
|
||||
// The first Tab lands in the editor, not on 33 links of the sidebar and header behind it.
|
||||
expect(tabStops()[0]).toBe(back);
|
||||
expect(back.closest("[inert]")).toBeNull();
|
||||
for (const shellPart of [
|
||||
screen.getByText("Zum Inhalt springen"),
|
||||
screen.getAllByRole("link", { name: /Dashboard|Start/ })[0],
|
||||
screen.getByRole("button", { name: "Konto-Menü" }),
|
||||
screen.getByTestId("offline-band"),
|
||||
...document.querySelectorAll<HTMLElement>("aside, header, nav"),
|
||||
]) {
|
||||
expect(shellPart.closest("[inert]"), shellPart.outerHTML.slice(0, 60)).not.toBeNull();
|
||||
}
|
||||
// The phone's „+ Neu" would float over the editor: it is not there at all.
|
||||
expect(screen.queryByRole("button", { name: "Neu" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the shell reachable on every other page", () => {
|
||||
pathname = "/colonies/c1";
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
|
||||
expect(document.querySelector("[inert]")).toBeNull();
|
||||
expect(tabStops()[0]).toBe(screen.getByRole("link", { name: "Zum Inhalt springen" }));
|
||||
});
|
||||
|
||||
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
|
||||
pathname = "/suggestions";
|
||||
permissions = ["inspections:create"];
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useAppearance } from "@/lib/appearance/appearance";
|
||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { AppMotion } from "@/components/app-motion";
|
||||
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { isFullScreenPage } from "./nav-config";
|
||||
import { hasQuickAddButton, QuickAddSheet } from "./quick-add-sheet";
|
||||
import { PageTransition } from "./page-transition";
|
||||
|
||||
@@ -72,95 +74,112 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
? "pb-[calc(var(--bottom-nav-height)+--spacing(18))]"
|
||||
: "pb-(--bottom-nav-height)";
|
||||
|
||||
// A full-screen page (the Beute editor) covers the shell: everything of the
|
||||
// shell turns inert, so the first Tab lands in the page and screen readers
|
||||
// skip what cannot be seen. Decided by route, like the quick-add button:
|
||||
// known on the first render and the same for the page's notice and loading
|
||||
// states. `contents` wrappers keep the boxes where they are in the layout.
|
||||
const isShellInert = isFullScreenPage(pathname);
|
||||
|
||||
// The page scrolls on the window. No ancestor of the page content may be a
|
||||
// scroll container (overflow auto/scroll/hidden): it would capture
|
||||
// `position: sticky` and the StickySubmit save bar would never stick.
|
||||
return (
|
||||
<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} />
|
||||
<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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center font-heading font-semibold sm:hidden"
|
||||
>
|
||||
<BrandMark withWordmark />
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
|
||||
{user.email}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sunActive}
|
||||
onCheckedChange={(checked) => {
|
||||
setContrast(checked ? "sun" : "normal");
|
||||
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Sun aria-hidden="true" />
|
||||
Sonnenmodus
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href="/settings/voice" />}>
|
||||
Sprache & Datenschutz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<OfflineBand
|
||||
userId={user.id}
|
||||
pageContentRef={pageContentRef}
|
||||
syncEnabled={sessionMode === "online"}
|
||||
/>
|
||||
<div
|
||||
id={MAIN_CONTENT_ID}
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className={`flex-1 ${contentPaddingClass} outline-none sm:pb-(--safe-area-bottom)`}
|
||||
<AppMotion>
|
||||
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
||||
<a
|
||||
href={`#${MAIN_CONTENT_ID}`}
|
||||
inert={isShellInert}
|
||||
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)]"
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<Sidebar user={user} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header
|
||||
inert={isShellInert}
|
||||
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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center font-heading font-semibold sm:hidden"
|
||||
>
|
||||
<BrandMark withWordmark />
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
|
||||
{user.email}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sunActive}
|
||||
onCheckedChange={(checked) => {
|
||||
setContrast(checked ? "sun" : "normal");
|
||||
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Sun aria-hidden="true" />
|
||||
Sonnenmodus
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href="/settings/voice" />}>
|
||||
Sprache & Datenschutz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<OfflineBand
|
||||
userId={user.id}
|
||||
pageContentRef={pageContentRef}
|
||||
syncEnabled={sessionMode === "online"}
|
||||
inert={isShellInert}
|
||||
/>
|
||||
<div
|
||||
id={MAIN_CONTENT_ID}
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
</div>
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutOpen}
|
||||
onOpenChange={setIsLogoutOpen}
|
||||
userId={user.id}
|
||||
onLoggedOut={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutOpen}
|
||||
onOpenChange={setIsLogoutOpen}
|
||||
userId={user.id}
|
||||
onLoggedOut={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
</AppMotion>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ interface LotFormValues {
|
||||
|
||||
type LotFormErrors = Partial<Record<keyof LotFormValues, string>>;
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||
|
||||
function initialValues(medicine: LotMedicine, lot?: LotView): LotFormValues {
|
||||
if (lot) {
|
||||
|
||||
@@ -129,7 +129,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
||||
<Input
|
||||
id="disposal-quantity"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={quantityText}
|
||||
onChange={(event) => setQuantityText(event.target.value)}
|
||||
/>
|
||||
@@ -141,7 +141,7 @@ function DisposalForm({ lotId, remaining, unit, onDone }: DisposalFormProps) {
|
||||
</Label>
|
||||
<Input
|
||||
id="disposal-reason"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={reason}
|
||||
maxLength={500}
|
||||
placeholder="z. B. abgelaufen, verschüttet"
|
||||
|
||||
@@ -128,7 +128,7 @@ function InventoryForm({ lotId, remaining, unit, onDone }: InventoryFormProps) {
|
||||
<Input
|
||||
id="inventory-counted"
|
||||
inputMode="decimal"
|
||||
className="h-11 sm:h-11"
|
||||
className="h-11 pointer-fine:h-11"
|
||||
value={counted}
|
||||
aria-describedby="inventory-status"
|
||||
onChange={(event) => setCounted(event.target.value)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -89,7 +89,7 @@ interface MedicineFormProps {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
const INPUT_CLASS = "h-11 sm:h-11";
|
||||
const INPUT_CLASS = "h-11 pointer-fine:h-11";
|
||||
|
||||
function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -219,10 +219,11 @@ function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Verschreibungspflichtig"
|
||||
checked={values.prescriptionOnly}
|
||||
onChange={(checked) => set("prescriptionOnly", checked)}
|
||||
onCheckedChange={(checked) => set("prescriptionOnly", checked)}
|
||||
/>
|
||||
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
|
||||
@@ -35,6 +35,22 @@ export default function MedicinesPage() {
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const shown = bucket === "active" ? active : inactive;
|
||||
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={bucket === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!canRead) return <NoReadAccess />;
|
||||
|
||||
return (
|
||||
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
{medicines.length > 0 ? (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{medicines.length > 0 ? countText : ""}
|
||||
</p>
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MedicineGrid medicines={visible} />
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ADMIN_GROUP,
|
||||
BOTTOM_NAV_HREFS,
|
||||
NAV_GROUPS,
|
||||
isFullScreenPage,
|
||||
visibleNavGroups,
|
||||
} from "./nav-config";
|
||||
|
||||
@@ -93,4 +94,13 @@ describe("nav-config", () => {
|
||||
expect(group?.label).toBe("Betrieb");
|
||||
expect(group?.items.find((item) => item.href === "/compliance")?.label).toBe("Prüfbereitschaft");
|
||||
});
|
||||
|
||||
it("knows the Beute editor („Umbauen“) as the one full-screen page", () => {
|
||||
expect(isFullScreenPage("/colonies/c1/hive")).toBe(true);
|
||||
expect(isFullScreenPage("/colonies/c%201/hive/")).toBe(true);
|
||||
expect(isFullScreenPage("/colonies/c1")).toBe(false);
|
||||
expect(isFullScreenPage("/colonies")).toBe(false);
|
||||
expect(isFullScreenPage("/colonies/c1/hive/history")).toBe(false);
|
||||
expect(isFullScreenPage("/apiaries/a1/hive")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -226,3 +226,15 @@ export function bottomNavItems(): NavItem[] {
|
||||
export function isNavActive(pathname: string, href: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
// „Umbauen" (spec 9.5): its own route, drawn over the whole screen inside the shell.
|
||||
const FULL_SCREEN_PAGE = /^\/colonies\/[^/]+\/hive\/?$/;
|
||||
|
||||
/**
|
||||
* Whether the page covers the whole screen. The shell behind it (skip link,
|
||||
* sidebar, header, offline band, bottom nav) then turns inert, so the first
|
||||
* Tab lands in the page and screen readers do not read what cannot be seen.
|
||||
*/
|
||||
export function isFullScreenPage(pathname: string): boolean {
|
||||
return FULL_SCREEN_PAGE.test(pathname);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export default function OfflinePage() {
|
||||
<Link
|
||||
key={stored.apiaryId}
|
||||
href={`/offline/apiary?id=${encodeURIComponent(stored.apiaryId)}`}
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="flex min-h-14 items-center gap-3 rounded-xl border border-border/60 px-3 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-medium">{stored.pack.apiary.name}</span>
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* A new page comes in (spec 17.4): at "Voll" it fades in and rises 6 px (--motion-enter,
|
||||
* --ease-out-expo), at "Dezent" or with the system's reduce motion it only fades (globals.css),
|
||||
* at "Aus" it stands at once. There is no exit: the old page goes at once, navigation never waits.
|
||||
*/
|
||||
export function PageTransition({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div key={pathname} className="motion-safe:animate-page-in">
|
||||
<div key={pathname} className="animate-page-in">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,32 +27,14 @@ export default function ProductsPage() {
|
||||
const products = productsQuery.data ?? [];
|
||||
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
||||
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
||||
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
/>
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
|
||||
return (
|
||||
<QueryState query={productsQuery} what="Produkte">
|
||||
{() =>
|
||||
visibleProducts.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={ShoppingBag}
|
||||
title="Noch keine Produkte"
|
||||
@@ -75,6 +57,30 @@ export default function ProductsPage() {
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
|
||||
import { AppearanceSection } from "./appearance-section";
|
||||
@@ -54,4 +54,18 @@ describe("AppearanceSection", () => {
|
||||
expect(document.documentElement.dataset.motion).toBe("subtle");
|
||||
expect(window.localStorage.getItem("apicore.motion")).toBe("subtle");
|
||||
});
|
||||
it("offers colour scheme, contrast and motion as cards with a description each", () => {
|
||||
render(<AppearanceSection />);
|
||||
|
||||
for (const group of ["Farbschema", "Kontrast", "Bewegung & Effekte"]) {
|
||||
for (const radio of within(screen.getByRole("radiogroup", { name: group })).getAllByRole("radio")) {
|
||||
expect(radio).toHaveClass("min-h-14");
|
||||
expect(radio).toHaveAccessibleDescription(/\S/);
|
||||
}
|
||||
}
|
||||
expect(screen.getByRole("radio", { name: "Hell" })).toHaveAccessibleDescription("Am besten bei Tageslicht.");
|
||||
expect(screen.getByRole("radio", { name: "Dezent" })).toHaveAccessibleDescription(
|
||||
"Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,17 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
|
||||
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
|
||||
|
||||
const THEME_OPTIONS: readonly ChoiceOption<ThemeChoice>[] = [
|
||||
{ value: "light", label: "Hell – am besten bei Tageslicht" },
|
||||
{ value: "dark", label: "Dunkel – für abends" },
|
||||
{ value: "system", label: "Wie Gerät" },
|
||||
{ value: "light", label: "Hell", description: "Am besten bei Tageslicht." },
|
||||
{ value: "dark", label: "Dunkel", description: "Für abends." },
|
||||
{ value: "system", label: "Wie Gerät", description: "Folgt der Einstellung des Geräts." },
|
||||
];
|
||||
|
||||
const CONTRAST_OPTIONS: readonly ChoiceOption<ContrastChoice>[] = [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "sun", label: "Sonne – maximaler Kontrast, größere Schrift, für draußen" },
|
||||
{ value: "normal", label: "Normal", description: "Für drinnen und Schatten." },
|
||||
{ value: "sun", label: "Sonne", description: "Maximaler Kontrast, größere Schrift, für draußen." },
|
||||
];
|
||||
|
||||
const MOTION_LABELS: Record<MotionLevel, string> = { full: "Voll", subtle: "Dezent", off: "Aus" };
|
||||
@@ -28,11 +26,12 @@ const MOTION_DESCRIPTIONS: Record<MotionLevel, string> = {
|
||||
const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((value) => ({
|
||||
value,
|
||||
label: MOTION_LABELS[value],
|
||||
description: MOTION_DESCRIPTIONS[value],
|
||||
}));
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
|
||||
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
|
||||
const ids = { theme: useId(), contrast: useId(), motion: useId() };
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -53,7 +52,7 @@ export function AppearanceSection() {
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
labelledBy={ids.theme}
|
||||
optionClassName={OPTION_CLASS}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -65,7 +64,7 @@ export function AppearanceSection() {
|
||||
value={contrast}
|
||||
onChange={setContrast}
|
||||
labelledBy={ids.contrast}
|
||||
optionClassName={OPTION_CLASS}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -77,13 +76,8 @@ export function AppearanceSection() {
|
||||
value={motion}
|
||||
onChange={setMotion}
|
||||
labelledBy={ids.motion}
|
||||
describedBy={ids.motionHint}
|
||||
className="grid-cols-3"
|
||||
optionClassName="px-3 py-2"
|
||||
appearance="cards"
|
||||
/>
|
||||
<p id={ids.motionHint} className="text-sm text-muted-foreground">
|
||||
{MOTION_DESCRIPTIONS[motion]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Gilt nur für dieses Gerät.</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
validateQuietHours,
|
||||
} from "@/lib/notification-settings/validation";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -104,17 +104,19 @@ function NotificationSettingsForm({
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Erinnerung je Aufgabe"
|
||||
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
||||
checked={draft.taskDueEnabled}
|
||||
onChange={(checked) => change("taskDueEnabled", checked)}
|
||||
onCheckedChange={(checked) => change("taskDueEnabled", checked)}
|
||||
/>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Morgenübersicht"
|
||||
description="Eine Meldung am Morgen mit allem, was heute ansteht."
|
||||
checked={draft.digestEnabled}
|
||||
onChange={(checked) => change("digestEnabled", checked)}
|
||||
onCheckedChange={(checked) => change("digestEnabled", checked)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
|
||||
|
||||
@@ -26,8 +26,7 @@ import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
|
||||
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
|
||||
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { ErrorState } from "@/components/query-state";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@@ -100,23 +99,26 @@ function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps
|
||||
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NOTIFICATION_CATEGORIES.map((category) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={category}
|
||||
label={NOTIFICATION_CATEGORY_LABELS[category]}
|
||||
checked={settings.telegramCategories.includes(category)}
|
||||
onChange={(checked) => toggleCategory(category, checked)}
|
||||
onCheckedChange={(checked) => toggleCategory(category, checked)}
|
||||
describedBy={isOffline ? hintId : undefined}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Mit Details"
|
||||
description={PRIVACY_NOTE}
|
||||
checked={settings.telegramDetail}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||
describedBy={isOffline ? hintId : undefined}
|
||||
onCheckedChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
|
||||
@@ -28,8 +28,26 @@ export default function QueensPage() {
|
||||
const queens = queensQuery.data ?? [];
|
||||
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
|
||||
const historyQueens = queens.filter((queen) => queen.deactivatedAt !== null);
|
||||
const bucketQueens = tab === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketQueens = bucket === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
return (
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -44,40 +62,28 @@ export default function QueensPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SheetTrigger } from "@/components/ui/sheet";
|
||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||
import { offersRecording, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch";
|
||||
import { isFullScreenPage } from "./nav-config";
|
||||
import { QUICK_ACTIONS } from "./quick-actions";
|
||||
|
||||
// Create and edit forms end in a save bar (StickySubmit) right where the
|
||||
@@ -24,6 +25,8 @@ const FORM_ROUTE = /\/(new|edit)(\/|$)/;
|
||||
export function hasQuickAddButton(pathname: string, permissions: readonly string[]): boolean {
|
||||
return (
|
||||
!FORM_ROUTE.test(pathname) &&
|
||||
// A full-screen page (the Beute editor) would sit under the floating button.
|
||||
!isFullScreenPage(pathname) &&
|
||||
QUICK_ACTIONS.some((action) => permissions.includes(action.permission))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { formatGeneratedNumber } from "@/lib/automation/number-format";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -280,11 +280,12 @@ function RuleCard({
|
||||
))}
|
||||
{!numberFormatRule ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Zusätzlich melden"
|
||||
checked={notify}
|
||||
describedBy={`notify-hint-${rule.id}`}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
clearIssues("notify");
|
||||
setNotify(checked);
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CalendarSyncSettings } from "@/lib/calendar-sync/types";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -81,14 +81,12 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
|
||||
Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die
|
||||
Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sync-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => setEnabled(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="sync-enabled"
|
||||
label="Synchronisation aktiv"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="sync-group-id">Kennung des Microsoft-365-Gruppenkalenders</Label>
|
||||
<Input
|
||||
@@ -103,19 +101,20 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
|
||||
„Objekt-ID“. Bei Unklarheit die IT fragen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Welche Einträge übertragen werden</p>
|
||||
<div role="group" aria-labelledby="sync-types-label" className="flex flex-col gap-1">
|
||||
<p id="sync-types-label" className="mb-1 text-sm font-medium">
|
||||
Welche Einträge übertragen werden
|
||||
</p>
|
||||
{SYNC_TYPE_FIELDS.map((field) => (
|
||||
<div key={field.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`sync-${field.key}`}
|
||||
checked={typeFlags[field.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`sync-${field.key}`}>{field.label}</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
key={field.key}
|
||||
id={`sync-${field.key}`}
|
||||
label={field.label}
|
||||
checked={typeFlags[field.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setTypeFlags((prev) => ({ ...prev, [field.key]: checked }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -133,28 +134,21 @@ export function CreateWebhookDialog() {
|
||||
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ereignisse</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div role="group" aria-labelledby="webhook-events-label" className="flex flex-col gap-2">
|
||||
<span id="webhook-events-label" className="text-sm font-medium">
|
||||
Ereignisse
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WEBHOOK_EVENTS.map((webhookEvent) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={webhookEvent}
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor={`webhook-event-${webhookEvent}`}
|
||||
>
|
||||
<input
|
||||
id={`webhook-event-${webhookEvent}`}
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(current, webhookEvent, inputEvent.target.checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
id={`webhook-event-${webhookEvent}`}
|
||||
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
checked={events.includes(webhookEvent)}
|
||||
onCheckedChange={(checked) =>
|
||||
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/t
|
||||
import { TaskPriority } from "@/lib/tasks/types";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { fetchUsers } from "@/lib/users/api";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -193,19 +194,13 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="template-edit-active"
|
||||
>
|
||||
<input
|
||||
id="template-edit-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
Aktiv – eine deaktivierte Vorlage erzeugt keine Aufgaben mehr
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="template-edit-active"
|
||||
label="Aktiv"
|
||||
description="Eine deaktivierte Vorlage erzeugt keine Aufgaben mehr."
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -81,48 +82,31 @@ function EditWebhookForm({
|
||||
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ereignisse</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div role="group" aria-labelledby="webhook-edit-events-label" className="flex flex-col gap-2">
|
||||
<span id="webhook-edit-events-label" className="text-sm font-medium">
|
||||
Ereignisse
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WEBHOOK_EVENTS.map((webhookEvent) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={webhookEvent}
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor={`webhook-edit-event-${webhookEvent}`}
|
||||
>
|
||||
<input
|
||||
id={`webhook-edit-event-${webhookEvent}`}
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(
|
||||
current,
|
||||
webhookEvent,
|
||||
inputEvent.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
id={`webhook-edit-event-${webhookEvent}`}
|
||||
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
checked={events.includes(webhookEvent)}
|
||||
onCheckedChange={(checked) =>
|
||||
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="webhook-edit-active"
|
||||
>
|
||||
<input
|
||||
id="webhook-edit-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={active}
|
||||
onChange={(inputEvent) => setActive(inputEvent.target.checked)}
|
||||
/>
|
||||
Aktiv – bei deaktiviertem Webhook werden keine Ereignisse zugestellt
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="webhook-edit-active"
|
||||
label="Aktiv"
|
||||
description="Bei deaktiviertem Webhook werden keine Ereignisse zugestellt."
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||
@@ -22,17 +22,17 @@ type Tab =
|
||||
| "api-keys";
|
||||
|
||||
// Everyday settings first, connections to other systems last.
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "rules", label: "Vorschläge" },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||
{ value: "social-media", label: "Stammdaten & Web" },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||
{ value: "api-keys", label: "API-Schlüssel" },
|
||||
{ value: "webhooks", label: "Webhooks" },
|
||||
const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
|
||||
{ value: "rules", label: "Vorschläge", Content: AutomationRulesTab },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben", Content: TaskTemplatesTab },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen", Content: LabelTemplatesTab },
|
||||
{ value: "social-media", label: "Stammdaten & Web", Content: SocialMediaTab },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync", Content: CalendarSyncTab },
|
||||
{ value: "api-keys", label: "API-Schlüssel", Content: ApiKeysTab },
|
||||
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return TABS.some((tab) => tab.value === value);
|
||||
}
|
||||
|
||||
@@ -54,15 +54,25 @@ function AutomationPageContent() {
|
||||
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "webhooks" ? <WebhooksTab /> : null}
|
||||
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
|
||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||
{tab === "api-keys" ? <ApiKeysTab /> : null}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Bereiche der Automatisierung">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TABS.map(({ value, Content }) => (
|
||||
<TabsContent key={value} value={value}>
|
||||
<Content />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
onClick={onToggle}
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
className="flex min-h-11 w-full items-start gap-3 rounded-xl px-4 py-1 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
@@ -41,7 +41,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
|
||||
className={cn("mt-1 size-4 shrink-0 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FormEvent, ReactNode, useId, useMemo } from "react";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -118,10 +118,11 @@ export function RuleToggle({ trigger, draft, path, label, hint }: RuleToggleProp
|
||||
const errorId = error ? `${baseId}-error` : undefined;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label={label}
|
||||
checked={draft.values[path] === true}
|
||||
onChange={(checked) => draft.set(path, checked)}
|
||||
onCheckedChange={(checked) => draft.set(path, checked)}
|
||||
describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined}
|
||||
/>
|
||||
{hintText ? (
|
||||
|
||||
@@ -48,74 +48,81 @@ export function TaskTemplatesTab() {
|
||||
|
||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||
const historyTemplates = (templates ?? []).filter((template) => !template.active);
|
||||
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleTemplates = bucket === "active" ? activeTemplates : historyTemplates;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={bucket === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
/>
|
||||
<CreateTaskTemplateDialog />
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={tab === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
toolbar={<CreateTaskTemplateDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -146,16 +147,13 @@ function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; on
|
||||
</Field>
|
||||
))}
|
||||
{system ? (
|
||||
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
|
||||
<input
|
||||
id="hive-system-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={values.active}
|
||||
onChange={(event) => setValues({ ...values, active: event.target.checked })}
|
||||
/>
|
||||
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="hive-system-active"
|
||||
label="Aktiv"
|
||||
description="Ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl."
|
||||
checked={values.active}
|
||||
onCheckedChange={(active) => setValues({ ...values, active })}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { LabelLineElement, LabelTextElement } from "@/lib/label-templates/types";
|
||||
import { ElementPropertiesPanel } from "./element-properties-panel";
|
||||
|
||||
const LINE: LabelLineElement = {
|
||||
id: "l-1",
|
||||
type: "line",
|
||||
x: 2,
|
||||
y: 3,
|
||||
length: 40,
|
||||
thickness: 1,
|
||||
color: "#000000",
|
||||
orientation: "horizontal",
|
||||
};
|
||||
|
||||
const TEXT: LabelTextElement = {
|
||||
id: "t-1",
|
||||
type: "text",
|
||||
x: 2,
|
||||
y: 3,
|
||||
width: 40,
|
||||
height: 8,
|
||||
text: "Honig",
|
||||
fontSize: 10,
|
||||
fontFamily: "sans",
|
||||
bold: true,
|
||||
italic: false,
|
||||
color: "#000000",
|
||||
align: "left",
|
||||
};
|
||||
|
||||
function renderPanel(element: LabelLineElement | LabelTextElement, onChange = vi.fn()) {
|
||||
render(
|
||||
<ElementPropertiesPanel element={element} onChange={onChange} onDelete={vi.fn()} onDuplicate={vi.fn()} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe("ElementPropertiesPanel", () => {
|
||||
it("the label designer line alignment is a ChoiceGroup", async () => {
|
||||
const onChange = renderPanel(LINE);
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: "Ausrichtung der Linie" });
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Waagerecht" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("radio", { name: "Senkrecht" })).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Senkrecht" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ orientation: "vertical" });
|
||||
});
|
||||
|
||||
it("bold and italic are named toolbar toggles with aria-pressed", async () => {
|
||||
const onChange = renderPanel(TEXT);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Fett" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Kursiv" })).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Kursiv" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ italic: true });
|
||||
});
|
||||
});
|
||||
+29
-23
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Bold, Copy, Italic, Trash2 } from "lucide-react";
|
||||
import {
|
||||
LabelElement,
|
||||
LabelFontFamily,
|
||||
LabelLineOrientation,
|
||||
LabelTextAlign,
|
||||
PLACEHOLDER_KEYS,
|
||||
PLACEHOLDER_LABELS,
|
||||
@@ -12,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
|
||||
const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = {
|
||||
sans: "Serifenlos",
|
||||
@@ -25,6 +28,11 @@ const ALIGN_LABELS: Record<LabelTextAlign, string> = {
|
||||
right: "Rechts",
|
||||
};
|
||||
|
||||
const ORIENTATION_OPTIONS = choiceOptions<LabelLineOrientation>({
|
||||
horizontal: "Waagerecht",
|
||||
vertical: "Senkrecht",
|
||||
});
|
||||
|
||||
interface ElementPropertiesPanelProps {
|
||||
element: LabelElement;
|
||||
onChange: (patch: Partial<LabelElement>) => void;
|
||||
@@ -44,7 +52,8 @@ export function ElementPropertiesPanel({
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
{/* pointer-coarse:gap-4: the 44 px hit areas of the two 28 px buttons must not overlap. */}
|
||||
<div className="flex gap-1 pointer-coarse:gap-4">
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onDuplicate} title="Duplizieren">
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
@@ -202,22 +211,26 @@ function TextFields({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex gap-1.5 pointer-coarse:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.bold ? "default" : "outline"}
|
||||
size="icon-sm"
|
||||
pressed={element.bold}
|
||||
aria-label="Fett"
|
||||
onClick={() => onChange({ bold: !element.bold })}
|
||||
>
|
||||
<Bold className="size-3.5" />
|
||||
<Bold aria-hidden className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.italic ? "default" : "outline"}
|
||||
size="icon-sm"
|
||||
pressed={element.italic}
|
||||
aria-label="Kursiv"
|
||||
onClick={() => onChange({ italic: !element.italic })}
|
||||
>
|
||||
<Italic className="size-3.5" />
|
||||
<Italic aria-hidden className="size-3.5" />
|
||||
</Button>
|
||||
<EnumSelect
|
||||
id="element-text-align"
|
||||
@@ -239,6 +252,7 @@ function LineFields({
|
||||
element: Extract<LabelElement, { type: "line" }>;
|
||||
onChange: (patch: Partial<LabelElement>) => void;
|
||||
}) {
|
||||
const orientationLabelId = useId();
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
@@ -273,25 +287,17 @@ function LineFields({
|
||||
onChange={(event) => onChange({ color: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.orientation === "horizontal" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onChange({ orientation: "horizontal" })}
|
||||
>
|
||||
Waagerecht
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={element.orientation === "vertical" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => onChange({ orientation: "vertical" })}
|
||||
>
|
||||
Senkrecht
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={orientationLabelId} className="text-sm leading-none font-medium">
|
||||
Ausrichtung der Linie
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={ORIENTATION_OPTIONS}
|
||||
value={element.orientation}
|
||||
onChange={(orientation) => onChange({ orientation })}
|
||||
labelledBy={orientationLabelId}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+14
-26
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/label-templates/check";
|
||||
import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
|
||||
import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -659,7 +660,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
id="designer-min-x-height"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="min-h-11 sm:h-11"
|
||||
className="min-h-11 pointer-fine:h-11"
|
||||
value={minXHeightText}
|
||||
aria-invalid={minXHeightInvalid ? true : undefined}
|
||||
aria-describedby="designer-min-x-height-hint"
|
||||
@@ -680,15 +681,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
|
||||
<input
|
||||
id="designer-default"
|
||||
type="checkbox"
|
||||
checked={isDefault}
|
||||
onChange={(event) => setIsDefault(event.target.checked)}
|
||||
/>
|
||||
Als Standardvorlage verwenden
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="designer-default"
|
||||
label="Als Standardvorlage verwenden"
|
||||
checked={isDefault}
|
||||
onCheckedChange={setIsDefault}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -786,22 +784,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
checked={cropMarks}
|
||||
onChange={(event) => setCropMarks(event.target.checked)}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Schnittmarken am Blattrand</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider.
|
||||
Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die
|
||||
Striche gedruckt werden.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<CheckboxField
|
||||
label="Schnittmarken am Blattrand"
|
||||
description="Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider. Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die Striche gedruckt werden."
|
||||
checked={cropMarks}
|
||||
onCheckedChange={setCropMarks}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AutomationPage from "./automation/page";
|
||||
import UsersRolesPage from "./users-roles/page";
|
||||
|
||||
const replace = vi.fn();
|
||||
let search = "";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
useSearchParams: () => new URLSearchParams(search),
|
||||
}));
|
||||
|
||||
// The tab contents fetch their own data; here only the switch between them matters.
|
||||
vi.mock("./automation/webhooks-tab", () => ({ WebhooksTab: () => <p>Inhalt Webhooks</p> }));
|
||||
vi.mock("./automation/task-templates-tab", () => ({ TaskTemplatesTab: () => <p>Inhalt Vorlagen</p> }));
|
||||
vi.mock("./automation/label-templates-tab", () => ({ LabelTemplatesTab: () => <p>Inhalt Etiketten</p> }));
|
||||
vi.mock("./automation/social-media-tab", () => ({ SocialMediaTab: () => <p>Inhalt Stammdaten</p> }));
|
||||
vi.mock("./automation/automation-rules-tab", () => ({ AutomationRulesTab: () => <p>Inhalt Vorschläge</p> }));
|
||||
vi.mock("./automation/calendar-sync-tab", () => ({ CalendarSyncTab: () => <p>Inhalt Kalender</p> }));
|
||||
vi.mock("./automation/api-keys-tab", () => ({ ApiKeysTab: () => <p>Inhalt API</p> }));
|
||||
vi.mock("./users-roles/users-tab", () => ({ UsersTab: () => <p>Inhalt Benutzer</p> }));
|
||||
vi.mock("./users-roles/roles-tab", () => ({ RolesTab: () => <p>Inhalt Rollen</p> }));
|
||||
|
||||
describe("both settings pages use Tabs directly", () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset();
|
||||
search = "";
|
||||
});
|
||||
|
||||
it("Automatisierung: ?tab= picks the tab, the panel is labelled by it and a switch keeps the URL form", async () => {
|
||||
search = "tab=api-keys";
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage />);
|
||||
|
||||
const selected = screen.getByRole("tab", { name: "API-Schlüssel" });
|
||||
expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "API-Schlüssel" })).toHaveTextContent("Inhalt API");
|
||||
expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Webhooks" }));
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
|
||||
search = "tab=unknown";
|
||||
render(<AutomationPage />);
|
||||
expect(screen.getByRole("tab", { name: "Vorschläge" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Vorschläge");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: an arrow only moves the focus, Enter switches the tab through the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsersRolesPage />);
|
||||
|
||||
expect(screen.getByRole("tabpanel", { name: "Benutzer" })).toHaveTextContent("Inhalt Benutzer");
|
||||
await user.click(screen.getByRole("tab", { name: "Benutzer" }));
|
||||
replace.mockReset();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveFocus();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/users-roles?tab=roles");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: ?tab=roles shows the roles", () => {
|
||||
search = "tab=roles";
|
||||
render(<UsersRolesPage />);
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Rollen");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UsersTab } from "./users-tab";
|
||||
import { RolesTab } from "./roles-tab";
|
||||
|
||||
@@ -14,7 +14,7 @@ const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "roles", label: "Rollen & Rechte" },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return value === "users" || value === "roles";
|
||||
}
|
||||
|
||||
@@ -36,9 +36,26 @@ function UsersRolesPageContent() {
|
||||
description="Zugänge, Rollen und Berechtigungen verwalten"
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "users" ? <UsersTab /> : <RolesTab />}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Benutzer oder Rollen">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="users">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<RolesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PermissionCatalogEntry } from "@/lib/roles/types";
|
||||
import { PermissionMatrix } from "./permission-matrix";
|
||||
|
||||
function entry(key: string, labelDe: string, sortOrder: number): PermissionCatalogEntry {
|
||||
return { key, labelDe, groupDe: "Völker", sortOrder } as PermissionCatalogEntry;
|
||||
}
|
||||
|
||||
const CATALOG = [entry("colonies:read", "Ansehen", 1), entry("colonies:update", "Bearbeiten", 2)];
|
||||
|
||||
function Harness({ disabled }: { disabled?: boolean }) {
|
||||
const [selected, setSelected] = useState(new Set(["colonies:read"]));
|
||||
return <PermissionMatrix catalog={CATALOG} selected={selected} onChange={setSelected} disabled={disabled} />;
|
||||
}
|
||||
|
||||
describe("PermissionMatrix", () => {
|
||||
it("the group box is partly checked and checks the whole group", async () => {
|
||||
render(<Harness />);
|
||||
const group = screen.getByRole("checkbox", { name: "Völker" });
|
||||
expect(group).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(group).toHaveAccessibleDescription("1 von 2 gewählt");
|
||||
|
||||
await userEvent.click(group);
|
||||
expect(group).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Bearbeiten" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("neighbouring rows keep at least 4 px between them", () => {
|
||||
render(<Harness />);
|
||||
const group = screen.getByRole("checkbox", { name: "Völker" });
|
||||
const entries = screen.getByRole("checkbox", { name: "Ansehen" }).parentElement as HTMLElement;
|
||||
expect(group.parentElement?.className).toMatch(/(^|\s)gap-1(\s|$)/);
|
||||
expect(entries.className).toMatch(/(^|\s)gap-y-1(\s|$)/);
|
||||
});
|
||||
|
||||
it("disabled says why, visibly and on every box", () => {
|
||||
render(<Harness disabled />);
|
||||
const hint = screen.getByText(/Rechte lassen sich hier nicht ändern/);
|
||||
expect(hint).toBeVisible();
|
||||
for (const box of screen.getAllByRole("checkbox")) {
|
||||
expect(box).toBeDisabled();
|
||||
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useId, useMemo } from "react";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { PermissionCatalogEntry } from "@/lib/roles/types";
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
@@ -10,8 +9,12 @@ interface PermissionMatrixProps {
|
||||
selected: Set<string>;
|
||||
onChange: (next: Set<string>) => void;
|
||||
disabled?: boolean;
|
||||
/** Why the rights are off; shown above the matrix while disabled (not grey only). */
|
||||
disabledHint?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_DISABLED_HINT = "Die Rechte lassen sich hier nicht ändern.";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -41,8 +44,12 @@ export function PermissionMatrix({
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
disabledHint = DEFAULT_DISABLED_HINT,
|
||||
}: PermissionMatrixProps) {
|
||||
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
|
||||
const hintId = useId();
|
||||
// One visible reason for the whole matrix instead of a line under every box.
|
||||
const describedBy = disabled ? hintId : undefined;
|
||||
|
||||
function toggleOne(key: string, checked: boolean) {
|
||||
const next = new Set(selected);
|
||||
@@ -67,44 +74,47 @@ export function PermissionMatrix({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-border/60 p-3">
|
||||
// Container queries: the matrix sits in a 384 px dialog on a wide screen,
|
||||
// so the columns follow its own width, not the viewport.
|
||||
<div className="@container flex flex-col gap-4 rounded-xl border border-border/60 p-3">
|
||||
{disabled ? (
|
||||
<p id={hintId} className="text-sm font-medium text-muted-foreground">
|
||||
{disabledHint}
|
||||
</p>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const groupSelectedCount = group.entries.filter((entry) =>
|
||||
selected.has(entry.key),
|
||||
).length;
|
||||
const allSelected = groupSelectedCount === group.entries.length;
|
||||
const groupState =
|
||||
groupSelectedCount === group.entries.length
|
||||
? true
|
||||
: groupSelectedCount > 0
|
||||
? "indeterminate"
|
||||
: false;
|
||||
return (
|
||||
<div key={group.groupDe} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`group-${slugify(group.groupDe)}`}
|
||||
checked={allSelected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`group-${slugify(group.groupDe)}`}
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
{group.groupDe}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{groupSelectedCount}/{group.entries.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 pl-6 sm:grid-cols-3">
|
||||
<div key={group.groupDe} className="flex flex-col gap-1">
|
||||
<CheckboxField
|
||||
id={`group-${slugify(group.groupDe)}`}
|
||||
className="font-semibold"
|
||||
label={group.groupDe}
|
||||
description={`${groupSelectedCount} von ${group.entries.length} gewählt`}
|
||||
checked={groupState}
|
||||
disabled={disabled}
|
||||
describedBy={describedBy}
|
||||
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-1 pl-8 @lg:grid-cols-2 @3xl:grid-cols-3">
|
||||
{group.entries.map((entry) => (
|
||||
<div key={entry.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={selected.has(entry.key)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
|
||||
/>
|
||||
<Label htmlFor={`perm-${entry.key}`} className="text-sm font-normal">
|
||||
{entry.labelDe}
|
||||
</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
key={entry.key}
|
||||
id={`perm-${entry.key}`}
|
||||
label={entry.labelDe}
|
||||
checked={selected.has(entry.key)}
|
||||
disabled={disabled}
|
||||
describedBy={describedBy}
|
||||
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,112 +54,119 @@ export function RolesTab() {
|
||||
|
||||
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
||||
const visibleRoles = tab === "active" ? activeRoles : historyRoles;
|
||||
const catalogLength = catalog?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
/>
|
||||
<CreateRoleDialog />
|
||||
</div>
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleRoles = bucket === "active" ? activeRoles : historyRoles;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={bucket === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={tab === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Reaktivieren
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
>
|
||||
Reaktivieren
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
toolbar={<CreateRoleDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
|
||||
import { isListedVoiceStatus } from "@/lib/voice/status-texts";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -45,20 +46,14 @@ function CloudConsentCard() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
|
||||
<label
|
||||
htmlFor="voice-cloud-consent"
|
||||
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
|
||||
<input
|
||||
id="voice-cloud-consent"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
className="size-6 shrink-0 accent-[var(--primary)]"
|
||||
checked={allowed}
|
||||
onChange={(event) => toggle(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="voice-cloud-consent"
|
||||
role="switch"
|
||||
size="sheet"
|
||||
label="Browser-Cloud-Erkennung erlauben"
|
||||
checked={allowed}
|
||||
onCheckedChange={toggle}
|
||||
/>
|
||||
<p role="status" className="text-sm font-medium">
|
||||
{allowed ? "Erlaubt" : "Aus"}
|
||||
</p>
|
||||
|
||||
@@ -17,18 +17,13 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-150",
|
||||
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4.5 shrink-0 transition-transform duration-150",
|
||||
!isActive && "motion-full:group-hover:scale-110",
|
||||
)}
|
||||
/>
|
||||
<Icon className="size-4.5 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||
</Link>
|
||||
|
||||
@@ -55,4 +55,12 @@ describe("StatisticsPage", () => {
|
||||
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the statistics page uses the segment variant", () => {
|
||||
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||
renderWithClient(<StatisticsPage />);
|
||||
const list = screen.getByRole("tablist", { hidden: true });
|
||||
expect(list).toHaveAttribute("data-variant", "segment");
|
||||
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,21 @@ export default function StoragePage() {
|
||||
const containers = containersQuery.data ?? [];
|
||||
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
||||
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
|
||||
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
|
||||
|
||||
const containersOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleContainers = bucket === "active" ? activeContainers : historyContainers;
|
||||
return (
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={bucket} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-8 p-4 sm:p-6">
|
||||
@@ -47,24 +61,15 @@ export default function StoragePage() {
|
||||
actions={<CreateStorageContainerDialog />}
|
||||
/>
|
||||
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
|
||||
<ActiveHistoryTabs
|
||||
value={containerTab}
|
||||
onChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
/>
|
||||
</div>
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={containerTab} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
<ActiveHistoryTabs
|
||||
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||
value={containerTab}
|
||||
onValueChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
>
|
||||
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
||||
|
||||
@@ -505,7 +505,7 @@ describe("SuggestionsPage", () => {
|
||||
|
||||
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
|
||||
const chevron = toggle.querySelector("svg");
|
||||
expect(chevron).toHaveClass("motion-safe:transition-transform");
|
||||
expect(chevron).toHaveClass("motion-full:transition-transform");
|
||||
expect(chevron).not.toHaveClass("transition-transform");
|
||||
});
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ export default function SuggestionsPage() {
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="suggestion-count"
|
||||
className="min-h-5 text-sm font-medium outline-none"
|
||||
className="min-h-5 text-sm font-medium"
|
||||
>
|
||||
{countText(loaded, total)}
|
||||
</p>
|
||||
@@ -347,7 +347,7 @@ export default function SuggestionsPage() {
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
|
||||
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", showDismissed && "rotate-180")}
|
||||
/>
|
||||
Verworfene (30 Tage)
|
||||
</Button>
|
||||
|
||||
@@ -90,4 +90,17 @@ describe("NewSwarmControlCheckPage", () => {
|
||||
expect(screen.getByLabelText(/^Datum/)).toHaveFocus();
|
||||
expect(createSwarmControlCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
it("swarm control new counts queen cells with NumberStepper", async () => {
|
||||
vi.mocked(createSwarmControlCheck).mockResolvedValue({ id: "s-1", colonyId: "colony-1" } as never);
|
||||
renderPage();
|
||||
|
||||
const cells = screen.getByLabelText(/Weiselzellen gefunden/);
|
||||
expect(cells).toHaveAttribute("inputmode", "numeric");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Weiselzelle mehr/ }));
|
||||
expect(cells).toHaveValue("2");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Kontrolle speichern" }));
|
||||
|
||||
expect(vi.mocked(createSwarmControlCheck).mock.calls[0][0]).toMatchObject({ queenCellsFound: 2 });
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,11 @@ import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
@@ -174,24 +175,22 @@ export default function NewSwarmControlCheckPage() {
|
||||
hint="Zellen, in denen eine neue Königin heranwächst – groß, zapfenförmig, meist am Wabenrand."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
value={values.queenCellsFound}
|
||||
onChange={(event) =>
|
||||
set("queenCellsFound", event.target.value)
|
||||
}
|
||||
onChange={(count) => set("queenCellsFound", count)}
|
||||
unitLabel="Weiselzellen"
|
||||
decrementLabel="Eine Weiselzelle weniger"
|
||||
incrementLabel="Eine Weiselzelle mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Weiselzellen bereits verdeckelt (akut)"
|
||||
checked={values.queenCellsCapped}
|
||||
onChange={(checked) => set("queenCellsCapped", checked)}
|
||||
onCheckedChange={(checked) => set("queenCellsCapped", checked)}
|
||||
/>
|
||||
<Field
|
||||
label="Schwarmstimmung"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TasksPage from "./page";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tasks/api", () => ({
|
||||
fetchTasks: vi.fn(),
|
||||
completeTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./task-edit-dialog", () => ({ TaskEditDialog: () => null }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TasksPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("TasksPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTasks).mockReset();
|
||||
vi.mocked(fetchTasks).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("refetches with the chosen priority when the filter changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = renderPage();
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenCalledWith({ priority: undefined }));
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Nach Priorität filtern" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Hoch" }));
|
||||
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenLastCalledWith({ priority: "HIGH" }));
|
||||
// The key still starts with "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) hits it.
|
||||
const keys = client.getQueryCache().getAll().map((query) => query.queryKey);
|
||||
expect(keys.every((key) => key[0] === "tasks")).toBe(true);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
@@ -26,7 +27,9 @@ export default function TasksPage() {
|
||||
const [onlyOverdue, setOnlyOverdue] = useState(false);
|
||||
|
||||
const { data: tasks, isLoading, isError } = useQuery({
|
||||
queryKey: ["tasks"],
|
||||
// The filter belongs to the key, or a new priority never refetches. The key keeps the
|
||||
// prefix "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) still reaches it.
|
||||
queryKey: ["tasks", "list", { priority: priority || null }],
|
||||
queryFn: () => fetchTasks({ priority: priority || undefined }),
|
||||
});
|
||||
|
||||
@@ -43,11 +46,79 @@ export default function TasksPage() {
|
||||
const [now] = useState(() => Date.now());
|
||||
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
|
||||
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
|
||||
const bucketTasks = tab === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketTasks = bucket === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={bucket === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -62,95 +133,35 @@ export default function TasksPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
{tab === "active" ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOverdue}
|
||||
onChange={(event) => setOnlyOverdue(event.target.checked)}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
toolbar={
|
||||
<>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
Nur überfällige
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "active" ? (
|
||||
<CheckboxField
|
||||
className="w-auto"
|
||||
label="Nur überfällige"
|
||||
checked={onlyOverdue}
|
||||
onCheckedChange={setOnlyOverdue}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,15 @@ describe("NewVarroaMeasurementPage", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Messung speichern" }));
|
||||
}
|
||||
|
||||
it("counts the mites of one colony with NumberStepper", async () => {
|
||||
renderPage();
|
||||
|
||||
const miteCount = screen.getByLabelText(/^Milbenzahl/);
|
||||
expect(miteCount).toHaveAttribute("inputmode", "numeric");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine Milbe mehr/ }));
|
||||
expect(miteCount).toHaveValue("1");
|
||||
});
|
||||
|
||||
it("preselects no period chip", () => {
|
||||
renderPage();
|
||||
for (const name of ["1 Tag", "2 Tage", "3 Tage", "7 Tage", "anders"]) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { BulkSubmitNote } from "@/components/form/bulk-submit-note";
|
||||
import { CaptureColonyField } from "@/components/form/capture-colony-field";
|
||||
@@ -332,15 +333,14 @@ export default function NewVarroaMeasurementPage() {
|
||||
}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
<NumberStepper
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
className="h-11"
|
||||
describedBy={describedBy}
|
||||
value={values.miteCount}
|
||||
onChange={(event) => set("miteCount", event.target.value)}
|
||||
onChange={(count) => set("miteCount", count)}
|
||||
unitLabel="Milben"
|
||||
decrementLabel="Eine Milbe weniger"
|
||||
incrementLabel="Eine Milbe mehr"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/voice/use-voice-recording-switch";
|
||||
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -175,19 +176,19 @@ function VoiceNotes() {
|
||||
/>
|
||||
) : null}
|
||||
{isEmpty ? null : (
|
||||
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||
<PresenceList className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||
{local.map((note) => (
|
||||
<li key={note.id}>
|
||||
<PresenceItem key={note.id}>
|
||||
<LocalVoiceCard
|
||||
note={note}
|
||||
colonyName={colonyName(note.colonyId)}
|
||||
online={online}
|
||||
onDiscard={() => discardLocal.mutate(note.id)}
|
||||
/>
|
||||
</li>
|
||||
</PresenceItem>
|
||||
))}
|
||||
{listed.map((item) => (
|
||||
<li key={item.id}>
|
||||
<PresenceItem key={item.id}>
|
||||
<VoiceCaptureCard
|
||||
item={item}
|
||||
extractionEnabled={extractionEnabled}
|
||||
@@ -195,9 +196,9 @@ function VoiceNotes() {
|
||||
onRetry={() => retry.mutate(item.id)}
|
||||
onDiscard={() => discard.mutate(item.id)}
|
||||
/>
|
||||
</li>
|
||||
</PresenceItem>
|
||||
))}
|
||||
</ul>
|
||||
</PresenceList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -257,7 +257,7 @@ function WinterContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
className="size-11 pointer-fine:size-8"
|
||||
aria-label="Vorheriger Winter"
|
||||
onClick={() => setWinterYear((year) => year - 1)}
|
||||
>
|
||||
@@ -267,7 +267,7 @@ function WinterContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-8"
|
||||
className="size-11 pointer-fine:size-8"
|
||||
aria-label="Nächster Winter"
|
||||
onClick={() => setWinterYear((year) => year + 1)}
|
||||
>
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("WinterCheckDialog", () => {
|
||||
it("records a lost colony with its loss cause", async () => {
|
||||
renderDialog({ season: "SPRING" });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verloren" }));
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Verloren" }));
|
||||
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
|
||||
const cause = screen.getByRole("combobox", { name: "Verlustursache" });
|
||||
expect(cause).toHaveTextContent("Ursache wählen");
|
||||
@@ -130,4 +130,32 @@ describe("WinterCheckDialog", () => {
|
||||
const [input] = vi.mocked(createWinterCheck).mock.calls[0];
|
||||
expect(input).toHaveProperty("queenPresent", null);
|
||||
});
|
||||
it("winter seams use NumberStepper", async () => {
|
||||
renderDialog({ existing });
|
||||
|
||||
const seams = screen.getByLabelText("Besetzte Wabengassen");
|
||||
expect(seams).toHaveValue("7");
|
||||
expect(seams).toHaveAttribute("inputmode", "numeric");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Eine besetzte Wabengasse mehr/ }));
|
||||
expect(seams).toHaveValue("8");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
const [, input] = vi.mocked(updateWinterCheck).mock.calls[0];
|
||||
expect(input).toHaveProperty("occupiedSeams", 8);
|
||||
});
|
||||
|
||||
it("asks for the outcome with one card per answer, each with its icon", async () => {
|
||||
renderDialog({ season: "SPRING" });
|
||||
|
||||
const group = screen.getByRole("radiogroup", { name: "Wie ist das Volk durch den Winter gekommen?" });
|
||||
const radios = within(group).getAllByRole("radio");
|
||||
expect(radios.map((radio) => radio.getAttribute("aria-checked"))).toEqual(["false", "false", "false"]);
|
||||
for (const radio of radios) {
|
||||
expect(radio).toHaveClass("min-h-14");
|
||||
expect(radio.querySelector("svg")).not.toBeNull();
|
||||
}
|
||||
await userEvent.click(within(group).getByRole("radio", { name: "Schwach" }));
|
||||
expect(within(group).getByRole("radio", { name: "Schwach" })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { FormEvent, useId, useState } from "react";
|
||||
import { CircleX, Sprout, TriangleAlert } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
@@ -17,14 +17,15 @@ import {
|
||||
} from "@/lib/winter-checks/types";
|
||||
import { winterLabel } from "@/lib/winter-checks/winter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TriStateField } from "@/components/form/tri-state-field";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
|
||||
@@ -42,7 +43,30 @@ function parseDecimal(value: string): number {
|
||||
return Number(value.replace(",", "."));
|
||||
}
|
||||
|
||||
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"];
|
||||
/** Highest count the server takes (create-winter-check.dto.ts). */
|
||||
const MAX_SEAMS = 60;
|
||||
|
||||
// Icon plus word, so the answer never hangs on colour (spec 10.2).
|
||||
const OUTCOME_OPTIONS: readonly ChoiceOption<WinterOutcome | null>[] = [
|
||||
{
|
||||
value: "SURVIVED",
|
||||
label: WINTER_OUTCOME_LABELS.SURVIVED,
|
||||
description: "Das Volk lebt und ist gut durchgekommen.",
|
||||
icon: Sprout,
|
||||
},
|
||||
{
|
||||
value: "WEAK",
|
||||
label: WINTER_OUTCOME_LABELS.WEAK,
|
||||
description: "Das Volk lebt, ist aber klein geworden.",
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
value: "LOST",
|
||||
label: WINTER_OUTCOME_LABELS.LOST,
|
||||
description: "Das Volk hat den Winter nicht überlebt.",
|
||||
icon: CircleX,
|
||||
},
|
||||
];
|
||||
|
||||
export function WinterCheckDialog({
|
||||
target,
|
||||
@@ -67,6 +91,7 @@ export function WinterCheckDialog({
|
||||
const [markDeceased, setMarkDeceased] = useState(target.isLiving);
|
||||
const [notes, setNotes] = useState(existing?.notes ?? "");
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const outcomeLabelId = useId();
|
||||
|
||||
const isLost = outcome === "LOST";
|
||||
const missing = isSpring && (!outcome || (isLost && !lossCause));
|
||||
@@ -79,7 +104,7 @@ export function WinterCheckDialog({
|
||||
winterYear: target.winterYear,
|
||||
date: toApiDate(date),
|
||||
// Empty means null, not omitted: on an edit it clears an earlier entry.
|
||||
occupiedSeams: emptyToNull(occupiedSeams, parseDecimal),
|
||||
occupiedSeams: emptyToNull(occupiedSeams, Number),
|
||||
// Spring checks have no feed stock field - leave whatever is stored.
|
||||
feedStockKg: isSpring ? undefined : emptyToNull(feedStockKg, parseDecimal),
|
||||
queenPresent,
|
||||
@@ -123,24 +148,18 @@ export function WinterCheckDialog({
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
{isSpring ? (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{OUTCOMES.map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
|
||||
aria-pressed={outcome === value}
|
||||
onClick={() => setOutcome(value)}
|
||||
>
|
||||
{outcome === value ? <Check aria-hidden /> : null}
|
||||
{WINTER_OUTCOME_LABELS[value]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p id={outcomeLabelId} className="text-base font-medium">
|
||||
Wie ist das Volk durch den Winter gekommen?
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={OUTCOME_OPTIONS}
|
||||
value={outcome}
|
||||
onChange={setOutcome}
|
||||
labelledBy={outcomeLabelId}
|
||||
appearance="cards"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLost ? (
|
||||
@@ -159,14 +178,12 @@ export function WinterCheckDialog({
|
||||
</p>
|
||||
</div>
|
||||
{target.isLiving ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="winter-mark-deceased"
|
||||
checked={markDeceased}
|
||||
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="winter-mark-deceased"
|
||||
label="Volk als abgestorben kennzeichnen"
|
||||
checked={markDeceased}
|
||||
onCheckedChange={setMarkDeceased}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -184,16 +201,18 @@ export function WinterCheckDialog({
|
||||
</div>
|
||||
|
||||
{!isLost ? (
|
||||
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
|
||||
<Input
|
||||
<NumberStepper
|
||||
id="winter-seams"
|
||||
inputMode="numeric"
|
||||
className="h-11"
|
||||
aria-describedby="winter-seams-hint"
|
||||
describedBy="winter-seams-hint"
|
||||
value={occupiedSeams}
|
||||
onChange={(event) => setOccupiedSeams(event.target.value)}
|
||||
onChange={setOccupiedSeams}
|
||||
max={MAX_SEAMS}
|
||||
unitLabel="Gassen"
|
||||
decrementLabel="Eine besetzte Wabengasse weniger"
|
||||
incrementLabel="Eine besetzte Wabengasse mehr"
|
||||
/>
|
||||
<p id="winter-seams-hint" className="text-xs text-muted-foreground">
|
||||
Zwischenräume zwischen den Waben, in denen Bienen sitzen. Mehr Gassen = stärkeres
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
@custom-variant pointer-fine (@media (pointer: fine));
|
||||
@custom-variant pointer-coarse (@media (pointer: coarse));
|
||||
|
||||
/* Type scale (spec 4.5): text-xs is 13 px (sun: 14 px), only for axes, legends and footers. */
|
||||
@theme {
|
||||
--text-xs: 0.8125rem;
|
||||
--text-xs--line-height: calc(1.125 / 0.8125);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -87,7 +93,7 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--animate-page-in: page-in 260ms ease-out;
|
||||
--animate-page-in: page-in var(--motion-enter) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
@@ -332,6 +338,14 @@
|
||||
.holo-stage {
|
||||
--ring: var(--holo-edge-strong);
|
||||
}
|
||||
/* A page-coloured card on the stage (the outline panel) takes the page ring back. The root
|
||||
keeps a copy of its ring for that, resolved for the current set (light, dark, sun). */
|
||||
html {
|
||||
--page-ring: var(--ring);
|
||||
}
|
||||
.holo-stage .stage-card {
|
||||
--ring: var(--page-ring);
|
||||
}
|
||||
|
||||
/* Tinted surfaces: set --tint (and --tint-ink) on the element. In sun the tint becomes the
|
||||
plain card with a 2 px border in the ink instead of a 15 % wash (spec 4.3). */
|
||||
@@ -430,7 +444,7 @@
|
||||
--queen-green: oklch(0.58 0.15 145);
|
||||
--queen-blue: oklch(0.52 0.15 255);
|
||||
--text-sm: 0.875rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs: 0.8125rem;
|
||||
--focus-width: 2px;
|
||||
--control-border-width: 1px;
|
||||
--comb-stroke-1: 2px;
|
||||
@@ -480,7 +494,21 @@
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border;
|
||||
}
|
||||
/* One focus style for everything (spec 4.6): a solid ring in --ring with a gap, 3 px in sun.
|
||||
Components add no focus classes; a row whose focus target sits inside uses
|
||||
focus-ring-within. */
|
||||
:where(:focus-visible) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
/* Firefox: the preflight sets `:-moz-focusring:where(:not(iframe)) { outline: auto }` (0,1,0),
|
||||
which beats the rule above. Firefox matches :-moz-focusring exactly where it matches
|
||||
:focus-visible (keyboard, not a mouse click), so restating the ring here adds nothing else. */
|
||||
:-moz-focusring:where(:not(iframe)) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@@ -513,6 +541,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* A row whose focus target sits inside (a label with its checkbox, radio or a visually hidden
|
||||
input) also rings the whole row, so the focus is easy to spot from arm's length (spec 4.6). */
|
||||
@utility focus-ring-within {
|
||||
&:has(:focus-visible) {
|
||||
outline: var(--focus-width) solid var(--ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Motion levels (see lib/motion/motion-preference.ts)
|
||||
html[data-motion] = full | subtle | off, chosen per device in the profile.
|
||||
@@ -527,8 +564,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion tokens (spec 17.3). Mirrored in lib/motion/tokens.ts; tokens.spec.ts keeps both equal.
|
||||
No DOM transition is longer than --motion-moment; exits are never longer than --motion-fade. */
|
||||
:root {
|
||||
--motion-fade: 150ms;
|
||||
--motion-enter: 200ms;
|
||||
--motion-settle: 350ms;
|
||||
--motion-moment: 420ms;
|
||||
--motion-shift: 8px;
|
||||
--motion-spring: 500ms;
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
/* The hive's flight spring (stiffness 210, damping 21) over --motion-spring: springEasing(). */
|
||||
--ease-spring: linear(0, 0.039, 0.113, 0.209, 0.314, 0.421, 0.524, 0.618, 0.702, 0.775, 0.837, 0.887, 0.928, 0.959, 0.983, 1, 1.012, 1.02, 1.024, 1.026, 1.026, 1.024, 1.022, 1.02, 1.017, 1.014, 1.012, 1.009, 1.007, 1.005, 1.004, 1);
|
||||
}
|
||||
|
||||
/* Off: every state change is instant. Colour and icon still carry the state. */
|
||||
@@ -541,17 +589,47 @@
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
|
||||
/* Subtle / system reduce: keep fades, drop movement. */
|
||||
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
|
||||
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
|
||||
:root[data-motion="subtle"] .animate-page-in {
|
||||
animation-name: fade-in;
|
||||
animation-duration: var(--motion-fade);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-safe\:animate-page-in {
|
||||
animation: fade-in 160ms ease-out;
|
||||
.animate-page-in {
|
||||
animation-name: fade-in;
|
||||
animation-duration: var(--motion-fade);
|
||||
}
|
||||
}
|
||||
:root[data-motion="subtle"] [data-slot="card"] {
|
||||
translate: none !important;
|
||||
|
||||
/* Overlays at "Dezent" and with the system's reduce motion: they only fade, and in --motion-fade
|
||||
both ways (spec 17.2); the move behind motion-full: is off anyway. */
|
||||
:root[data-motion="subtle"] [data-slot="sheet-content"],
|
||||
:root[data-motion="subtle"] [data-slot="sheet-overlay"],
|
||||
:root[data-motion="subtle"] [data-slot="dialog-content"],
|
||||
:root[data-motion="subtle"] [data-slot="dialog-overlay"],
|
||||
:root[data-motion="subtle"] [data-slot="dropdown-menu-content"],
|
||||
:root[data-motion="subtle"] [data-slot="dropdown-menu-sub-content"],
|
||||
:root[data-motion="subtle"] [data-slot="select-content"] {
|
||||
transition-duration: var(--motion-fade);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="sheet-content"],
|
||||
[data-slot="sheet-overlay"],
|
||||
[data-slot="dialog-content"],
|
||||
[data-slot="dialog-overlay"],
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"] {
|
||||
transition-duration: var(--motion-fade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toasts (spec 17.4): Sonner's own slide is full motion. At "Dezent" a toast stands at its place
|
||||
at once and only fades; Sonner itself drops every transition under system reduce. */
|
||||
:root[data-motion="subtle"] [data-sonner-toast],
|
||||
:root[data-motion="subtle"] [data-sonner-toast] > * {
|
||||
transition-property: opacity !important;
|
||||
transition-duration: var(--motion-fade) !important;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@@ -564,7 +642,8 @@
|
||||
}
|
||||
|
||||
/* --- Task completion: the one authored moment ------------------------------
|
||||
The honey cell fills, the tick draws itself, then the row folds away. */
|
||||
The honey cell fills (--motion-moment), the tick draws itself, then the row leaves the list
|
||||
(TodayPanel: AnimatePresence, the others move up with the flight spring). */
|
||||
.honey-check {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
@@ -574,7 +653,6 @@
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
color: var(--primary-foreground);
|
||||
outline: none;
|
||||
}
|
||||
.honey-check::before {
|
||||
content: "";
|
||||
@@ -584,8 +662,8 @@
|
||||
border: 2px solid var(--input);
|
||||
background: var(--card);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background-color 150ms ease-out;
|
||||
border-color var(--motion-fade) ease-out,
|
||||
background-color var(--motion-fade) ease-out;
|
||||
}
|
||||
.honey-check::after {
|
||||
content: "";
|
||||
@@ -594,15 +672,12 @@
|
||||
border-radius: 0.6rem;
|
||||
background: var(--primary);
|
||||
clip-path: circle(0% at 50% 60%);
|
||||
transition: clip-path 420ms var(--ease-out-expo);
|
||||
transition: clip-path var(--motion-moment) var(--ease-out-expo);
|
||||
}
|
||||
.honey-check:hover::before {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in oklch, var(--primary) 8%, var(--card));
|
||||
}
|
||||
.honey-check:focus-visible::before {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 45%, transparent);
|
||||
}
|
||||
.honey-check svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -610,7 +685,8 @@
|
||||
height: 1.1rem;
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
|
||||
/* Starts as the cell is half full and ends with it: inside the moment. */
|
||||
transition: stroke-dashoffset calc(var(--motion-moment) - 160ms) var(--ease-out-expo) 160ms;
|
||||
}
|
||||
.honey-check[aria-checked="true"]::after {
|
||||
clip-path: circle(75% at 50% 60%);
|
||||
@@ -619,60 +695,34 @@
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.today-row {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
transition:
|
||||
grid-template-rows 320ms var(--ease-out-expo) 380ms,
|
||||
opacity 220ms ease-out 360ms;
|
||||
}
|
||||
.today-row > * {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.today-row[data-leaving="true"] {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The next step changes: bring the new one in with a short focus pull. */
|
||||
/* The next step changes: the new one comes in by --motion-shift (no blur: filter never moves). */
|
||||
@keyframes next-step-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
transform: translateY(8px);
|
||||
transform: translateY(var(--motion-shift));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes next-step-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.next-step-enter {
|
||||
animation: next-step-in 480ms var(--ease-out-expo);
|
||||
animation: next-step-in var(--motion-enter) var(--ease-out-expo);
|
||||
}
|
||||
:root[data-motion="subtle"] .next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
animation: fade-in var(--motion-fade) ease-out;
|
||||
}
|
||||
:root[data-motion="subtle"] .today-row,
|
||||
:root[data-motion="subtle"] .honey-check::after {
|
||||
/* The honey-check moment (cell fills, tick draws itself) belongs to full motion; subtle keeps
|
||||
the tick but drops the slow fill and the stroke delay. */
|
||||
:root[data-motion="subtle"] .honey-check::after,
|
||||
:root[data-motion="subtle"] .honey-check svg {
|
||||
transition-duration: 120ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
animation: fade-in var(--motion-fade) ease-out;
|
||||
}
|
||||
.today-row,
|
||||
.honey-check::after,
|
||||
.honey-check svg {
|
||||
transition-duration: 120ms;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Icon, IconDescriptor } from "next/dist/lib/metadata/types/metadata-types";
|
||||
import { THEME_COLORS } from "@/lib/appearance/appearance-core";
|
||||
import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
|
||||
import { metadata, viewport } from "./layout";
|
||||
|
||||
// next/font only works inside the Next.js compiler.
|
||||
vi.mock("next/font/local", () => ({ default: () => ({ variable: "font" }) }));
|
||||
|
||||
// The <head> of every page: icons (spec 12.2) and the browser chrome colour (task A2/A4/A9).
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
|
||||
|
||||
function descriptors(icons: Icon | Icon[] | undefined): IconDescriptor[] {
|
||||
return [icons ?? []].flat().map((icon) => (typeof icon === "object" && "url" in icon ? icon : { url: String(icon) }));
|
||||
}
|
||||
|
||||
describe("root layout head", () => {
|
||||
it("links the SVG icon next to favicon.ico and keeps the apple-touch-icon", () => {
|
||||
const icons = metadata.icons as { icon?: Icon[]; apple?: Icon[] };
|
||||
|
||||
expect(descriptors(icons.icon)).toContainEqual(expect.objectContaining({ url: "/icon.svg", type: "image/svg+xml" }));
|
||||
expect(existsSync(path.join(ROOT, "public/icon.svg"))).toBe(true);
|
||||
// As soon as metadata.icons is set, Next.js drops the file-based apple-icon from
|
||||
// the head (favicon.ico stays): it has to be listed here.
|
||||
expect(descriptors(icons.apple)).toContainEqual(
|
||||
expect.objectContaining({ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }),
|
||||
);
|
||||
expect(existsSync(path.join(ROOT, "src/app/apple-icon.png"))).toBe(true);
|
||||
expect(existsSync(path.join(ROOT, "src/app/favicon.ico"))).toBe(true);
|
||||
});
|
||||
|
||||
it("THEME_COLORS are the --background of the four colour sets in globals.css", () => {
|
||||
const background = (set: Parameters<typeof tokenSet>[1]) => hexOf(oklchOf(tokenSet(css, set).background));
|
||||
|
||||
expect(THEME_COLORS).toEqual({
|
||||
light: background("light"),
|
||||
dark: background("dark"),
|
||||
sunLight: background("sun-light"),
|
||||
sunDark: background("sun-dark"),
|
||||
});
|
||||
expect(THEME_COLORS.dark).toBe("#1a120d");
|
||||
});
|
||||
|
||||
it("before the inline script runs, the dark device gets the dark page colour", () => {
|
||||
expect(viewport.themeColor).toEqual([
|
||||
{ media: "(prefers-color-scheme: light)", color: THEME_COLORS.light },
|
||||
{ media: "(prefers-color-scheme: dark)", color: THEME_COLORS.dark },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,13 @@ const plexMono = localFont({
|
||||
export const metadata: Metadata = {
|
||||
title: "ApiCore – Imkereiverwaltung",
|
||||
description: "Digitale Imkereiverwaltung für Wabenhain",
|
||||
// favicon.ico (src/app) stays first by file convention. The SVG lives in
|
||||
// public/ for the manifest and the service worker. Setting icons here drops
|
||||
// the file-based apple-icon from the head, so it is listed again.
|
||||
icons: {
|
||||
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
|
||||
apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user