Compare commits

...

3 Commits

Author SHA1 Message Date
BOHA
1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00
BOHA
907ee574e6 chore(lint): remove all 34 dead-code warnings (verified site by site)
unused-vars (25): leftover imports/types across pages, routes and services;
write-only sickHours accumulator; unused holidayCount pair; test helpers
authPatch/authDelete; TOut dropped from ApiMutationOptions (the hook keeps
its own generic — no call-site changes); requireAuth no longer importable
in customers/warehouse routes (matches the no-bare-auth-reads convention).

useless-assignment (5): initializers proven overwritten on every path
(systemQty x2, hours, writtenSize -> let x: number) and the seed's dead
adminUser reassignment (create kept, assignment dropped).

useless-escape (4): [eE+\-] -> [eE+-] and [\/...] -> [/...] — identical
semantics.

Behavior-neutral; lint 102 -> 68 warnings (0 errors), suite 641 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:13:52 +02:00
BOHA
b51ea2505e feat(db): stock CHECK constraints + audit_logs DATETIME (2038 fix)
Migration 20260612234500: CHECK (quantity >= 0) on sklad_batches and
sklad_item_locations — the DB itself now rejects negative stock, so a
future C2-class bug (cumulative decrements driving a batch negative and
silently vanishing from totals) becomes a loud error instead of hidden
corruption. All three databases verified clean of negative rows before
the migration.

audit_logs.created_at TIMESTAMP(0) -> DATETIME(0): TIMESTAMP dies in 2038
and converts through the session timezone; existing wall times preserved
by the MODIFY.

Pinned by db-constraints.test.ts (4 tests, each watched fail pre-migration:
negative insert/update/location all succeeded before, 2098 audit timestamp
was rejected before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:56:56 +02:00
57 changed files with 1130 additions and 208 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.32", "version": "2.4.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.32", "version": "2.4.35",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.32", "version": "2.4.35",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0);
-- Stock invariants enforced by the database itself: a future cumulative-
-- decrement bug (the C2 class — duplicate issue lines driving a batch
-- negative and silently vanishing from stock totals) becomes a loud error
-- instead of hidden corruption. All databases verified clean of negative
-- rows before this migration (2026-06-12).
ALTER TABLE `sklad_batches`
ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0);
ALTER TABLE `sklad_item_locations`
ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0);

View File

@@ -65,7 +65,9 @@ model audit_logs {
new_values String? @db.LongText new_values String? @db.LongText
user_agent String? @db.Text user_agent String? @db.Text
session_id String? @db.VarChar(128) session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0) // DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
// the session timezone (2026-06-12 hardening migration).
created_at DateTime? @default(now()) @db.DateTime(0)
@@index([action], map: "idx_audit_logs_action") @@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created") @@index([created_at], map: "idx_audit_logs_created")

View File

@@ -391,12 +391,12 @@ async function main() {
console.log(` Admin role now has all ${allPerms.length} permissions`); console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists // 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({ const adminUser = await prisma.users.findFirst({
where: { username: "admin" }, where: { username: "admin" },
}); });
if (!adminUser) { if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10); const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({ await prisma.users.create({
data: { data: {
username: "admin", username: "admin",
email: "admin@boha.local", email: "admin@boha.local",

View File

@@ -59,7 +59,7 @@ async function main() {
// fails we KEEP file_data and report the row as failed. // fails we KEEP file_data and report the row as failed.
const expectedSize = rec.file_data.length; const expectedSize = rec.file_data.length;
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath); const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
let writtenSize = -1; let writtenSize: number;
if (readBack) { if (readBack) {
writtenSize = readBack.data.length; writtenSize = readBack.data.length;
} else { } else {

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
/**
* Pins the 2026-06-12 DB hardening migration:
* 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB
* itself rejects negative stock, so a future C2-class bug (cumulative
* decrements driving a batch negative) becomes a loud error instead of
* silently hidden corruption.
* 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap.
*/
const N = "db_constraints_";
let itemId: number;
let locationId: number;
let receiptId: number;
let lineAId: number;
let lineBId: number;
async function cleanup() {
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_item_locations.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
await prisma.sklad_locations.deleteMany({
where: { name: { contains: N } },
});
await prisma.audit_logs.deleteMany({
where: { description: { contains: N } },
});
}
beforeAll(async () => {
await cleanup();
const item = await prisma.sklad_items.create({
data: { name: `${N}item`, unit: "ks" },
});
itemId = item.id;
const location = await prisma.sklad_locations.create({
data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` },
});
locationId = location.id;
const receipt = await prisma.sklad_receipts.create({
data: { notes: `${N}receipt`, status: "CONFIRMED" },
});
receiptId = receipt.id;
const lineA = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receiptId,
item_id: itemId,
quantity: 5,
unit_price: 1,
},
});
lineAId = lineA.id;
const lineB = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receiptId,
item_id: itemId,
quantity: 5,
unit_price: 1,
},
});
lineBId = lineB.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("stock CHECK constraints", () => {
it("rejects inserting a negative batch quantity", async () => {
await expect(
prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: lineAId,
quantity: -1,
original_qty: 5,
unit_price: 1,
is_consumed: false,
},
}),
).rejects.toThrow();
});
it("rejects updating a batch quantity below zero", async () => {
const batch = await prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: lineBId,
quantity: 5,
original_qty: 5,
unit_price: 1,
is_consumed: false,
},
});
await expect(
prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`,
).rejects.toThrow();
const fresh = await prisma.sklad_batches.findUnique({
where: { id: batch.id },
});
expect(Number(fresh?.quantity)).toBe(5);
});
it("rejects a negative item_location quantity", async () => {
await expect(
prisma.sklad_item_locations.create({
data: { item_id: itemId, location_id: locationId, quantity: -1 },
}),
).rejects.toThrow();
});
});
describe("audit_logs.created_at has no 2038 cap", () => {
it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => {
const row = await prisma.audit_logs.create({
data: {
action: "test",
description: `${N}future`,
created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)),
},
});
expect(row.created_at?.getUTCFullYear()).toBe(2098);
});
});

View File

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

View File

@@ -1090,26 +1090,6 @@ async function authPost(path: string, token: string, body: unknown) {
}); });
} }
async function authPatch(path: string, token: string, body: unknown) {
return app.inject({
method: "PATCH",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authDelete(path: string, token: string) {
return app.inject({
method: "DELETE",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();

View File

@@ -148,7 +148,7 @@ const TrashIcon = (
); );
export default function PlanCellModal(props: Props) { export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props; const { open, mode } = props;
if (!open || mode.kind === "closed") return null; if (!open || mode.kind === "closed") return null;
if (mode.kind === "view") return <ViewModal {...props} />; if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range") if (mode.kind === "day-in-range")
@@ -488,6 +488,7 @@ function DayInRangeModal(
onClose={onClose} onClose={onClose}
onSubmit={onClose} onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny" submitText="Zavřít — ponechat rozsah beze změny"
hideCancel
maxWidth="md" maxWidth="md"
> >
<Typography variant="body2" sx={{ mb: 2 }}> <Typography variant="body2" sx={{ mb: 2 }}>

View File

@@ -491,9 +491,11 @@ export default function PlanGrid({
}, [projects]); }, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the // 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. // visible range changes). Stable identity also keeps the row map cheap.
const dateFrom = data?.date_from;
const dateTo = data?.date_to;
const days = useMemo( const days = useMemo(
() => (data ? eachDay(data.date_from, data.date_to) : []), () => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
[data?.date_from, data?.date_to], [dateFrom, dateTo],
); );
if (!data) return <LoadingState />; if (!data) return <LoadingState />;

View File

@@ -19,46 +19,22 @@ import {
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
// ---------- Shared types ---------- // ---------- Shared types ----------
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
export interface ShiftFormData { // here so existing importers of ShiftFormModal keep working.
user_id: string; export type {
shift_date: string; ShiftFormData,
leave_type: string; ProjectLog,
leave_hours: number; Project,
arrival_date: string; User,
arrival_time: string; EditingRecord,
break_start_date: string; } from "./shiftFormTypes";
break_start_time: string; import type {
break_end_date: string; ShiftFormData,
break_end_time: string; ProjectLog,
departure_date: string; Project,
departure_time: string; User,
notes: string; EditingRecord,
} } from "./shiftFormTypes";
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;
}
// ---------- Sub-component props ---------- // ---------- Sub-component props ----------

View File

@@ -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;
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { Select } from "../../ui"; 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 // When item/project change, the loaded reservation list changes. If the
// currently-selected reservation is no longer available, clear it and notify // currently-selected reservation is no longer available, clear it and notify

View File

@@ -43,9 +43,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set()); const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => { useEffect(() => {
const timeouts = timeoutsRef.current;
return () => { return () => {
timeoutsRef.current.forEach(clearTimeout); timeouts.forEach(clearTimeout);
timeoutsRef.current.clear(); timeouts.clear();
}; };
}, []); }, []);

View File

@@ -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
[], [],
); );

View File

@@ -29,7 +29,7 @@ import type {
ProjectLog, ProjectLog,
Project, Project,
User, User,
} from "../components/ShiftFormModal"; } from "../components/shiftFormTypes";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -234,7 +234,6 @@ function computeUserTotals(
} }
const holidayDates = holidaysInMonth(yr, mo + 1); const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) { for (const uid of Object.keys(totals)) {
const t = totals[uid]; const t = totals[uid];
@@ -345,14 +344,25 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
function buildProjectLogsHtml(record: Record<string, any>): 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<string, UserTotal>;
}
export function buildProjectLogsHtml(record: AttendanceRecord): string {
if (record.project_logs && record.project_logs.length > 0) { if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs return record.project_logs
.map((log: any) => { .map((log) => {
let h: number, m: number; let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) { if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0; // hours/minutes arrive as string or number — String() before parseInt
m = parseInt(log.minutes) || 0; // (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) { } else if (log.started_at && log.ended_at) {
const mins = Math.max( const mins = Math.max(
0, 0,
@@ -375,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—"); return escapeHtml(record.project_name || "—");
} }
function buildUserSectionHtml( export function buildUserSectionHtml(
userId: string, userId: string,
userData: Record<string, any>, userData: UserTotal,
printData: Record<string, any>, printData: PrintData,
): string { ): string {
// Build a date-keyed lookup of the user's records. // Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, Record<string, any>>(); const recordsByDate = new Map<string, AttendanceRecord>();
for (const r of userData.records || []) { for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r); recordsByDate.set(normalizeDateStr(r.shift_date), r);
} }
@@ -392,7 +402,7 @@ function buildUserSectionHtml(
const mo = parseInt(monthStr, 10); const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo); const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = (userData.records || []) as Record<string, any>[]; const userRecords = userData.records ?? [];
const rows: string[] = []; const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) { for (let d = 1; d <= daysInMonth; d++) {
@@ -476,14 +486,12 @@ function buildUserSectionHtml(
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The // Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down // "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here. // and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0, let vacationHours = 0;
sickHours = 0;
for (const r of userRecords) { for (const r of userRecords) {
const lt = r.leave_type || "work"; const lt = r.leave_type || "work";
if (lt === "work") continue; if (lt === "work") continue;
const h = Number(r.leave_hours) || 8; const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h; if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
} }
// Odpracováno: floor(worked / 8) × 8. // Odpracováno: floor(worked / 8) × 8.
@@ -492,7 +500,6 @@ function buildUserSectionHtml(
// Free Svátek: 8h per holiday date the user did not work. // Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo); const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set( const workedDates = new Set(
userRecords userRecords
.filter((r) => (r.leave_type || "work") === "work") .filter((r) => (r.leave_type || "work") === "work")
@@ -592,8 +599,8 @@ function buildUserSectionHtml(
</div>`; </div>`;
} }
function buildPrintHtml( export function buildPrintHtml(
pData: Record<string, any>, pData: PrintData,
userSections: string, userSections: string,
emptyMsg: string, emptyMsg: string,
filterNote: string, filterNote: string,
@@ -1334,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const pData = result.data; const pData = result.data;
const userSections = Object.entries(pData.user_totals) const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) => .map(([uid, uData]) =>
buildUserSectionHtml(uid, uData as Record<string, any>, pData), buildUserSectionHtml(uid, uData as UserTotal, pData),
) )
.join(""); .join("");
const emptyMsg = const emptyMsg =

View File

@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Snapshot the currently-cached grid for a given key. Returns // Snapshot the currently-cached grid for a given key. Returns
// `undefined` if there's nothing cached (we don't patch empty data). // `undefined` if there's nothing cached (we don't patch empty data).
function snapshotGrid(key: readonly unknown[]): GridData | undefined { function snapshotGrid(key: readonly unknown[]): GridData | undefined {
return qc.getQueryData<GridData>(key as any); return qc.getQueryData<GridData>(key);
} }
// Apply a per-date cell patch to the cached grid at `key`. Returns the // 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. // `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
function rollbackCells(ctx: CellRollback | undefined) { function rollbackCells(ctx: CellRollback | undefined) {
if (!ctx || !ctx.rolled) return; if (!ctx || !ctx.rolled) return;
const prev = qc.getQueryData<GridData>(ctx.key as any); const prev = qc.getQueryData<GridData>(ctx.key);
if (!prev) return; if (!prev) return;
const userPrev = prev.cells[ctx.userId] ?? {}; const userPrev = prev.cells[ctx.userId] ?? {};
const userNext = { ...userPrev }; 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 // Find the user that owns this entry in the current grid by
// looking for any cell with entryId === id (we already know // looking for any cell with entryId === id (we already know
// the id from vars; it doesn't change across the update). // the id from vars; it doesn't change across the update).
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey);
let ownerUserId: number | null = null; let ownerUserId: number | null = null;
if (grid) { if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) { 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. // 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 // Look it up from the current grid: find the user that has a cell
// with entryId === id, and read rangeFrom/rangeTo from that cell. // with entryId === id, and read rangeFrom/rangeTo from that cell.
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null; if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
let entryRange: { from: string; to: string } | null = null; let entryRange: { from: string; to: string } | null = null;
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
onMutate: (vars): CellRollback => { onMutate: (vars): CellRollback => {
// Find the user/date for this overrideId in the current grid, then // Find the user/date for this overrideId in the current grid, then
// patch that single cell with the new values. // patch that single cell with the new values.
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null; if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
method: "DELETE", method: "DELETE",
}), }),
onMutate: (vars): CellRollback => { onMutate: (vars): CellRollback => {
const grid = qc.getQueryData<GridData>(currentGridKey as any); const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null; if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) { for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) { for (const [date, cells] of Object.entries(byDate)) {

View File

@@ -92,7 +92,7 @@ async function performMutation<TIn, TOut>(opts: {
return result.data as TOut; return result.data as TOut;
} }
export interface ApiMutationOptions<TIn, TOut> { export interface ApiMutationOptions<TIn> {
url: string | ((input: TIn) => string); url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod); method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */ /** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
@@ -122,7 +122,7 @@ export interface ApiMutationOptions<TIn, TOut> {
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code. * as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
*/ */
export function useApiMutation<TIn, TOut>( export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> & opts: ApiMutationOptions<TIn> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">, Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) { ) {
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts; const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;

View File

@@ -1,5 +1,4 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter"; import { jsonQuery } from "../apiAdapter";
export interface PlanUser { export interface PlanUser {

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useRef } from "react"; import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -9,7 +9,6 @@ import { iconBadgeSx } from "../theme";
import { import {
attendanceHistoryOptions, attendanceHistoryOptions,
type AttendanceRecord, type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance"; } from "../lib/queries/attendance";
import { import {
formatDate, formatDate,
@@ -201,7 +200,6 @@ const renderProjectCell = (record: AttendanceRecord) => {
export default function AttendanceHistory() { export default function AttendanceHistory() {
const { user, hasPermission } = useAuth(); const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions()); const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || ""; const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null); const printRef = useRef<HTMLDivElement>(null);
@@ -212,7 +210,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery( const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }), attendanceHistoryOptions({ month, userId: user?.id }),
); );
const records = data ?? []; const records = useMemo(() => data ?? [], [data]);
const computed = useMemo(() => { const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-"); const [yearStr, monthStr] = month.split("-");

View File

@@ -26,10 +26,7 @@ const LocationMap = styled("div")(({ theme }) => ({
})); }));
import { formatDate, formatTime } from "../utils/attendanceHelpers"; import { formatDate, formatTime } from "../utils/attendanceHelpers";
import { import { attendanceLocationOptions } from "../lib/queries/attendance";
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui"; import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
const BackIcon = ( const BackIcon = (

View File

@@ -3,10 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { import { companySettingsOptions } from "../lib/queries/settings";
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common"; import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -23,7 +20,6 @@ import {
TextField, TextField,
Select, Select,
CheckboxField, CheckboxField,
StatusChip,
FileUpload, FileUpload,
LoadingState, LoadingState,
type DataColumn, type DataColumn,

View File

@@ -621,7 +621,10 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? []; const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions()); const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? []; const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id)); const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null; const invoice = invoiceQuery.data ?? null;
@@ -656,8 +659,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeouts.forEach(clearTimeout);
}; };
}, []); }, []);

View File

@@ -1,14 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { orderDetailOptions, type OrderItem } from "../lib/queries/orders";
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -128,8 +121,9 @@ export default function OrderDetail() {
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps }, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeouts.forEach(clearTimeout);
}; };
}, []); }, []);

View File

@@ -10,6 +10,10 @@ import {
getCell, getCell,
getCells, getCells,
type ModalMode as HookModalMode, type ModalMode as HookModalMode,
type PlanEntryBody,
type PlanEntryPatchBody,
type PlanOverrideBody,
type PlanOverridePatchBody,
} from "../hooks/usePlanWork"; } from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal"; import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid"; import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return; if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true); setBulkSubmitting(true);
try { 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), user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from, date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : 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, project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category, category: bulkForm.category,
note: bulkForm.note, note: bulkForm.note,
}); })) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message }; // apiCall returns the full envelope { success, data, message };
// the summary counts are under .data // the summary counts are under .data
const summary = data?.data ?? data; const summary: BulkSummary = data?.data ?? data;
const skipped = const skipped =
summary?.skipped_days > 0 (summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).` ? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: ""; : "";
alert.success( alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied. // optimistic cache patch the hook already applied.
const saveEntry = useCallback( const saveEntry = useCallback(
async (body: any) => { async (body: PlanEntryBody) => {
try { try {
await createEntry.mutateAsync(body); await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries // Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
); );
const updateEntryFn = useCallback( 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 / // Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already // which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid. // discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
); );
const saveOverride = useCallback( const saveOverride = useCallback(
async (body: any) => { async (body: PlanOverrideBody) => {
try { try {
await createOverride.mutateAsync(body); await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date); firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
); );
const updateOverrideFn = useCallback( const updateOverrideFn = useCallback(
async (id: number, body: any) => { async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback. // Find the user/date for the pulse + rollback.
let userId: number | null = null; let userId: number | null = null;
let date: string | null = null; let date: string | null = null;

View File

@@ -11,7 +11,6 @@ import MenuItem from "@mui/material/MenuItem";
import { import {
projectDetailOptions, projectDetailOptions,
type ProjectData,
type ProjectNote, type ProjectNote,
} from "../lib/queries/projects"; } from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users"; import { userListOptions, type User as ApiUser } from "../lib/queries/users";

View File

@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeouts.forEach(clearTimeout);
}; };
}, []); }, []);
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
? "Zavřít" ? "Zavřít"
: "Uložit" : "Uložit"
} }
cancelText={ // Read-only (paid) detail has only a close action — a single "Zavřít"
editInvoice && editInvoice._originalStatus === "paid" // button, not a redundant cancel button beside it.
? "Zavřít" hideCancel={editInvoice?._originalStatus === "paid"}
: "Zrušit"
}
loading={saving} loading={saving}
> >
{editInvoice && {editInvoice &&

View File

@@ -715,7 +715,25 @@ export default function Settings() {
</Box> </Box>
); );
} }
const si = systemInfo as Record<string, any>; 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<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({ const Row = ({
label, label,
children, children,
@@ -800,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices], ["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders], ["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers], ["Nabídky", si.nas?.offers],
] as [string, Record<string, any>][] ] as [string, NasInfo | undefined][]
).map(([label, info]) => ( ).map(([label, info]) => (
<Row key={label} label={label}> <Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

@@ -199,7 +199,7 @@ export default function Trips() {
TripForm, TripForm,
{ message?: string; error?: string } { message?: string; error?: string }
>({ >({
url: (formData) => url: () =>
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`, editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
method: () => (editingTrip ? "PUT" : "POST"), method: () => (editingTrip ? "PUT" : "POST"),
invalidate: ["trips", "vehicles"], invalidate: ["trips", "vehicles"],

View File

@@ -23,7 +23,7 @@ interface InventoryItem {
} }
function parseDecimal(raw: string): number { function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, ""); const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned); const n = Number(cleaned);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useId } from "react"; import { useState, useEffect, useRef, useId, useCallback } from "react";
import { import {
useParams, useParams,
useNavigate, useNavigate,
@@ -65,7 +65,7 @@ interface BatchesResponse {
} }
function parseDecimal(raw: string): number { function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, ""); const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned); const n = Number(cleaned);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
const baseId = useId(); const baseId = useId();
const counterRef = useRef(0); 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) // Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as { const prefill = location.state as {
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
} }
formInitialized.current = true; formInitialized.current = true;
} }
}, [isEdit, issue]); }, [isEdit, issue, nextKey]);
const saveMutation = useApiMutation< const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>, ReturnType<typeof buildPayload>,

View File

@@ -50,7 +50,7 @@ interface MovementItem {
} }
function parseDecimal(raw: string): number { function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, ""); const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned); const n = Number(cleaned);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
const baseId = useId(); const baseId = useId();
const counterRef = useRef(0); const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`; const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
const [form, setForm] = useState<ReceiptForm>({ const [form, setForm] = useState<ReceiptForm>({
supplier_id: null, supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
} }
formInitialized.current = true; formInitialized.current = true;
} }
}, [isEdit, receipt]); }, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation< const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>, ReturnType<typeof buildPayload>,

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { theme } from "./theme"; import { theme } from "./theme";
/** Loose shape for poking into MUI styleOverrides objects in assertions. */
type StyleObj = Record<string, unknown>;
/** Minimal palette shape used by the colorSchemes assertions below. */ /** Minimal palette shape used by the colorSchemes assertions below. */
interface SchemesPalette { interface SchemesPalette {
primary: { main: string }; primary: { main: string };
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
}); });
it("makes buttons pill-shaped and cards 16px by default", () => { it("makes buttons pill-shaped and cards 16px by default", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any; const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn?.borderRadius).toBe(999); expect(btn.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as any; const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
expect(card?.borderRadius).toBe(16); expect(card.borderRadius).toBe(16);
}); });
it("adds 200-300ms interaction motion (press + focus ring)", () => { it("adds 200-300ms interaction motion (press + focus ring)", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any; const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn?.transition).toContain("transform"); expect(btn.transition).toContain("transform");
expect(btn?.["&:active"]?.transform).toBe("scale(0.97)"); const active = btn["&:active"] as StyleObj;
expect(active.transform).toBe("scale(0.97)");
const input = theme.components?.MuiOutlinedInput?.styleOverrides const input = theme.components?.MuiOutlinedInput?.styleOverrides
?.root as any; ?.root as StyleObj;
expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba"); const focused = input["&.Mui-focused"] as StyleObj;
expect(focused.boxShadow).toContain("rgba");
}); });
}); });

View File

@@ -1,7 +1,6 @@
import { FastifyRequest, FastifyReply } from "fastify"; import { FastifyRequest, FastifyReply } from "fastify";
import { verifyAccessToken } from "../services/auth"; import { verifyAccessToken } from "../services/auth";
import { error } from "../utils/response"; import { error } from "../utils/response";
import { AuthData } from "../types";
export async function requireAuth( export async function requireAuth(
request: FastifyRequest, request: FastifyRequest,

View File

@@ -13,6 +13,8 @@ import { parseBody } from "../../schemas/common";
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema"; import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
import { invalidateSettingsCache } from "../../services/system-settings"; import { invalidateSettingsCache } from "../../services/system-settings";
import os from "os"; import os from "os";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { config } from "../../config/env"; import { config } from "../../config/env";
import { NasFileManager } from "../../services/nas-file-manager"; import { NasFileManager } from "../../services/nas-file-manager";
import { import {
@@ -21,6 +23,15 @@ import {
} from "../../services/nas-financials-manager"; } from "../../services/nas-financials-manager";
import { nasOffersManager } from "../../services/nas-offers-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) */ /** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields( function encodeCustomFields(
fields: unknown, fields: unknown,
@@ -325,8 +336,6 @@ export default async function companySettingsRoutes(
settings.custom_fields as string | null, settings.custom_fields as string | null,
); );
const pkg = require("../../../package.json") as { version: string };
let available_vat_rates: number[] = [0, 10, 12, 15, 21]; let available_vat_rates: number[] = [0, 10, 12, 15, 21];
try { try {
const raw = settings.available_vat_rates as string | null; const raw = settings.available_vat_rates as string | null;
@@ -359,7 +368,7 @@ export default async function companySettingsRoutes(
available_currencies, available_currencies,
has_logo, has_logo,
has_logo_dark, has_logo_dark,
app_version: pkg.version, app_version: APP_VERSION,
}); });
}); });
@@ -368,7 +377,6 @@ export default async function companySettingsRoutes(
"/system-info", "/system-info",
{ preHandler: requirePermission("settings.system") }, { preHandler: requirePermission("settings.system") },
async (request, reply) => { async (request, reply) => {
const pkg = require("../../../package.json") as { version: string };
const uptimeSec = process.uptime(); const uptimeSec = process.uptime();
const days = Math.floor(uptimeSec / 86400); const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600); const hours = Math.floor((uptimeSec % 86400) / 3600);
@@ -401,7 +409,7 @@ export default async function companySettingsRoutes(
const projectNas = new NasFileManager(); const projectNas = new NasFileManager();
return success(reply, { return success(reply, {
app_version: pkg.version, app_version: APP_VERSION,
node_version: process.version, node_version: process.version,
platform: `${os.type()} ${os.release()}`, platform: `${os.type()} ${os.release()}`,
uptime: uptimeStr, uptime: uptimeStr,

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";

View File

@@ -13,7 +13,6 @@ import {
markOverdueInvoices, markOverdueInvoices,
listInvoices, listInvoices,
getInvoiceListTotals, getInvoiceListTotals,
getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview, getNextInvoiceNumberPreview,
getInvoiceStats, getInvoiceStats,
getOrderDataForInvoice, getOrderDataForInvoice,

View File

@@ -194,7 +194,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id", "/categories/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const body = parseBody(UpdatePlanCategorySchema, request.body); const body = parseBody(UpdatePlanCategorySchema, request.body);
if ("error" in body) return error(reply, body.error, 400); if ("error" in body) return error(reply, body.error, 400);
@@ -218,7 +218,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id", "/categories/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const result = await deletePlanCategory(id); const result = await deletePlanCategory(id);
if ("error" in result) return error(reply, result.error, result.status); if ("error" in result) return error(reply, result.error, result.status);
@@ -289,7 +289,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id", "/entries/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const body = parseBody(UpdatePlanEntrySchema, request.body); const body = parseBody(UpdatePlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400); if ("error" in body) return error(reply, body.error, 400);
@@ -321,7 +321,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id", "/entries/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const force = forceFromQuery(request.query); const force = forceFromQuery(request.query);
const result = await deleteEntry(id, request.authData!.userId, force); const result = await deleteEntry(id, request.authData!.userId, force);
@@ -373,7 +373,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id", "/overrides/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const body = parseBody(UpdatePlanOverrideSchema, request.body); const body = parseBody(UpdatePlanOverrideSchema, request.body);
if ("error" in body) return error(reply, body.error, 400); if ("error" in body) return error(reply, body.error, 400);
@@ -405,7 +405,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id", "/overrides/:id",
{ preHandler: requirePermission("attendance.manage") }, { preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => { 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; if (id === null) return;
const force = forceFromQuery(request.query); const force = forceFromQuery(request.query);
const result = await deleteOverride(id, request.authData!.userId, force); const result = await deleteOverride(id, request.authData!.userId, force);

View File

@@ -199,7 +199,7 @@ export default async function projectFilesRoutes(
const subPath = parsedQuery.data.path || ""; const subPath = parsedQuery.data.path || "";
const rawFileName = file.filename; const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_"); const fileName = rawFileName.replace(/[/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer(); const buffer = await file.toBuffer();
const err = await fm.uploadFile( const err = await fm.uploadFile(

View File

@@ -89,7 +89,11 @@ export default async function projectsRoutes(
const result = await createProject(parsed.data); const result = await createProject(parsed.data);
if ("error" in result) 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({ await logAudit({
request, request,
@@ -127,7 +131,11 @@ export default async function projectsRoutes(
const result = await updateProject(id, parsed.data); const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404); if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) { 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({ await logAudit({
@@ -160,7 +168,11 @@ export default async function projectsRoutes(
content: parsed.data.content ?? undefined, content: parsed.data.content ?? undefined,
}); });
if (note && "error" in note) { 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({ await logAudit({

View File

@@ -82,7 +82,7 @@ export default async function sessionsRoutes(
"/:id", "/:id",
{ preHandler: requireAuth }, { preHandler: requireAuth },
async (request, reply) => { async (request, reply) => {
const id = parseId((request.params as any).id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const authData = request.authData!; const authData = request.authData!;

View File

@@ -293,7 +293,7 @@ export default async function tripsRoutes(
"/last-km/:vehicleId", "/last-km/:vehicleId",
{ preHandler: requirePermission("trips.manage") }, { preHandler: requirePermission("trips.manage") },
async (request, reply) => { async (request, reply) => {
const vehicleId = parseId((request.params as any).vehicleId, reply); const vehicleId = parseId(request.params.vehicleId, reply);
if (vehicleId === null) return; if (vehicleId === null) return;
const [lastTrip, vehicle] = await Promise.all([ const [lastTrip, vehicle] = await Promise.all([
@@ -373,7 +373,7 @@ export default async function tripsRoutes(
"/:id", "/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") }, { preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => { async (request, reply) => {
const id = parseId((request.params as any).id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const parsed = parseBody(UpdateTripSchema, request.body); const parsed = parseBody(UpdateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -461,7 +461,7 @@ export default async function tripsRoutes(
"/:id", "/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") }, { preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => { async (request, reply) => {
const id = parseId((request.params as any).id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const authData = request.authData!; const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } }); const existing = await prisma.trips.findUnique({ where: { id } });

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { config } from "../../config/env"; import { config } from "../../config/env";
import { requireAuth, requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition"; import { contentDisposition } from "../../utils/content-disposition";
@@ -2209,7 +2209,7 @@ export default async function warehouseRoutes(
// Build items with auto-filled system_qty and calculated difference // Build items with auto-filled system_qty and calculated difference
const itemsData = await Promise.all( const itemsData = await Promise.all(
body.items.map(async (item) => { body.items.map(async (item) => {
let systemQty = 0; let systemQty: number;
if (item.location_id != null) { if (item.location_id != null) {
// Get stock at specific location // Get stock at specific location
@@ -2318,7 +2318,7 @@ export default async function warehouseRoutes(
if (body.items) { if (body.items) {
itemsData = await Promise.all( itemsData = await Promise.all(
body.items.map(async (item) => { body.items.map(async (item) => {
let systemQty = 0; let systemQty: number;
if (item.location_id != null) { if (item.location_id != null) {
const loc = await prisma.sklad_item_locations.findUnique({ const loc = await prisma.sklad_item_locations.findUnique({

View File

@@ -1,4 +1,5 @@
import Fastify from "fastify"; import Fastify from "fastify";
import type { IncomingMessage, ServerResponse } from "http";
import type { ScheduledTask } from "node-cron"; import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
@@ -167,13 +168,26 @@ async function start() {
// --- Frontend: Vite dev middleware (dev only) --- // --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) { 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<ViteDevServerLike>;
};
const viteModule = await (Function( const viteModule = await (Function(
'return import("vite")', 'return import("vite")',
)() as Promise<any>); )() as Promise<ViteModuleLike>);
const createViteServer = viteModule.createServer as ( const vite = await viteModule.createServer({
opts: any,
) => Promise<any>;
const vite = await createViteServer({
server: { middlewareMode: true }, server: { middlewareMode: true },
appType: "spa", appType: "spa",
}); });

View File

@@ -523,6 +523,8 @@ export async function extractInvoice(
return JSON.parse(text) as ExtractedInvoice; return JSON.parse(text) as ExtractedInvoice;
} catch (e) { } catch (e) {
console.error("[ai.service] extractInvoice: model reply was not JSON", 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,
});
} }
} }

View File

@@ -791,7 +791,7 @@ export async function getProjectReport(year: number) {
// Use detailed project logs (started_at/ended_at or hours/minutes) // Use detailed project logs (started_at/ended_at or hours/minutes)
for (const log of rec.attendance_project_logs) { for (const log of rec.attendance_project_logs) {
let hours = 0; let hours: number;
if (log.hours != null || log.minutes != null) { if (log.hours != null || log.minutes != null) {
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60; hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
} else if (log.started_at && log.ended_at) { } else if (log.started_at && log.ended_at) {

View File

@@ -1,7 +1,7 @@
import crypto from "crypto"; import crypto from "crypto";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { FastifyRequest, FastifyReply } from "fastify"; import { FastifyRequest } from "fastify";
import prisma from "../config/database"; import prisma from "../config/database";
import { config } from "../config/env"; import { config } from "../config/env";
import { AuthData, JwtPayload } from "../types"; import { AuthData, JwtPayload } from "../types";

View File

@@ -57,7 +57,9 @@ async function fetchRatesForDate(
// an entry that is past TTL — better to serve yesterday's rate than fail). // an entry that is past TTL — better to serve yesterday's rate than fail).
const stale = rateCache.get(key); const stale = rateCache.get(key);
if (stale) return stale.rates; 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 { } finally {
inflight.delete(key); inflight.delete(key);
} }

View File

@@ -115,12 +115,29 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
return where; 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). // 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<T extends EnrichableQuotation>(q: T) {
const total = q.quotation_items const total = q.quotation_items
.filter((i: any) => i.is_included_in_total !== false) .filter((i) => i.is_included_in_total !== false)
.reduce( .reduce(
(s: number, i: any) => (s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0, 0,
); );
@@ -260,7 +277,7 @@ export async function getOffer(id: number) {
}; };
} }
export async function createOffer(body: Record<string, any>) { export async function createOffer(body: Record<string, unknown>) {
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft"; const status = body.status ? String(body.status) : "draft";
@@ -362,7 +379,7 @@ export async function createOffer(body: Record<string, any>) {
} }
} }
export async function updateOffer(id: number, body: Record<string, any>) { export async function updateOffer(id: number, body: Record<string, unknown>) {
const existing = await prisma.quotations.findUnique({ where: { id } }); const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const }; if (!existing) return { error: "not_found" as const };

View File

@@ -79,11 +79,36 @@ async function syncProjectStatus(
// silently 0 in the per-currency totals. // silently 0 in the per-currency totals.
// //
// Orders are NOT tax documents — totals are NET only (no VAT anywhere). // 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<T extends EnrichableOrder>(o: T) {
const total = o.order_items const total = o.order_items
.filter((i: any) => i.is_included_in_total !== false) .filter((i) => i.is_included_in_total !== false)
.reduce( .reduce(
(s: number, i: any) => (s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0, 0,
); );

View File

@@ -680,6 +680,11 @@ export async function deleteEntry(
// Override CRUD // Override CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** The work_plan_overrides row shape, derived straight from the Prisma client. */
type WorkPlanOverride = Awaited<
ReturnType<typeof prisma.work_plan_overrides.create>
>;
/** /**
* Create a work_plan_overrides row. Additive: stacks up to * Create a work_plan_overrides row. Additive: stacks up to
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create * MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
@@ -696,7 +701,7 @@ export async function createOverride(
}, },
actorUserId: number, actorUserId: number,
force: boolean, force: boolean,
): Promise<Result<any>> { ): Promise<Result<WorkPlanOverride>> {
const lock = assertNotPastDate(input.shift_date, force); const lock = assertNotPastDate(input.shift_date, force);
if (lock) return lock; if (lock) return lock;
@@ -762,7 +767,7 @@ export async function updateOverride(
input: { project_id?: number | null; category?: string; note?: string }, input: { project_id?: number | null; category?: string; note?: string },
actorUserId: number, actorUserId: number,
force: boolean, force: boolean,
): Promise<Result<any>> { ): Promise<Result<WorkPlanOverride>> {
const existing = await prisma.work_plan_overrides.findUnique({ const existing = await prisma.work_plan_overrides.findUnique({
where: { id }, where: { id },
}); });

View File

@@ -97,7 +97,7 @@ export async function getProject(id: number) {
}; };
} }
export async function updateProject(id: number, body: Record<string, any>) { export async function updateProject(id: number, body: Record<string, unknown>) {
const existing = await prisma.projects.findUnique({ where: { id } }); const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return null; if (!existing) return null;

View File

@@ -61,7 +61,7 @@ export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search, permission } = params; const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: any = search const where: Prisma.usersWhereInput = search
? { ? {
OR: [ OR: [
{ username: { contains: search } }, { username: { contains: search } },

View File

@@ -1,4 +1,4 @@
import { PaginationQuery, PaginationMeta } from "../types"; import { PaginationMeta } from "../types";
/** /**
* Parse common pagination/sort/search query params. * Parse common pagination/sort/search query params.