diff --git a/package-lock.json b/package-lock.json
index 02c5649..4e184c8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "app-ts",
- "version": "2.4.34",
+ "version": "2.4.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
- "version": "2.4.34",
+ "version": "2.4.35",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
diff --git a/package.json b/package.json
index e8d9798..11f2213 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "app-ts",
- "version": "2.4.34",
+ "version": "2.4.35",
"description": "",
"main": "dist/server.js",
"scripts": {
diff --git a/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
new file mode 100644
index 0000000..ba8fdaf
--- /dev/null
+++ b/src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
@@ -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`] = `
+"
+
+
+
+ | Datum |
+ Den |
+ Typ |
+ Příchod |
+ Pauza |
+ Odchod |
+ Hodiny |
+ Projekty |
+ Poznámka |
+
+
+ | 1. 3. 2098 |
+ Sobota |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 2. 3. 2098 |
+ Neděle |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 3. 3. 2098 |
+ Pondělí |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 4. 3. 2098 |
+ Úterý |
+ Práce |
+ 08:00 |
+ 12:00 - 12:30 |
+ 16:30 |
+ 8:00 (8,00) |
+ Alpha (8:00h) |
+ Poznámka <b> |
+
+ | 5. 3. 2098 |
+ Středa |
+ Dovolená |
+ — |
+ — |
+ — |
+ 8,00 |
+ — |
+ |
+
+ | 6. 3. 2098 |
+ Čtvrtek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 7. 3. 2098 |
+ Pátek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 8. 3. 2098 |
+ Sobota |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 9. 3. 2098 |
+ Neděle |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 10. 3. 2098 |
+ Pondělí |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 11. 3. 2098 |
+ Úterý |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 12. 3. 2098 |
+ Středa |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 13. 3. 2098 |
+ Čtvrtek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 14. 3. 2098 |
+ Pátek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 15. 3. 2098 |
+ Sobota |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 16. 3. 2098 |
+ Neděle |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 17. 3. 2098 |
+ Pondělí |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 18. 3. 2098 |
+ Úterý |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 19. 3. 2098 |
+ Středa |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 20. 3. 2098 |
+ Čtvrtek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 21. 3. 2098 |
+ Pátek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 22. 3. 2098 |
+ Sobota |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 23. 3. 2098 |
+ Neděle |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 24. 3. 2098 |
+ Pondělí |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 25. 3. 2098 |
+ Úterý |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 26. 3. 2098 |
+ Středa |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 27. 3. 2098 |
+ Čtvrtek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 28. 3. 2098 |
+ Pátek |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 29. 3. 2098 |
+ Sobota |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 30. 3. 2098 |
+ Neděle |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+ | 31. 3. 2098 |
+ Pondělí |
+ — |
+ — |
+ — |
+ — |
+ — |
+ |
+ |
+
+
+
+
+ Odpracováno:
+ 8,00h
+
+
+ Odpracováno včetně svátků a přesčasů:
+ 16,00h
+
+
+ Dovolená:
+ 8,00h
+
+
+ Přesčas:
+ 8,00h
+
+
+ Svátek:
+ 0,00h
+
+
+ So/Ne:
+ 0,00h
+
+
+ Práce v noci:
+ 0,00h
+
+
+ Fond měsíce:
+ 168,00h (21 dnů)
+
+
+ Víkend (So/Ne)
+ Svátek
+ Víkend + svátek
+ Dovolená
+
+
+
"
+`;
diff --git a/src/__tests__/attendance-print-html.test.ts b/src/__tests__/attendance-print-html.test.ts
new file mode 100644
index 0000000..ae2f6ac
--- /dev/null
+++ b/src/__tests__/attendance-print-html.test.ts
@@ -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` so the test compiles both
+ * before and after the param types tighten.
+ */
+
+type RecordArg = Parameters[0];
+type UserDataArg = Parameters[1];
+type PrintDataArg = Parameters[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("Alpha (2:30h)
");
+ });
+
+ 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("Beta (1:05h)
");
+ });
+
+ 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("Gamma (2:30h)
");
+ });
+
+ it("falls back to '#id' and 0:00 when name and times are missing", () => {
+ expect(
+ buildProjectLogsHtml(asRecord({ project_logs: [{ project_id: 7 }] })),
+ ).toBe("#7 (0:00h)
");
+ });
+
+ it("renders the bare project name when there are no logs", () => {
+ expect(
+ buildProjectLogsHtml(
+ asRecord({ project_name: "Solo & ", project_logs: [] }),
+ ),
+ ).toBe("Solo & <Co>");
+ });
+});
+
+// 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 ",
+ 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("Docházka - Březen 2098");
+ expect(html).toContain("EVIDENCE DOCHÁZKY");
+ expect(html).toContain('Březen 2098
');
+ expect(html).toContain("Firma s.r.o.");
+ expect(html).toContain(section);
+ });
+});
diff --git a/src/__tests__/modal-duplicate-close.test.ts b/src/__tests__/modal-duplicate-close.test.ts
new file mode 100644
index 0000000..169859e
--- /dev/null
+++ b/src/__tests__/modal-duplicate-close.test.ts
@@ -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 (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 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 `` 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(" component, not .
+ 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 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([]);
+ });
+});
diff --git a/src/admin/components/PlanCellModal.tsx b/src/admin/components/PlanCellModal.tsx
index 75d73d0..75d3c25 100644
--- a/src/admin/components/PlanCellModal.tsx
+++ b/src/admin/components/PlanCellModal.tsx
@@ -488,6 +488,7 @@ function DayInRangeModal(
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
+ hideCancel
maxWidth="md"
>
diff --git a/src/admin/components/PlanGrid.tsx b/src/admin/components/PlanGrid.tsx
index 9130082..93af55b 100644
--- a/src/admin/components/PlanGrid.tsx
+++ b/src/admin/components/PlanGrid.tsx
@@ -491,9 +491,11 @@ export default function PlanGrid({
}, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the
// visible range changes). Stable identity also keeps the row map cheap.
+ const dateFrom = data?.date_from;
+ const dateTo = data?.date_to;
const days = useMemo(
- () => (data ? eachDay(data.date_from, data.date_to) : []),
- [data?.date_from, data?.date_to],
+ () => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
+ [dateFrom, dateTo],
);
if (!data) return ;
diff --git a/src/admin/components/ShiftFormModal.tsx b/src/admin/components/ShiftFormModal.tsx
index 47d4da4..76e158e 100644
--- a/src/admin/components/ShiftFormModal.tsx
+++ b/src/admin/components/ShiftFormModal.tsx
@@ -19,46 +19,22 @@ import {
} from "../utils/attendanceHelpers";
// ---------- Shared types ----------
-
-export interface ShiftFormData {
- user_id: string;
- shift_date: string;
- leave_type: string;
- leave_hours: number;
- arrival_date: string;
- arrival_time: string;
- break_start_date: string;
- break_start_time: string;
- break_end_date: string;
- break_end_time: string;
- departure_date: string;
- departure_time: string;
- notes: string;
-}
-
-export interface ProjectLog {
- _key?: string;
- id?: number;
- project_id: string | number;
- hours: string | number;
- minutes: string | number;
-}
-
-export interface Project {
- id: number | string;
- project_number: string;
- name: string;
-}
-
-export interface User {
- id: number | string;
- name: string;
-}
-
-export interface EditingRecord {
- user_name: string;
- shift_date: string;
-}
+// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
+// here so existing importers of ShiftFormModal keep working.
+export type {
+ ShiftFormData,
+ ProjectLog,
+ Project,
+ User,
+ EditingRecord,
+} from "./shiftFormTypes";
+import type {
+ ShiftFormData,
+ ProjectLog,
+ Project,
+ User,
+ EditingRecord,
+} from "./shiftFormTypes";
// ---------- Sub-component props ----------
diff --git a/src/admin/components/shiftFormTypes.ts b/src/admin/components/shiftFormTypes.ts
new file mode 100644
index 0000000..0ae681d
--- /dev/null
+++ b/src/admin/components/shiftFormTypes.ts
@@ -0,0 +1,44 @@
+// Shared attendance shift-form types, kept in a dependency-free module so they
+// can be imported without pulling in the ShiftFormModal component graph (which
+// reaches MUI/import.meta files that don't compile under the CommonJS test
+// project). ShiftFormModal re-exports these for backward compatibility.
+
+export interface ShiftFormData {
+ user_id: string;
+ shift_date: string;
+ leave_type: string;
+ leave_hours: number;
+ arrival_date: string;
+ arrival_time: string;
+ break_start_date: string;
+ break_start_time: string;
+ break_end_date: string;
+ break_end_time: string;
+ departure_date: string;
+ departure_time: string;
+ notes: string;
+}
+
+export interface ProjectLog {
+ _key?: string;
+ id?: number;
+ project_id: string | number;
+ hours: string | number;
+ minutes: string | number;
+}
+
+export interface Project {
+ id: number | string;
+ project_number: string;
+ name: string;
+}
+
+export interface User {
+ id: number | string;
+ name: string;
+}
+
+export interface EditingRecord {
+ user_name: string;
+ shift_date: string;
+}
diff --git a/src/admin/components/warehouse/ReservationPicker.tsx b/src/admin/components/warehouse/ReservationPicker.tsx
index 3c6c268..49e8234 100644
--- a/src/admin/components/warehouse/ReservationPicker.tsx
+++ b/src/admin/components/warehouse/ReservationPicker.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "../../ui";
@@ -26,7 +26,7 @@ export default function ReservationPicker({
}),
);
- const reservations = result?.data ?? [];
+ const reservations = useMemo(() => result?.data ?? [], [result?.data]);
// When item/project change, the loaded reservation list changes. If the
// currently-selected reservation is no longer available, clear it and notify
diff --git a/src/admin/context/AlertContext.tsx b/src/admin/context/AlertContext.tsx
index 3f018f7..326dfc2 100644
--- a/src/admin/context/AlertContext.tsx
+++ b/src/admin/context/AlertContext.tsx
@@ -43,9 +43,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
const timeoutsRef = useRef>>(new Set());
useEffect(() => {
+ const timeouts = timeoutsRef.current;
return () => {
- timeoutsRef.current.forEach(clearTimeout);
- timeoutsRef.current.clear();
+ timeouts.forEach(clearTimeout);
+ timeouts.clear();
};
}, []);
diff --git a/src/admin/context/AuthContext.tsx b/src/admin/context/AuthContext.tsx
index 24be892..3fc4209 100644
--- a/src/admin/context/AuthContext.tsx
+++ b/src/admin/context/AuthContext.tsx
@@ -122,6 +122,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
},
+ // silentRefresh and setAccessTokenFn reference each other; listing
+ // silentRefresh here would recreate both callbacks on every render. The
+ // scheduled silentRefresh is safe to call as a stable closure because all
+ // of its state lives in refs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts
index 27a41b7..b97a360 100644
--- a/src/admin/hooks/useAttendanceAdmin.ts
+++ b/src/admin/hooks/useAttendanceAdmin.ts
@@ -29,7 +29,7 @@ import type {
ProjectLog,
Project,
User,
-} from "../components/ShiftFormModal";
+} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
@@ -344,14 +344,25 @@ function escapeHtml(str: string): string {
.replace(/'/g, "'");
}
-function buildProjectLogsHtml(record: Record): string {
+/** Shape of the print endpoint payload consumed by the HTML builders. */
+interface PrintData {
+ month: string;
+ month_name: string;
+ selected_user_name?: string | null;
+ user_totals: Record;
+}
+
+export function buildProjectLogsHtml(record: AttendanceRecord): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
- .map((log: any) => {
+ .map((log) => {
let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) {
- h = parseInt(log.hours) || 0;
- m = parseInt(log.minutes) || 0;
+ // hours/minutes arrive as string or number — String() before parseInt
+ // (parseInt already coerces to string internally, so this is a no-op
+ // at runtime, it just satisfies parseInt's string parameter type).
+ h = parseInt(String(log.hours)) || 0;
+ m = parseInt(String(log.minutes)) || 0;
} else if (log.started_at && log.ended_at) {
const mins = Math.max(
0,
@@ -374,13 +385,13 @@ function buildProjectLogsHtml(record: Record): string {
return escapeHtml(record.project_name || "—");
}
-function buildUserSectionHtml(
+export function buildUserSectionHtml(
userId: string,
- userData: Record,
- printData: Record,
+ userData: UserTotal,
+ printData: PrintData,
): string {
// Build a date-keyed lookup of the user's records.
- const recordsByDate = new Map>();
+ const recordsByDate = new Map();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
@@ -391,7 +402,7 @@ function buildUserSectionHtml(
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
- const userRecords = (userData.records || []) as Record[];
+ const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
@@ -588,8 +599,8 @@ function buildUserSectionHtml(
`;
}
-function buildPrintHtml(
- pData: Record,
+export function buildPrintHtml(
+ pData: PrintData,
userSections: string,
emptyMsg: string,
filterNote: string,
@@ -1330,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const pData = result.data;
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) =>
- buildUserSectionHtml(uid, uData as Record, pData),
+ buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =
diff --git a/src/admin/hooks/usePlanWork.ts b/src/admin/hooks/usePlanWork.ts
index 10c0307..a9be0f1 100644
--- a/src/admin/hooks/usePlanWork.ts
+++ b/src/admin/hooks/usePlanWork.ts
@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Snapshot the currently-cached grid for a given key. Returns
// `undefined` if there's nothing cached (we don't patch empty data).
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
- return qc.getQueryData(key as any);
+ return qc.getQueryData(key);
}
// Apply a per-date cell patch to the cached grid at `key`. Returns the
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
function rollbackCells(ctx: CellRollback | undefined) {
if (!ctx || !ctx.rolled) return;
- const prev = qc.getQueryData(ctx.key as any);
+ const prev = qc.getQueryData(ctx.key);
if (!prev) return;
const userPrev = prev.cells[ctx.userId] ?? {};
const userNext = { ...userPrev };
@@ -379,7 +379,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Find the user that owns this entry in the current grid by
// looking for any cell with entryId === id (we already know
// the id from vars; it doesn't change across the update).
- const grid = qc.getQueryData(currentGridKey as any);
+ const grid = qc.getQueryData(currentGridKey);
let ownerUserId: number | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
@@ -430,7 +430,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// For delete we need to know the entry's user_id and full range.
// Look it up from the current grid: find the user that has a cell
// with entryId === id, and read rangeFrom/rangeTo from that cell.
- const grid = qc.getQueryData(currentGridKey as any);
+ const grid = qc.getQueryData(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
let entryRange: { from: string; to: string } | null = null;
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
onMutate: (vars): CellRollback => {
// Find the user/date for this overrideId in the current grid, then
// patch that single cell with the new values.
- const grid = qc.getQueryData(currentGridKey as any);
+ const grid = qc.getQueryData(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
method: "DELETE",
}),
onMutate: (vars): CellRollback => {
- const grid = qc.getQueryData(currentGridKey as any);
+ const grid = qc.getQueryData(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
@@ -600,7 +600,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// PlanWork.tsx so they continue to compile unchanged; by the time a
// `mutateAsync` promise rejects, the mutation's own `onError` has already
// restored the patched cells, so there is nothing left to do here.
-
+
function rollbackMutation(_mutation: unknown, _userId: number) {
/* no-op — see onMutate/onError above */
}
diff --git a/src/admin/pages/AttendanceHistory.tsx b/src/admin/pages/AttendanceHistory.tsx
index 7dce0ad..853ea67 100644
--- a/src/admin/pages/AttendanceHistory.tsx
+++ b/src/admin/pages/AttendanceHistory.tsx
@@ -210,7 +210,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
- const records = data ?? [];
+ const records = useMemo(() => data ?? [], [data]);
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");
diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx
index 0c9a3b7..a689d2d 100644
--- a/src/admin/pages/InvoiceDetail.tsx
+++ b/src/admin/pages/InvoiceDetail.tsx
@@ -621,7 +621,10 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
- const bankAccounts = bankAccountsQuery.data ?? [];
+ const bankAccounts = useMemo(
+ () => bankAccountsQuery.data ?? [],
+ [bankAccountsQuery.data],
+ );
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
@@ -656,8 +659,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false);
useEffect(() => {
+ const blobTimeouts = blobTimeoutsRef.current;
return () => {
- blobTimeoutsRef.current.forEach(clearTimeout);
+ blobTimeouts.forEach(clearTimeout);
};
}, []);
diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx
index 19896a8..f433870 100644
--- a/src/admin/pages/OrderDetail.tsx
+++ b/src/admin/pages/OrderDetail.tsx
@@ -121,8 +121,9 @@ export default function OrderDetail() {
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
+ const blobTimeouts = blobTimeoutsRef.current;
return () => {
- blobTimeoutsRef.current.forEach(clearTimeout);
+ blobTimeouts.forEach(clearTimeout);
};
}, []);
diff --git a/src/admin/pages/PlanWork.tsx b/src/admin/pages/PlanWork.tsx
index faddcbb..128a6de 100644
--- a/src/admin/pages/PlanWork.tsx
+++ b/src/admin/pages/PlanWork.tsx
@@ -10,6 +10,10 @@ import {
getCell,
getCells,
type ModalMode as HookModalMode,
+ type PlanEntryBody,
+ type PlanEntryPatchBody,
+ type PlanOverrideBody,
+ type PlanOverridePatchBody,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
- const data: any = await bulkCreate.mutateAsync({
+ // Counts can arrive either at the envelope root or nested under .data.
+ type BulkSummary = {
+ created_days?: number;
+ skipped_days?: number;
+ users?: number;
+ };
+ const data = (await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
@@ -190,12 +200,12 @@ export default function PlanWork() {
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
- });
+ })) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
- const summary = data?.data ?? data;
+ const summary: BulkSummary = data?.data ?? data;
const skipped =
- summary?.skipped_days > 0
+ (summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
- async (body: any) => {
+ async (body: PlanEntryBody) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
);
const updateEntryFn = useCallback(
- async (id: number, body: any) => {
+ async (id: number, body: PlanEntryPatchBody) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
);
const saveOverride = useCallback(
- async (body: any) => {
+ async (body: PlanOverrideBody) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
);
const updateOverrideFn = useCallback(
- async (id: number, body: any) => {
+ async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;
diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx
index 6bff231..0a0de93 100644
--- a/src/admin/pages/ReceivedInvoices.tsx
+++ b/src/admin/pages/ReceivedInvoices.tsx
@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef(null);
useEffect(() => {
+ const blobTimeouts = blobTimeoutsRef.current;
return () => {
- blobTimeoutsRef.current.forEach(clearTimeout);
+ blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
? "Zavřít"
: "Uložit"
}
- cancelText={
- editInvoice && editInvoice._originalStatus === "paid"
- ? "Zavřít"
- : "Zrušit"
- }
+ // Read-only (paid) detail has only a close action — a single "Zavřít"
+ // button, not a redundant cancel button beside it.
+ hideCancel={editInvoice?._originalStatus === "paid"}
loading={saving}
>
{editInvoice &&
diff --git a/src/admin/pages/Settings.tsx b/src/admin/pages/Settings.tsx
index 3d42024..8b346d0 100644
--- a/src/admin/pages/Settings.tsx
+++ b/src/admin/pages/Settings.tsx
@@ -715,7 +715,25 @@ export default function Settings() {
);
}
- const si = systemInfo as Record;
+ type NasInfo = { configured?: boolean; path?: string | null };
+ type SystemInfo = {
+ app_version?: string;
+ node_version?: string;
+ platform?: string;
+ uptime?: string;
+ environment?: string;
+ timezone?: string;
+ memory?: {
+ rss?: string;
+ heap_used?: string;
+ heap_total?: string;
+ system_free?: string;
+ system_total?: string;
+ };
+ database?: { status?: string; migrations_applied?: number | string };
+ nas?: Record;
+ };
+ const si = systemInfo as SystemInfo;
const Row = ({
label,
children,
@@ -800,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers],
- ] as [string, Record][]
+ ] as [string, NasInfo | undefined][]
).map(([label, info]) => (
diff --git a/src/admin/pages/WarehouseIssueForm.tsx b/src/admin/pages/WarehouseIssueForm.tsx
index dc7633e..bf1c462 100644
--- a/src/admin/pages/WarehouseIssueForm.tsx
+++ b/src/admin/pages/WarehouseIssueForm.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useId } from "react";
+import { useState, useEffect, useRef, useId, useCallback } from "react";
import {
useParams,
useNavigate,
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
const baseId = useId();
const counterRef = useRef(0);
- const nextKey = () => `${baseId}-item-${counterRef.current++}`;
+ const nextKey = useCallback(
+ () => `${baseId}-item-${counterRef.current++}`,
+ [baseId],
+ );
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
}
formInitialized.current = true;
}
- }, [isEdit, issue]);
+ }, [isEdit, issue, nextKey]);
const saveMutation = useApiMutation<
ReturnType,
diff --git a/src/admin/pages/WarehouseReceiptForm.tsx b/src/admin/pages/WarehouseReceiptForm.tsx
index 38ccb9f..ae9db92 100644
--- a/src/admin/pages/WarehouseReceiptForm.tsx
+++ b/src/admin/pages/WarehouseReceiptForm.tsx
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
const baseId = useId();
const counterRef = useRef(0);
- const nextKey = () => `${baseId}-item-${counterRef.current++}`;
+ const nextKey = useCallback(
+ () => `${baseId}-item-${counterRef.current++}`,
+ [baseId],
+ );
const [form, setForm] = useState({
supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
}
formInitialized.current = true;
}
- }, [isEdit, receipt]);
+ }, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation<
ReturnType,
diff --git a/src/admin/theme.test.ts b/src/admin/theme.test.ts
index 4ea2e8c..ea0ad9d 100644
--- a/src/admin/theme.test.ts
+++ b/src/admin/theme.test.ts
@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { theme } from "./theme";
+/** Loose shape for poking into MUI styleOverrides objects in assertions. */
+type StyleObj = Record;
+
/** Minimal palette shape used by the colorSchemes assertions below. */
interface SchemesPalette {
primary: { main: string };
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
});
it("makes buttons pill-shaped and cards 16px by default", () => {
- const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
- expect(btn?.borderRadius).toBe(999);
- const card = theme.components?.MuiCard?.styleOverrides?.root as any;
- expect(card?.borderRadius).toBe(16);
+ const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
+ expect(btn.borderRadius).toBe(999);
+ const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
+ expect(card.borderRadius).toBe(16);
});
it("adds 200-300ms interaction motion (press + focus ring)", () => {
- const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
- expect(btn?.transition).toContain("transform");
- expect(btn?.["&:active"]?.transform).toBe("scale(0.97)");
+ const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
+ expect(btn.transition).toContain("transform");
+ const active = btn["&:active"] as StyleObj;
+ expect(active.transform).toBe("scale(0.97)");
const input = theme.components?.MuiOutlinedInput?.styleOverrides
- ?.root as any;
- expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba");
+ ?.root as StyleObj;
+ const focused = input["&.Mui-focused"] as StyleObj;
+ expect(focused.boxShadow).toContain("rgba");
});
});
diff --git a/src/routes/admin/company-settings.ts b/src/routes/admin/company-settings.ts
index 0bbb506..5becc5e 100644
--- a/src/routes/admin/company-settings.ts
+++ b/src/routes/admin/company-settings.ts
@@ -13,6 +13,8 @@ import { parseBody } from "../../schemas/common";
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
import { invalidateSettingsCache } from "../../services/system-settings";
import os from "os";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
import { config } from "../../config/env";
import { NasFileManager } from "../../services/nas-file-manager";
import {
@@ -21,6 +23,15 @@ import {
} from "../../services/nas-financials-manager";
import { nasOffersManager } from "../../services/nas-offers-manager";
+// Read once at load. A static `import` of the root package.json would pull a
+// file outside `src` into the program, expanding tsc's rootDir and shifting the
+// whole dist/ layout — so resolve it at runtime relative to the compiled file.
+const APP_VERSION = (
+ JSON.parse(
+ readFileSync(join(__dirname, "../../../package.json"), "utf8"),
+ ) as { version: string }
+).version;
+
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(
fields: unknown,
@@ -325,8 +336,6 @@ export default async function companySettingsRoutes(
settings.custom_fields as string | null,
);
- const pkg = require("../../../package.json") as { version: string };
-
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
try {
const raw = settings.available_vat_rates as string | null;
@@ -359,7 +368,7 @@ export default async function companySettingsRoutes(
available_currencies,
has_logo,
has_logo_dark,
- app_version: pkg.version,
+ app_version: APP_VERSION,
});
});
@@ -368,7 +377,6 @@ export default async function companySettingsRoutes(
"/system-info",
{ preHandler: requirePermission("settings.system") },
async (request, reply) => {
- const pkg = require("../../../package.json") as { version: string };
const uptimeSec = process.uptime();
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
@@ -401,7 +409,7 @@ export default async function companySettingsRoutes(
const projectNas = new NasFileManager();
return success(reply, {
- app_version: pkg.version,
+ app_version: APP_VERSION,
node_version: process.version,
platform: `${os.type()} ${os.release()}`,
uptime: uptimeStr,
diff --git a/src/routes/admin/plan.ts b/src/routes/admin/plan.ts
index 788e424..e94bfc7 100644
--- a/src/routes/admin/plan.ts
+++ b/src/routes/admin/plan.ts
@@ -194,7 +194,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanCategorySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -218,7 +218,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deletePlanCategory(id);
if ("error" in result) return error(reply, result.error, result.status);
@@ -289,7 +289,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -321,7 +321,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const force = forceFromQuery(request.query);
const result = await deleteEntry(id, request.authData!.userId, force);
@@ -373,7 +373,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanOverrideSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -405,7 +405,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const force = forceFromQuery(request.query);
const result = await deleteOverride(id, request.authData!.userId, force);
diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts
index 72e71f1..10c8792 100644
--- a/src/routes/admin/projects.ts
+++ b/src/routes/admin/projects.ts
@@ -89,7 +89,11 @@ export default async function projectsRoutes(
const result = await createProject(parsed.data);
if ("error" in result)
- return error(reply, result.error, (result as any).status ?? 400);
+ return error(
+ reply,
+ result.error,
+ (result as { status?: number }).status ?? 400,
+ );
await logAudit({
request,
@@ -127,7 +131,11 @@ export default async function projectsRoutes(
const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) {
- return error(reply, result.error, (result as any).status ?? 400);
+ return error(
+ reply,
+ result.error,
+ (result as { status?: number }).status ?? 400,
+ );
}
await logAudit({
@@ -160,7 +168,11 @@ export default async function projectsRoutes(
content: parsed.data.content ?? undefined,
});
if (note && "error" in note) {
- return error(reply, note.error, (note as any).status ?? 400);
+ return error(
+ reply,
+ note.error,
+ (note as { status?: number }).status ?? 400,
+ );
}
await logAudit({
diff --git a/src/routes/admin/sessions.ts b/src/routes/admin/sessions.ts
index 1b10efc..b659b11 100644
--- a/src/routes/admin/sessions.ts
+++ b/src/routes/admin/sessions.ts
@@ -82,7 +82,7 @@ export default async function sessionsRoutes(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId(request.params.id, reply);
if (id === null) return;
const authData = request.authData!;
diff --git a/src/routes/admin/trips.ts b/src/routes/admin/trips.ts
index 43857c8..fd4fbd6 100644
--- a/src/routes/admin/trips.ts
+++ b/src/routes/admin/trips.ts
@@ -293,7 +293,7 @@ export default async function tripsRoutes(
"/last-km/:vehicleId",
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
- const vehicleId = parseId((request.params as any).vehicleId, reply);
+ const vehicleId = parseId(request.params.vehicleId, reply);
if (vehicleId === null) return;
const [lastTrip, vehicle] = await Promise.all([
@@ -373,7 +373,7 @@ export default async function tripsRoutes(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -461,7 +461,7 @@ export default async function tripsRoutes(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
- const id = parseId((request.params as any).id, reply);
+ const id = parseId(request.params.id, reply);
if (id === null) return;
const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } });
diff --git a/src/server.ts b/src/server.ts
index 8698e5e..a177269 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,4 +1,5 @@
import Fastify from "fastify";
+import type { IncomingMessage, ServerResponse } from "http";
import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
@@ -167,13 +168,26 @@ async function start() {
// --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) {
+ // `vite` is a dev-only dependency loaded via a runtime import the bundler
+ // can't see (the Function() trick), so its types aren't resolvable under the
+ // server's moduleResolution — describe just the slice we use.
+ type ViteDevServerLike = {
+ middlewares: (
+ req: IncomingMessage,
+ res: ServerResponse,
+ next: (err?: Error) => void,
+ ) => void;
+ };
+ type ViteModuleLike = {
+ createServer: (opts: {
+ server: { middlewareMode: boolean };
+ appType: string;
+ }) => Promise;
+ };
const viteModule = await (Function(
'return import("vite")',
- )() as Promise);
- const createViteServer = viteModule.createServer as (
- opts: any,
- ) => Promise;
- const vite = await createViteServer({
+ )() as Promise);
+ const vite = await viteModule.createServer({
server: { middlewareMode: true },
appType: "spa",
});
diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts
index e63cde8..9fce597 100644
--- a/src/services/ai.service.ts
+++ b/src/services/ai.service.ts
@@ -523,6 +523,8 @@ export async function extractInvoice(
return JSON.parse(text) as ExtractedInvoice;
} catch (e) {
console.error("[ai.service] extractInvoice: model reply was not JSON", e);
- throw new Error("Model nevrátil platná data faktury (neplatný JSON)");
+ throw new Error("Model nevrátil platná data faktury (neplatný JSON)", {
+ cause: e,
+ });
}
}
diff --git a/src/services/exchange-rates.ts b/src/services/exchange-rates.ts
index 8d575f8..a2ef8ed 100644
--- a/src/services/exchange-rates.ts
+++ b/src/services/exchange-rates.ts
@@ -57,7 +57,9 @@ async function fetchRatesForDate(
// an entry that is past TTL — better to serve yesterday's rate than fail).
const stale = rateCache.get(key);
if (stale) return stale.rates;
- throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
+ throw new Error("Nepodařilo se získat aktuální kurzy z ČNB", {
+ cause: err,
+ });
} finally {
inflight.delete(key);
}
diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts
index b4b7ef9..b256b4f 100644
--- a/src/services/offers.service.ts
+++ b/src/services/offers.service.ts
@@ -115,12 +115,29 @@ function buildOfferWhere(params: OfferFilterParams): Record {
return where;
}
+// Structural shape for enrichQuotation — it's called with different selects
+// (full include vs. the minimal totals select), so a single Prisma payload type
+// doesn't fit. Only the fields actually read are pinned; the rest is spread.
+type EnrichableLineItem = {
+ is_included_in_total?: unknown;
+ quantity?: unknown;
+ unit_price?: unknown;
+};
+type EnrichableQuotation = {
+ quotation_items: EnrichableLineItem[];
+ scope_sections?: unknown;
+ customers?: { name?: string | null } | null;
+ [key: string]: unknown;
+};
+
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
-function enrichQuotation(q: any) {
+// Generic over the concrete payload so `...rest` keeps every field the caller's
+// select actually returned (the return type must stay as rich as the input).
+function enrichQuotation(q: T) {
const total = q.quotation_items
- .filter((i: any) => i.is_included_in_total !== false)
+ .filter((i) => i.is_included_in_total !== false)
.reduce(
- (s: number, i: any) =>
+ (s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
@@ -260,7 +277,7 @@ export async function getOffer(id: number) {
};
}
-export async function createOffer(body: Record) {
+export async function createOffer(body: Record) {
try {
return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
@@ -362,7 +379,7 @@ export async function createOffer(body: Record) {
}
}
-export async function updateOffer(id: number, body: Record) {
+export async function updateOffer(id: number, body: Record) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts
index 27d09c1..b6794d2 100644
--- a/src/services/orders.service.ts
+++ b/src/services/orders.service.ts
@@ -79,11 +79,36 @@ async function syncProjectStatus(
// silently 0 in the per-currency totals.
//
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
-function enrichOrder(o: any) {
+// Structural shape for enrichOrder — like enrichQuotation it runs over several
+// different selects (full include vs. the minimal totals select), so only the
+// fields actually read are pinned; everything else is spread through.
+type EnrichableLineItem = {
+ is_included_in_total?: unknown;
+ quantity?: unknown;
+ unit_price?: unknown;
+};
+type EnrichableOrder = {
+ order_items: EnrichableLineItem[];
+ order_sections?: unknown;
+ invoices?: Array<{
+ id?: number | null;
+ invoice_number?: string | null;
+ }> | null;
+ customers?: { name?: string | null } | null;
+ quotations?: {
+ quotation_number?: string | null;
+ project_code?: string | null;
+ } | null;
+ [key: string]: unknown;
+};
+
+// Generic over the concrete payload so `...rest` keeps every field the caller's
+// select actually returned (the return type must stay as rich as the input).
+function enrichOrder(o: T) {
const total = o.order_items
- .filter((i: any) => i.is_included_in_total !== false)
+ .filter((i) => i.is_included_in_total !== false)
.reduce(
- (s: number, i: any) =>
+ (s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts
index 512b4ff..e51d341 100644
--- a/src/services/plan.service.ts
+++ b/src/services/plan.service.ts
@@ -680,6 +680,11 @@ export async function deleteEntry(
// Override CRUD
// ---------------------------------------------------------------------------
+/** The work_plan_overrides row shape, derived straight from the Prisma client. */
+type WorkPlanOverride = Awaited<
+ ReturnType
+>;
+
/**
* Create a work_plan_overrides row. Additive: stacks up to
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
@@ -696,7 +701,7 @@ export async function createOverride(
},
actorUserId: number,
force: boolean,
-): Promise> {
+): Promise> {
const lock = assertNotPastDate(input.shift_date, force);
if (lock) return lock;
@@ -762,7 +767,7 @@ export async function updateOverride(
input: { project_id?: number | null; category?: string; note?: string },
actorUserId: number,
force: boolean,
-): Promise> {
+): Promise> {
const existing = await prisma.work_plan_overrides.findUnique({
where: { id },
});
diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts
index f954b38..c2f695e 100644
--- a/src/services/projects.service.ts
+++ b/src/services/projects.service.ts
@@ -97,7 +97,7 @@ export async function getProject(id: number) {
};
}
-export async function updateProject(id: number, body: Record) {
+export async function updateProject(id: number, body: Record) {
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return null;
diff --git a/src/services/users.service.ts b/src/services/users.service.ts
index dcdb670..1796702 100644
--- a/src/services/users.service.ts
+++ b/src/services/users.service.ts
@@ -61,7 +61,7 @@ export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
- const where: any = search
+ const where: Prisma.usersWhereInput = search
? {
OR: [
{ username: { contains: search } },