chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix

UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 18:35:31 +02:00
parent 907ee574e6
commit 1ee59b54bc
36 changed files with 954 additions and 140 deletions

View File

@@ -0,0 +1,374 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildUserSectionHtml > produces a stable per-user section (full output pinned) 1`] = `
"<div class="user-section">
<div class="user-header">
<h3>Jan Novák</h3>
<span class="total">Odpracováno: 8:00 h</span>
</div>
<table>
<thead><tr>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody><tr class="weekend">
<td>1. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>2. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>3. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>4. 3. 2098</td>
<td>Úterý</td>
<td>Práce</td>
<td class="text-center">08:00</td>
<td class="text-center">12:00 - 12:30</td>
<td class="text-center">16:30</td>
<td class="text-center">8:00 (8,00)</td>
<td style="font-size:8px"><div>Alpha (8:00h)</div></td>
<td>Poznámka &lt;b&gt;</td>
</tr><tr class="vacation">
<td>5. 3. 2098</td>
<td>Středa</td>
<td>Dovolená</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">8,00</td>
<td style="font-size:8px">—</td>
<td></td>
</tr><tr class="">
<td>6. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>7. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>8. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>9. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>10. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>11. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>12. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>13. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>14. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>15. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>16. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>17. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>18. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>19. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>20. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>21. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>22. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>23. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>24. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>25. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>26. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>27. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>28. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>29. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>30. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>31. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr></tbody>
</table>
<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">16,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">168,00h (21 dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>
</div>"
`;

View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
buildProjectLogsHtml,
buildUserSectionHtml,
buildPrintHtml,
} from "../admin/hooks/useAttendanceAdmin";
/**
* Characterization test for the attendance print-HTML builders.
*
* These builders had `any`-typed params; this pins their CURRENT output so the
* follow-up retyping (precise AttendanceRecord/UserTotal/PrintData types) is
* provably behavior-preserving. The only runtime touch in that retype is
* `parseInt(log.hours)` → `parseInt(String(log.hours))` to satisfy the typed
* `string | number | null` shape — the cases below exercise both string and
* numeric hours/minutes precisely so that equivalence is locked in.
*
* Fixtures are cast through `Parameters<typeof fn>` so the test compiles both
* before and after the param types tighten.
*/
type RecordArg = Parameters<typeof buildProjectLogsHtml>[0];
type UserDataArg = Parameters<typeof buildUserSectionHtml>[1];
type PrintDataArg = Parameters<typeof buildUserSectionHtml>[2];
const asRecord = (o: unknown) => o as unknown as RecordArg;
const asUserData = (o: unknown) => o as unknown as UserDataArg;
const asPrintData = (o: unknown) => o as unknown as PrintDataArg;
describe("buildProjectLogsHtml", () => {
it("formats hours/minutes given as strings", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{ project_id: 1, project_name: "Alpha", hours: "2", minutes: "30" },
],
}),
),
).toBe("<div>Alpha (2:30h)</div>");
});
it("formats hours/minutes given as numbers (parseInt-of-number path)", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{ project_id: 2, project_name: "Beta", hours: 1, minutes: 5 },
],
}),
),
).toBe("<div>Beta (1:05h)</div>");
});
it("derives duration from started_at/ended_at when hours are absent", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{
project_id: 3,
project_name: "Gamma",
started_at: "2098-01-05T08:00:00",
ended_at: "2098-01-05T10:30:00",
},
],
}),
),
).toBe("<div>Gamma (2:30h)</div>");
});
it("falls back to '#id' and 0:00 when name and times are missing", () => {
expect(
buildProjectLogsHtml(asRecord({ project_logs: [{ project_id: 7 }] })),
).toBe("<div>#7 (0:00h)</div>");
});
it("renders the bare project name when there are no logs", () => {
expect(
buildProjectLogsHtml(
asRecord({ project_name: "Solo & <Co>", project_logs: [] }),
),
).toBe("Solo &amp; &lt;Co&gt;");
});
});
// Deterministic fixture: a fixed far-future month, one work day (with a numeric
// project log so the snapshot also guards the parseInt path in context) and one
// vacation day.
const workRecord = {
id: 1,
user_id: 5,
shift_date: "2098-03-04",
leave_type: "work",
arrival_time: "2098-03-04T08:00:00",
departure_time: "2098-03-04T16:30:00",
break_start: "2098-03-04T12:00:00",
break_end: "2098-03-04T12:30:00",
notes: "Poznámka <b>",
project_logs: [
{ project_id: 1, project_name: "Alpha", hours: 8, minutes: 0 },
],
};
const leaveRecord = {
id: 2,
user_id: 5,
shift_date: "2098-03-05",
leave_type: "vacation",
leave_hours: 8,
};
const userData = {
name: "Jan Novák",
minutes: 480,
records: [workRecord, leaveRecord],
};
const printData = {
month: "2098-03",
month_name: "Březen 2098",
selected_user_name: null,
user_totals: { "5": userData },
};
describe("buildUserSectionHtml", () => {
it("produces a stable per-user section (full output pinned)", () => {
const html = buildUserSectionHtml(
"5",
asUserData(userData),
asPrintData(printData),
);
expect(html).toMatchSnapshot();
});
});
describe("buildPrintHtml", () => {
// NB: the document footer embeds `new Date()`, so assert stable fragments
// rather than snapshotting the whole (non-deterministic) output.
it("wraps the user sections in a stable document shell", () => {
const section = buildUserSectionHtml(
"5",
asUserData(userData),
asPrintData(printData),
);
const html = buildPrintHtml(
asPrintData(printData),
section,
"",
"",
"Firma s.r.o.",
);
expect(html).toContain("<title>Docházka - Březen 2098</title>");
expect(html).toContain("EVIDENCE DOCHÁZKY");
expect(html).toContain('<div class="period">Březen 2098</div>');
expect(html).toContain("Firma s.r.o.");
expect(html).toContain(section);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Guards against the "two Zavřít buttons" bug class.
*
* The shared <Modal> (src/admin/ui/Modal.tsx) ALWAYS renders a primary submit
* button (text = `submitText`) and — unless `hideCancel` is set — a secondary
* cancel button (text = `cancelText`, default "Zrušit"). A close-only modal
* therefore sets `submitText="Zavřít"`; if it ALSO leaves the cancel button on,
* the user sees two buttons that both just close the dialog — and when
* `cancelText` is "Zavřít" too, two literally-identical "Zavřít" buttons
* (the production bug found on the "paid received invoice" detail modal).
*
* Rule enforced: any <Modal> whose `submitText` is a "Zavřít…" label (i.e. its
* only action is to close) MUST pass `hideCancel`. Close-only modals get a
* single button. See PlanCellModal / PlanCategoriesModal for the correct shape.
*/
const ADMIN_DIR = join(__dirname, "..", "admin");
function tsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...tsxFiles(full));
else if (entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
/**
* Extract every `<Modal …>` opening-tag substring from a source string.
* Brace- and string-aware so arrow functions (`=>`) and conditional props
* (`{cond ? "a" : "b"}`) inside the tag don't terminate it early.
*/
function modalOpeningTags(src: string): string[] {
const tags: string[] = [];
let i = 0;
while ((i = src.indexOf("<Modal", i)) !== -1) {
const after = src[i + 6];
// Must be the <Modal> component, not <ModalSomething>.
if (after && !/[\s>/]/.test(after)) {
i += 6;
continue;
}
let depth = 0;
let quote: string | null = null;
let j = i + 6;
for (; j < src.length; j++) {
const c = src[j];
if (quote) {
if (c === quote && src[j - 1] !== "\\") quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(src.slice(i, j + 1));
i = j + 1;
}
return tags;
}
/** Value of a JSX prop: `name="..."`, `name={...}` (balanced), or boolean. */
function propValue(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\b`);
const m = re.exec(tag);
if (!m) return null;
let k = m.index + m[0].length;
while (k < tag.length && /\s/.test(tag[k])) k++;
if (tag[k] !== "=") return ""; // boolean prop (e.g. `hideCancel`)
k++;
while (k < tag.length && /\s/.test(tag[k])) k++;
if (tag[k] === '"' || tag[k] === "'") {
const q = tag[k];
const end = tag.indexOf(q, k + 1);
return tag.slice(k + 1, end);
}
if (tag[k] === "{") {
let depth = 0;
for (let p = k; p < tag.length; p++) {
if (tag[p] === "{") depth++;
else if (tag[p] === "}" && --depth === 0) return tag.slice(k, p + 1);
}
}
return null;
}
describe("Modal close-only buttons", () => {
const offenders: string[] = [];
for (const file of tsxFiles(ADMIN_DIR)) {
const src = readFileSync(file, "utf8");
for (const tag of modalOpeningTags(src)) {
const submit = propValue(tag, "submitText");
const isCloseOnly = submit != null && submit.includes("Zavřít");
const hasHideCancel = propValue(tag, "hideCancel") != null;
if (isCloseOnly && !hasHideCancel) {
const title = propValue(tag, "title") ?? "(no title)";
offenders.push(`${file.replace(ADMIN_DIR, "admin")}${title}`);
}
}
}
it("close-only modals (submitText='Zavřít') set hideCancel — no duplicate close button", () => {
expect(
offenders,
`These <Modal>s render a close-only 'Zavřít' submit alongside a redundant ` +
`cancel button (two buttons that both just close). Add hideCancel:\n` +
offenders.map((o) => `${o}`).join("\n"),
).toEqual([]);
});
});