Compare commits

...

4 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
BOHA
fd2e613aa5 feat(ui): sidebar icon refresh + unified detail-page headers
Sidebar (user-approved design, docs/superpowers/specs/2026-06-12-sidebar-
icons-design.md): all 31 menu items get unique contextual stroke glyphs in
rounded-square tiles tinted per section (Docházka blue, Kniha jízd teal,
Administrativa amber, Sklad violet, Systém slate; new teal/violet/slate
palette entries with channel tokens in both schemes). Replaces the old set
where 13 items shared a glyph (3x people, 3x sliders, ...) and Faktury was a
dollar sign. Odin stays the only solid + animated tile. Uniqueness pinned by
navdata-icons.test.ts (tsconfig.test.json gains jsx for the .tsx import).

Headers: ProjectDetail and OrderDetail now match the Offer/Invoice shell —
number rendered in secondary color at regular weight next to the bold
entity word, and flexWrap on both left header boxes so the title drops
below Zpět on phones instead of overflowing the viewport (the reported
mobile bug on received orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:37:44 +02:00
63 changed files with 1616 additions and 563 deletions

View File

@@ -0,0 +1,52 @@
# Sidebar icon refresh — design
**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens)
## Problem
All 31 sidebar items have icons, but 13 share a glyph with another item
(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury
uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat
the purpose of icons as unique visual anchors.
## Decisions (user-approved)
1. **Full refresh** of all items (option B over targeted fix).
2. **Tile language (option B of 3 rendered directions):** every item's glyph
sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a
**soft color wash — one muted hue per menu section**:
Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` ·
Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`.
3. **Odin stays unique:** the only **solid** (gradient) tile and the only
animated icon. No animation anywhere else. Red stays reserved for
Odin/primary.
4. **Glyph set** approved on the full-set screen: receipt for Faktury,
business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování,
ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for
Inventura, shelf rack for Lokace, arrows into/out of a box for
Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for
Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti,
timesheet for Moje historie (docházka), shapes for Kategorie, chart frame
for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique
in the set): clock (Záznam docházky), calendar (Plán prací), people
(Uživatelé), gear (Nastavení), box (Položky).
## Implementation
- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark
mains, full main/light/dark/contrastText so cssVariables emits `*Channel`
tokens) + TS module augmentation.
- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs
(stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for
Odin (its icon ships its own tile).
- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor:
rgba(<hue>.mainChannel / 0.12)`, glyph color `<hue>.main`, svg 16 px)
around every non-`rawIcon` item. No hardcoded hex; washes are
channel-alpha so both schemes work.
- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique
static markup (no two items may ever share a glyph again).
## Out of scope
Animation for non-Odin icons (explicitly rejected), page-level icons,
mobile drawer changes (it reuses SidebarNav).

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.31",
"version": "2.4.35",
"description": "",
"main": "dist/server.js",
"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
user_agent String? @db.Text
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([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`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
const adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",

View File

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

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { isValidElement, type ReactElement } from "react";
import { menuSections, type MenuItem } from "../admin/ui/navData";
/**
* Pins the sidebar icon-refresh contract (2026-06-12 design): every menu
* item has a glyph, and no two items share one — duplicated glyphs were the
* whole problem the refresh fixed (3× "people", 3× "sliders", …). Odin's
* rawIcon (OdinMark, MUI-styled + animated) is exempt from rendering here
* but still must exist and stay the ONLY rawIcon.
*/
const allItems: Array<MenuItem & { section: string }> = menuSections.flatMap(
(s) => s.items.map((item) => ({ section: s.label, ...item })),
);
describe("sidebar nav icons", () => {
it("every item has an icon and a section hue", () => {
for (const section of menuSections) {
expect(section.hue, `section ${section.label}`).toBeTruthy();
}
for (const item of allItems) {
expect(isValidElement(item.icon), `${item.section}/${item.label}`).toBe(
true,
);
}
});
it("Odin is the only rawIcon (its own tile + animation stay unique)", () => {
const raw = allItems.filter((i) => i.rawIcon);
expect(raw.map((i) => i.label)).toEqual(["Odin"]);
});
it("no two items share a glyph", () => {
const rendered = allItems
.filter((i) => !i.rawIcon)
.map((item) => ({
key: `${item.section}/${item.label}`,
markup: renderToStaticMarkup(item.icon as ReactElement),
}));
const seen = new Map<string, string>();
for (const { key, markup } of rendered) {
const dupe = seen.get(markup);
expect(dupe, `${key} shares its glyph with ${dupe}`).toBeUndefined();
seen.set(markup, key);
}
expect(seen.size).toBe(rendered.length);
});
});

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 () => {
app = await buildApp();

View File

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

View File

@@ -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 <LoadingState />;

View File

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

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 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

View File

@@ -43,9 +43,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => {
const timeouts = timeoutsRef.current;
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current.clear();
timeouts.forEach(clearTimeout);
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,
Project,
User,
} from "../components/ShiftFormModal";
} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
@@ -234,7 +234,6 @@ function computeUserTotals(
}
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) {
const t = totals[uid];
@@ -345,14 +344,25 @@ function escapeHtml(str: string): string {
.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) {
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,
@@ -375,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—");
}
function buildUserSectionHtml(
export function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
userData: UserTotal,
printData: PrintData,
): string {
// 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 || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
@@ -392,7 +402,7 @@ function buildUserSectionHtml(
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = (userData.records || []) as Record<string, any>[];
const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
@@ -476,14 +486,12 @@ function buildUserSectionHtml(
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
let vacationHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
@@ -492,7 +500,6 @@ function buildUserSectionHtml(
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
@@ -592,8 +599,8 @@ function buildUserSectionHtml(
</div>`;
}
function buildPrintHtml(
pData: Record<string, any>,
export function buildPrintHtml(
pData: PrintData,
userSections: string,
emptyMsg: string,
filterNote: string,
@@ -1334,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<string, any>, pData),
buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =

View File

@@ -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<GridData>(key as any);
return qc.getQueryData<GridData>(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<GridData>(ctx.key as any);
const prev = qc.getQueryData<GridData>(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<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(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<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(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<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(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<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(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 */
}

View File

@@ -92,7 +92,7 @@ async function performMutation<TIn, TOut>(opts: {
return result.data as TOut;
}
export interface ApiMutationOptions<TIn, TOut> {
export interface ApiMutationOptions<TIn> {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
/** 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.
*/
export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
opts: ApiMutationOptions<TIn> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;

View File

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

View File

@@ -1,5 +1,5 @@
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 Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
@@ -9,7 +9,6 @@ import { iconBadgeSx } from "../theme";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
@@ -201,7 +200,6 @@ const renderProjectCell = (record: AttendanceRecord) => {
export default function AttendanceHistory() {
const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
@@ -212,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("-");

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react";
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { orderDetailOptions, type OrderItem } from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -128,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);
};
}, []);
@@ -389,7 +383,17 @@ export default function OrderDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of overflowing the viewport (same as Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/orders"
@@ -399,9 +403,22 @@ export default function OrderDetail() {
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Objednávka {order.order_number}
Objednávka
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{order.order_number}
</Box>
</Typography>
<StatusChip
label={statusLabel(ORDER_STATUS, order.status)}

View File

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

View File

@@ -11,7 +11,6 @@ import MenuItem from "@mui/material/MenuItem";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
@@ -298,7 +297,17 @@ export default function ProjectDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of being squeezed beside it (mirrors Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/projects"
@@ -308,9 +317,22 @@ export default function ProjectDetail() {
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Projekt {project.project_number}
Projekt
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{project.project_number}
</Box>
</Typography>
<StatusChip
label={STATUS_LABELS[project.status] || project.status}

View File

@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(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 &&

View File

@@ -715,7 +715,25 @@ export default function Settings() {
</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 = ({
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<string, any>][]
] as [string, NasInfo | undefined][]
).map(([label, info]) => (
<Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

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

View File

@@ -23,7 +23,7 @@ interface InventoryItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
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 {
useParams,
useNavigate,
@@ -65,7 +65,7 @@ interface BatchesResponse {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -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<typeof buildPayload>,

View File

@@ -50,7 +50,7 @@ interface MovementItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -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<ReceiptForm>({
supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
}
formInitialized.current = true;
}
}, [isEdit, receipt]);
}, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -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<string, unknown>;
/** 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");
});
});

View File

@@ -1,4 +1,9 @@
import { createTheme, type Theme } from "@mui/material/styles";
import {
createTheme,
type Theme,
type PaletteColor,
type PaletteColorChannel,
} from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
@@ -33,6 +38,26 @@ export const theme = createTheme({
warning: { main: "#b45309", contrastText: "#fff" },
error: { main: "#b91c1c", contrastText: "#fff" },
info: { main: "#1d4ed8", contrastText: "#fff" },
// Sidebar section hues (full objects so cssVariables emits *Channel
// tokens — the nav tiles use channel-alpha washes).
teal: {
main: "#0e8a7c",
light: "#3aa99c",
dark: "#0a675d",
contrastText: "#fff",
},
violet: {
main: "#6a4cb4",
light: "#8a6fd0",
dark: "#54399a",
contrastText: "#fff",
},
slate: {
main: "#5c6470",
light: "#7d8694",
dark: "#454c56",
contrastText: "#fff",
},
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
@@ -46,6 +71,25 @@ export const theme = createTheme({
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
error: { main: "#ef4444", contrastText: "#1a1a1a" },
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
// Sidebar section hues — brighter mains so the glyphs read on dark.
teal: {
main: "#2dd4bf",
light: "#5eead4",
dark: "#14b8a6",
contrastText: "#1a1a1a",
},
violet: {
main: "#a78bfa",
light: "#c4b5fd",
dark: "#8b5cf6",
contrastText: "#1a1a1a",
},
slate: {
main: "#94a3b8",
light: "#cbd5e1",
dark: "#64748b",
contrastText: "#1a1a1a",
},
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
@@ -239,6 +283,24 @@ export const fonts = {
mono: FONT_MONO,
};
// Custom sidebar-section palette entries (teal/violet/slate above). The
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
// the nav tiles build their alpha washes.
type NavPaletteColor = PaletteColor & PaletteColorChannel;
declare module "@mui/material/styles" {
interface Palette {
teal: NavPaletteColor;
violet: NavPaletteColor;
slate: NavPaletteColor;
}
interface PaletteOptions {
teal?: PaletteOptions["primary"];
violet?: PaletteOptions["primary"];
slate?: PaletteOptions["primary"];
}
}
export type IconBadgeColor =
| "default"
| "primary"

View File

@@ -88,9 +88,8 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
sx={{
borderRadius: 2,
mb: 0.25,
py: 0.6,
py: 0.45,
color: "text.secondary",
"& svg": { width: 18, height: 18 },
"&.Mui-selected, &.Mui-selected:hover": {
bgcolor: "background.paper",
color: "primary.main",
@@ -99,8 +98,29 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
},
}}
>
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
{item.icon}
<ListItemIcon sx={{ minWidth: 36, color: "inherit" }}>
{item.rawIcon ? (
item.icon
) : (
<Box
sx={(t) => ({
width: 26,
height: 26,
// Echoes OdinMark's rounded-square tile; only Odin
// is solid + animated, these are quiet washes.
borderRadius: "32%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
bgcolor: `rgba(${t.vars!.palette[section.hue].mainChannel} / 0.12)`,
color: t.vars!.palette[section.hue].main,
"& svg": { width: 16, height: 16 },
})}
>
{item.icon}
</Box>
)}
</ListItemIcon>
<ListItemText
primary={item.label}

View File

@@ -1,6 +1,15 @@
import { type ReactNode } from "react";
import OdinMark from "../components/odin/OdinMark";
/** Palette key driving a section's tile wash + glyph color (see theme.ts). */
export type NavHue =
| "primary"
| "info"
| "teal"
| "warning"
| "violet"
| "slate";
export interface MenuItem {
path: string;
label: string;
@@ -10,33 +19,51 @@ export interface MenuItem {
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
/** Icon ships its own container (OdinMark) — SidebarNav skips the tile. */
rawIcon?: boolean;
}
export interface MenuSection {
label: string;
hue: NavHue;
items: MenuItem[];
}
/**
* One stroke glyph per item, every glyph unique within the set (pinned by
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
* 16px inside SidebarNav's 26px section-hue tile.
*/
function glyph(children: ReactNode) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export const menuSections: MenuSection[] = [
{
label: "Přehled",
hue: "primary",
items: [
{
path: "/",
label: "Přehled",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
// tachometr — dashboard
icon: glyph(
<>
<circle cx="12" cy="13" r="8.5" />
<path d="M12 13l4-4.5" />
</>,
),
},
{
@@ -44,78 +71,66 @@ export const menuSections: MenuSection[] = [
label: "Odin",
permission: "ai.use",
end: true,
icon: <OdinMark size={22} />,
icon: <OdinMark size={26} />,
rawIcon: true,
},
],
},
{
label: "Docházka",
hue: "info",
items: [
{
path: "/attendance",
label: "Záznam",
permission: "attendance.record",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// píchačky — hodiny
icon: glyph(
<>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 15" />
</svg>
</>,
),
},
{
path: "/attendance/history",
label: "Moje historie",
permission: "attendance.history",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
// výkaz s hodinami — timesheet
icon: glyph(
<>
<path d="M14 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h6" />
<line x1="7" y1="8" x2="13" y2="8" />
<line x1="7" y1="12" x2="11" y2="12" />
<circle cx="16.5" cy="16" r="4.5" />
<polyline points="16.5 14 16.5 16 18 17" />
</>,
),
},
{
path: "/attendance/requests",
label: "Žádosti",
permission: "attendance.record",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
// odeslaná žádost — vlaštovka
icon: glyph(
<>
<path d="M22 2L11 13" />
<path d="M22 2l-7 20-4-9-9-4z" />
</>,
),
},
{
path: "/attendance/approval",
label: "Schvalování",
permission: "attendance.approve",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
// razítko
icon: glyph(
<>
<path d="M9.5 12V9.8C9.5 8.4 8 7.8 8 6a4 4 0 0 1 8 0c0 1.8-1.5 2.4-1.5 3.8V12" />
<path d="M5 16a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2H5z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
),
},
{
@@ -124,147 +139,108 @@ export const menuSections: MenuSection[] = [
permission: "attendance.manage",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// osoba + fajfka
icon: glyph(
<>
<circle cx="9" cy="8" r="3.5" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M15.5 11.5l2 2 4-4" />
</>,
),
},
{
path: "/attendance/balances",
label: "Správa bilancí",
permission: "attendance.balances",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
// kniha plus/mínus — ruční korekce bilancí
icon: glyph(
<>
<rect x="4" y="3" width="16" height="18" rx="2" />
<line x1="9" y1="8.5" x2="15" y2="8.5" />
<line x1="12" y1="5.5" x2="12" y2="11.5" />
<line x1="9" y1="16" x2="15" y2="16" />
</>,
),
},
{
path: "/plan-work",
label: "Plán prací",
permission: ["attendance.manage", "attendance.record"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
// kalendář s plánovacím blokem
icon: glyph(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<line x1="3" y1="9.5" x2="21" y2="9.5" />
<path d="M8 14h3v3H8z" />
</>,
),
},
],
},
{
label: "Kniha jízd",
hue: "teal",
items: [
{
path: "/trips",
label: "Záznam",
permission: "trips.record",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="5" cy="18" r="3" />
<circle cx="19" cy="18" r="3" />
<path d="M5 18V12L8 5h8l3 7v6" />
<path d="M10 18h4" />
</svg>
// trasa A→B
icon: glyph(
<>
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="5" r="2" />
<path d="M5 17C5 11 19 13 19 7" />
</>,
),
},
{
path: "/trips/history",
label: "Moje historie",
permission: "trips.history",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
// navštívená místa — pin
icon: glyph(
<>
<path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z" />
<circle cx="12" cy="11" r="2.5" />
</>,
),
},
{
path: "/trips/admin",
label: "Správa",
permission: "trips.manage",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// schránka s fajfkou — kniha jízd
icon: glyph(
<>
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 2h6v4H9z" />
<path d="M9 13l2 2 4-4" />
</>,
),
},
{
path: "/vehicles",
label: "Vozidla",
permission: "vehicles.manage",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 3v5h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
// auto
icon: glyph(
<>
<path d="M5 17H3v-4l2-5h11l3 5h2v4h-2" />
<circle cx="7.5" cy="17" r="2" />
<circle cx="16.5" cy="17" r="2" />
<line x1="9.5" y1="17" x2="14.5" y2="17" />
</>,
),
},
],
},
{
label: "Administrativa",
hue: "warning",
items: [
{
path: "/offers",
@@ -272,16 +248,12 @@ export const menuSections: MenuSection[] = [
permission: "offers.view",
matchPrefix: "/offers",
matchExclude: ["/offers/customers", "/offers/templates"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
// cenovka
icon: glyph(
<>
<path d="M20.6 13.4L11 3H4v7l9.6 10.4a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 0 0-2.8z" />
<circle cx="8" cy="8" r="1.5" />
</>,
),
},
{
@@ -289,17 +261,13 @@ export const menuSections: MenuSection[] = [
label: "Objednávky",
permission: "orders.view",
matchPrefix: "/orders",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
// košík
icon: glyph(
<>
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
<path d="M2 3h3l2.7 12h10.6l2.7-9H6" />
</>,
),
},
{
@@ -307,16 +275,13 @@ export const menuSections: MenuSection[] = [
label: "Faktury",
permission: "invoices.view",
matchPrefix: "/invoices",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
// účtenka
icon: glyph(
<>
<path d="M6 2h12v20l-3-2-3 2-3-2-3 2z" />
<line x1="9" y1="7" x2="15" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</>,
),
},
{
@@ -324,60 +289,44 @@ export const menuSections: MenuSection[] = [
label: "Projekty",
permission: "projects.view",
matchPrefix: "/projects",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
// složka (NAS projektové složky)
icon: glyph(
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />,
),
},
{
path: "/offers/customers",
label: "Zákazníci",
permission: "customers.view",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
// vizitka
icon: glyph(
<>
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="8.5" cy="10.5" r="2.2" />
<path d="M5 16.5a3.5 3.5 0 0 1 7 0" />
<line x1="14.5" y1="9.5" x2="19" y2="9.5" />
<line x1="14.5" y1="13.5" x2="19" y2="13.5" />
</>,
),
},
],
},
{
label: "Sklad",
hue: "violet",
items: [
{
path: "/warehouse",
label: "Přehled",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
// skladová hala
icon: glyph(
<>
<path d="M3 21V9l9-5 9 5v12" />
<path d="M7 21v-7h10v7" />
<line x1="7" y1="17.5" x2="17" y2="17.5" />
</>,
),
},
{
@@ -385,17 +334,13 @@ export const menuSections: MenuSection[] = [
label: "Položky",
permission: "warehouse.view",
matchPrefix: "/warehouse/items",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// krabice
icon: glyph(
<>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
</>,
),
},
{
@@ -403,17 +348,13 @@ export const menuSections: MenuSection[] = [
label: "Příjmy",
permission: "warehouse.operate",
matchPrefix: "/warehouse/receipts",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
<path d="M5 19h14" />
</svg>
// šipka do krabice
icon: glyph(
<>
<rect x="4" y="12" width="16" height="9" rx="1" />
<polyline points="8 6 12 10 16 6" />
<line x1="12" y1="2" x2="12" y2="10" />
</>,
),
},
{
@@ -421,17 +362,13 @@ export const menuSections: MenuSection[] = [
label: "Výdeje",
permission: "warehouse.operate",
matchPrefix: "/warehouse/issues",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
<path d="M5 5h14" />
</svg>
// šipka z krabice ven
icon: glyph(
<>
<rect x="4" y="13" width="16" height="8" rx="1" />
<polyline points="8 6 12 2 16 6" />
<line x1="12" y1="2" x2="12" y2="11" />
</>,
),
},
{
@@ -439,18 +376,12 @@ export const menuSections: MenuSection[] = [
label: "Rezervace",
permission: "warehouse.operate",
matchPrefix: "/warehouse/reservations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
// krabice se záložkou
icon: glyph(
<>
<rect x="3" y="8" width="13" height="12" rx="1" />
<path d="M16 3h5v9l-2.5-2L16 12z" />
</>,
),
},
{
@@ -458,16 +389,15 @@ export const menuSections: MenuSection[] = [
label: "Inventura",
permission: "warehouse.inventory",
matchPrefix: "/warehouse/inventory",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
// čárový kód — fyzický sken zásob
icon: glyph(
<>
<line x1="4" y1="6" x2="4" y2="18" />
<line x1="8" y1="6" x2="8" y2="18" />
<line x1="12" y1="6" x2="12" y2="18" />
<line x1="17" y1="6" x2="17" y2="18" />
<line x1="20" y1="6" x2="20" y2="18" />
</>,
),
},
{
@@ -475,17 +405,12 @@ export const menuSections: MenuSection[] = [
label: "Reporty",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
// graf v rámu
icon: glyph(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<polyline points="7 14 11 10 14 13 17 8" />
</>,
),
},
{
@@ -493,23 +418,14 @@ export const menuSections: MenuSection[] = [
label: "Kategorie",
permission: "warehouse.manage",
matchPrefix: "/warehouse/categories",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// tvary — třídění do kategorií
icon: glyph(
<>
<circle cx="7.5" cy="7.5" r="3.5" />
<rect x="13.5" y="4" width="7" height="7" rx="1" />
<path d="M7.5 13.5l3.5 7h-7z" />
<rect x="13.5" y="13.5" width="7" height="7" rx="3.5" />
</>,
),
},
{
@@ -517,18 +433,16 @@ export const menuSections: MenuSection[] = [
label: "Lokace",
permission: "warehouse.manage",
matchPrefix: "/warehouse/locations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
// regál s krabicemi
icon: glyph(
<>
<path d="M3 21V4" />
<path d="M21 21V4" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="21" x2="21" y2="21" />
<rect x="7" y="8" width="4" height="4" />
<rect x="13" y="17" width="4" height="4" />
</>,
),
},
{
@@ -536,41 +450,34 @@ export const menuSections: MenuSection[] = [
label: "Dodavatelé",
permission: "warehouse.manage",
matchPrefix: "/warehouse/suppliers",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
// kamion
icon: glyph(
<>
<rect x="1" y="6" width="13" height="10" rx="1" />
<path d="M14 9h4l3 4v3h-7" />
<circle cx="6" cy="18.5" r="2" />
<circle cx="17.5" cy="18.5" r="2" />
</>,
),
},
],
},
{
label: "Systém",
hue: "slate",
items: [
{
path: "/users",
label: "Uživatelé",
permission: "users.view",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// lidé — uživatelské účty
icon: glyph(
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</>,
),
},
{
@@ -583,35 +490,27 @@ export const menuSections: MenuSection[] = [
"settings.system",
"settings.templates",
],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// ozubené kolo
icon: glyph(
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</>,
),
},
{
path: "/audit-log",
label: "Audit log",
permission: "settings.audit",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
// lupa nad záznamy
icon: glyph(
<>
<path d="M4 5h12" />
<path d="M4 10h6" />
<path d="M4 15h5" />
<circle cx="15" cy="14" r="4.5" />
<line x1="18.2" y1="17.2" x2="21.5" y2="20.5" />
</>,
),
},
],

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

@@ -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<ViteDevServerLike>;
};
const viteModule = await (Function(
'return import("vite")',
)() as Promise<any>);
const createViteServer = viteModule.createServer as (
opts: any,
) => Promise<any>;
const vite = await createViteServer({
)() as Promise<ViteModuleLike>);
const vite = await viteModule.createServer({
server: { middlewareMode: true },
appType: "spa",
});

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import crypto from "crypto";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { FastifyRequest, FastifyReply } from "fastify";
import { FastifyRequest } from "fastify";
import prisma from "../config/database";
import { config } from "../config/env";
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).
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);
}

View File

@@ -115,12 +115,29 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
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<T extends EnrichableQuotation>(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<string, any>) {
export async function createOffer(body: Record<string, unknown>) {
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<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 } });
if (!existing) return { error: "not_found" as const };

View File

@@ -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<T extends EnrichableOrder>(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,
);

View File

@@ -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<typeof prisma.work_plan_overrides.create>
>;
/**
* 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<Result<any>> {
): Promise<Result<WorkPlanOverride>> {
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<Result<any>> {
): Promise<Result<WorkPlanOverride>> {
const existing = await prisma.work_plan_overrides.findUnique({
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 } });
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 sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where: any = search
const where: Prisma.usersWhereInput = search
? {
OR: [
{ 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.

View File

@@ -4,6 +4,7 @@
"module": "commonjs",
"lib": ["ES2022"],
"types": ["node", "vitest/globals"],
"jsx": "react-jsx",
"moduleResolution": "node",
"noEmit": true,
"strict": true,