From 1ee59b54bcd900291f88ff4aedb2ff77ff8948ce Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 13 Jun 2026 18:35:31 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v2.4.35=20=E2=80=94=20lint=20?= =?UTF-8?q?cleanup=20(68=E2=86=920)=20+=20duplicate-Zav=C5=99=C3=ADt=20mod?= =?UTF-8?q?al=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package-lock.json | 4 +- package.json | 2 +- .../attendance-print-html.test.ts.snap | 374 ++++++++++++++++++ src/__tests__/attendance-print-html.test.ts | 155 ++++++++ src/__tests__/modal-duplicate-close.test.ts | 117 ++++++ src/admin/components/PlanCellModal.tsx | 1 + src/admin/components/PlanGrid.tsx | 6 +- src/admin/components/ShiftFormModal.tsx | 56 +-- src/admin/components/shiftFormTypes.ts | 44 +++ .../warehouse/ReservationPicker.tsx | 4 +- src/admin/context/AlertContext.tsx | 5 +- src/admin/context/AuthContext.tsx | 5 + src/admin/hooks/useAttendanceAdmin.ts | 37 +- src/admin/hooks/usePlanWork.ts | 14 +- src/admin/pages/AttendanceHistory.tsx | 2 +- src/admin/pages/InvoiceDetail.tsx | 8 +- src/admin/pages/OrderDetail.tsx | 3 +- src/admin/pages/PlanWork.tsx | 26 +- src/admin/pages/ReceivedInvoices.tsx | 11 +- src/admin/pages/Settings.tsx | 22 +- src/admin/pages/WarehouseIssueForm.tsx | 9 +- src/admin/pages/WarehouseReceiptForm.tsx | 7 +- src/admin/theme.test.ts | 23 +- src/routes/admin/company-settings.ts | 18 +- src/routes/admin/plan.ts | 12 +- src/routes/admin/projects.ts | 18 +- src/routes/admin/sessions.ts | 2 +- src/routes/admin/trips.ts | 6 +- src/server.ts | 24 +- src/services/ai.service.ts | 4 +- src/services/exchange-rates.ts | 4 +- src/services/offers.service.ts | 27 +- src/services/orders.service.ts | 31 +- src/services/plan.service.ts | 9 +- src/services/projects.service.ts | 2 +- src/services/users.service.ts | 2 +- 36 files changed, 954 insertions(+), 140 deletions(-) create mode 100644 src/__tests__/__snapshots__/attendance-print-html.test.ts.snap create mode 100644 src/__tests__/attendance-print-html.test.ts create mode 100644 src/__tests__/modal-duplicate-close.test.ts create mode 100644 src/admin/components/shiftFormTypes.ts 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`] = ` +"
+
+

Jan Novák

+ Odpracováno: 8:00 h +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatumDenTypPříchodPauzaOdchodHodinyProjektyPoznámka
1. 3. 2098Sobota
2. 3. 2098Neděle
3. 3. 2098Pondělí
4. 3. 2098ÚterýPráce08:0012:00 - 12:3016:308:00 (8,00)
Alpha (8:00h)
Poznámka <b>
5. 3. 2098StředaDovolená8,00
6. 3. 2098Čtvrtek
7. 3. 2098Pátek
8. 3. 2098Sobota
9. 3. 2098Neděle
10. 3. 2098Pondělí
11. 3. 2098Úterý
12. 3. 2098Středa
13. 3. 2098Čtvrtek
14. 3. 2098Pátek
15. 3. 2098Sobota
16. 3. 2098Neděle
17. 3. 2098Pondělí
18. 3. 2098Úterý
19. 3. 2098Středa
20. 3. 2098Čtvrtek
21. 3. 2098Pátek
22. 3. 2098Sobota
23. 3. 2098Neděle
24. 3. 2098Pondělí
25. 3. 2098Úterý
26. 3. 2098Středa
27. 3. 2098Čtvrtek
28. 3. 2098Pátek
29. 3. 2098Sobota
30. 3. 2098Neděle
31. 3. 2098Pondě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 } },