Compare commits
69 Commits
v2.4.20
...
875ef4d79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875ef4d79e | ||
|
|
a679f6e8ac | ||
|
|
3530dd247c | ||
|
|
996012e3d3 | ||
|
|
3cd7cfcc34 | ||
|
|
68d9c224bc | ||
|
|
200aa0e1a8 | ||
|
|
e3b414d22c | ||
|
|
89656ac2c6 | ||
|
|
94030230e7 | ||
|
|
d859b5bbf7 | ||
|
|
5683912b76 | ||
|
|
3ec512faf1 | ||
|
|
9f9f359acb | ||
|
|
593dfc356d | ||
|
|
0172c9a442 | ||
|
|
81b4cb51e7 | ||
|
|
a274a49e90 | ||
|
|
9c192e79e7 | ||
|
|
4d0ec53514 | ||
|
|
87e644eef8 | ||
|
|
6428044624 | ||
|
|
67ddfb2d5d | ||
|
|
81293ae543 | ||
|
|
237ebf3ef8 | ||
|
|
983a1408f1 | ||
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 | ||
|
|
4674ff3389 | ||
|
|
4bb7de7247 | ||
|
|
719d1f2659 | ||
|
|
3787cc4dd0 | ||
|
|
1ee59b54bc | ||
|
|
907ee574e6 | ||
|
|
b51ea2505e | ||
|
|
fd2e613aa5 | ||
|
|
1f2aff8325 | ||
|
|
e11765bf0e | ||
|
|
8f74efba97 | ||
|
|
b9103c59f0 | ||
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 | ||
|
|
164ce54adf | ||
|
|
6877919133 | ||
|
|
5ec70599aa | ||
|
|
7e4469b29a | ||
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 | ||
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -21,5 +21,14 @@ dist-client/
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local Claude tooling (personal, not shared)
|
||||||
|
.claude/commands/
|
||||||
|
.claude/skills/
|
||||||
|
.claude/workflows/
|
||||||
|
CLAUDE-FABLE-5.md
|
||||||
|
|
||||||
|
# Personal scratch (planning docs, etc.)
|
||||||
|
simon/
|
||||||
|
|
||||||
# Superpowers brainstorm mockups
|
# Superpowers brainstorm mockups
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|||||||
334
AUDIT-2026-06-12.md
Normal file
334
AUDIT-2026-06-12.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Codebase Audit — boha-app-ts — 2026-06-12
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
| ------------ | ----: |
|
||||||
|
| **Critical** | 3 |
|
||||||
|
| **High** | 6 |
|
||||||
|
| **Medium** | 11 |
|
||||||
|
| **Low** | 8 |
|
||||||
|
| **Total** | 28 |
|
||||||
|
|
||||||
|
> 2026-06-12: M3 (offer PDF footer) withdrawn as a false positive after user
|
||||||
|
> verification — see the struck section below. Counts updated 12→11 / 29→28.
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
- **Fan-out discovery:** 27 independent finder passes swept the codebase across domain modules (auth/sessions, attendance/leave, documents — offers/orders/issued-orders/invoices, warehouse, projects, trips/vehicles, plan, users/roles, AI/Odin, settings) and cross-cutting dimensions (Zod-cap-vs-DB-column alignment, date/timezone boundaries, pagination determinism, React Query cache invalidation, transaction atomicity, FK-error→4xx mapping, privilege boundaries).
|
||||||
|
- **Dedupe vs prior backlog:** candidate findings were de-duplicated against the existing audit backlog (`docs/codebase-audit-findings-2026-06-06.md`, `docs/cross-module-consistency-audit-2026-06-09.md`, `REVIEW_FINDINGS.md`). Already-fixed items (e.g. the 2026-06-06 sessions audit-logging finding) and already-tracked duplicates were dropped; only net-new defects survived.
|
||||||
|
- **3-lens adversarial verification:** every surviving finding was independently re-checked through three lenses — (1) **trace-to-refute** (follow the full code path end-to-end and try to break each step), (2) **convention veto** (confirm it genuinely VIOLATES a documented `CLAUDE.md` convention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-time `toJSON`, local-noon `@db.Date` writes, lenient date schemas, net-only documents, gross `received_invoices.amount`, shared `orders.*` permission family), and (3) **executable repro** (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independent `real` verdicts. Findings the convention lens vetoed (e.g. one warehouse over-issue variant whose exact numbers are blocked by an upstream aggregate guard — kept only after a corrected, reachable repro was supplied) were re-scoped, not waved through.
|
||||||
|
|
||||||
|
Order below is by severity, then by blast radius within a severity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### C1. Deleting a vacation/sick attendance record never restores the leave balance — `vacation_used` stays permanently inflated
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/attendance.service.ts:1751`
|
||||||
|
- **Failure scenario:** `createLeave` increments `leave_balances.vacation_used`/`sick_used` when booking leave (lines 1304–1331), but `deleteAttendance` (1751–1772) only runs `prisma.attendance.delete` — it never decrements the balance. `getStatus` (line 246) and `getBalances` (line 530) derive `vacation_remaining = vacation_total - vacation_used` from the stored counter, so cancelling a booked leave by deleting its rows permanently removes entitlement the employee never actually took. The leave-request approval path (`leave-requests.ts`) has the identical asymmetric increment-on-approve / no-decrement-on-delete.
|
||||||
|
- **Repro:**
|
||||||
|
1. As an admin (`attendance.manage`), `POST /api/admin/attendance?action=leave` for user 5, `date_from=2026-07-06`, `date_to=2026-07-10`, `leave_type=vacation`, `leave_hours=8` → creates 5 rows and adds 40h to `vacation_used`.
|
||||||
|
2. Read balances: `vacation_used=40`, `vacation_remaining = total-40`.
|
||||||
|
3. `DELETE /api/admin/attendance/:id` for each of the 5 rows.
|
||||||
|
4. Read balances again: rows are gone but `vacation_used` is still 40 and `vacation_remaining` is still down 40h. Only a manual admin `handleBalances` reset fixes it.
|
||||||
|
- **Pinning test:** Book a 5-day vacation, assert `vacation_used` rose by 40h, delete all 5 attendance rows, then assert `vacation_used` returned to its pre-booking value (fails today: stays 40).
|
||||||
|
|
||||||
|
### C2. `confirmIssue`: duplicate issue lines on the same batch pass per-line validation but decrement cumulatively, driving batch quantity negative (silent over-issue)
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/warehouse.service.ts:626`
|
||||||
|
- **Failure scenario:** The confirm path validates and decrements stock **per line**, re-reading the batch fresh each iteration with no cross-line running tally and no `if (newBatchQty < 0) throw` guard. Two issue lines for the same item that resolve to the same batch each pass the independent `batch.quantity >= line.quantity` check, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes `3-5 = -2`, flagging `is_consumed=true`. The negative/consumed batch drops out of `getItemTotalStock` (`is_consumed:false` filter), so the over-issue is silently hidden from all stock totals. No `(issue_id, batch_id)` unique constraint exists on `sklad_issue_lines`.
|
||||||
|
- **Repro (corrected — the single-batch case is blocked by the per-item aggregate guard in `validateIssueReservationRules`; use two batches so the per-item total check passes while one batch still goes negative):**
|
||||||
|
1. Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16.
|
||||||
|
2. `POST /issues` with two lines, both `item_id=X`, no `batch_id`, each `quantity=5`. `selectFifoBatches` is called once per line against the live DB with nothing persisted, so both resolve to B1; the per-item availability check (16 < 10 is false) passes. Draft stores two lines both `batch_id=B1`.
|
||||||
|
3. `POST /issues/:id/confirm` → 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to `-2` with `is_consumed=true`.
|
||||||
|
4. 10 units issued from a batch that held 8; B1 persists at `quantity=-2`, drops from stock totals — 2 units leave with no accounting trail and FIFO is broken, with no error raised.
|
||||||
|
- **Pinning test:** Confirm an issue containing two same-item lines whose combined quantity exceeds a single resolved batch; assert the confirm is rejected (400) and no batch row is left with `quantity < 0`.
|
||||||
|
|
||||||
|
### C3. `confirmInventorySession` swallows an in-transaction throw with `return` instead of `throw`, committing partial stock writes the route reports as failed
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/warehouse.service.ts:1275`
|
||||||
|
- **Failure scenario:** The function body is `return prisma.$transaction(async (tx) => { ... })` (line 1108). A single loop over `session.items` interleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upserts `sklad_item_locations`; a later deficit line makes `selectFifoBatches` throw. The catch at line 1275 **returns** `{ error, status: 400 }` from inside the transaction callback. In an interactive Prisma transaction, returning a value (rather than throwing) **commits** — so the surplus item's writes persist while the session→CONFIRMED update (line 1293) is skipped, leaving the session DRAFT. The route sees `"error" in result` and returns HTTP 400 ("nothing changed"). Re-confirming the still-DRAFT session re-runs the loop and re-creates the same surplus batch — repeatable phantom inventory and corrupted valuation on every retry.
|
||||||
|
- **Repro:**
|
||||||
|
1. ItemA exists; ItemB has a location row `quantity=10` but fewer unconsumed batches (documented location/batch drift at lines 1244–1250 — also reproducible by consuming batches between session create and confirm).
|
||||||
|
2. `POST /admin/inventory-sessions` with items ordered `[{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}]` (ItemA's line gets the lower id, processed first).
|
||||||
|
3. `POST /admin/inventory-sessions/<id>/confirm` → HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumped `item_location`, and the session is still DRAFT.
|
||||||
|
4. Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure.
|
||||||
|
- **Pinning test:** Confirm an inventory session whose item list has a valid surplus line before an over-deficit line; assert the response is an error AND no `sklad_receipts`/`sklad_batches` rows were created (transaction rolled back) and the session is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### H1. Terminating one session force-logs-out ALL of the user's sessions on the terminated device's next refresh
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/services/auth.ts:280`
|
||||||
|
- **Failure scenario:** Session termination (`sessions.ts:94-97` DELETE `/:id`, `sessions.ts:124-131` action=all, `profile.ts:98-101` password-change) sets ONLY `replaced_at` (no `replaced_by_hash`, row not deleted). Legitimate rotation (`auth.ts:315-318`) sets BOTH. The reuse-detection condition at `auth.ts:280-282` is `storedToken.replaced_at || storedToken.replaced_by_hash`, evaluated BEFORE the expiry check at line 292 — so a manually-terminated token presented on a later refresh is misclassified as theft and runs `tx.refresh_tokens.deleteMany({ where: { user_id } })` (line 285), wiping every session for that user, including the laptop the user is actively on, plus a false "reuse detected" warning.
|
||||||
|
- **Repro:**
|
||||||
|
1. Log in from two devices → Session A (laptop), Session B (phone), both `replaced_at=null`.
|
||||||
|
2. From the laptop, `DELETE /api/admin/sessions/<B.id>` → B gets `replaced_at` set only.
|
||||||
|
3. After B's access token expires (~15 min), the phone calls `POST /api/admin/refresh` with B's still-present cookie.
|
||||||
|
4. `refreshAccessToken` hits the reuse branch (B's `replaced_at` truthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh.
|
||||||
|
- **Pinning test:** Create two refresh-token sessions, terminate one via the sessions DELETE route, present the terminated token to `refreshAccessToken`; assert the OTHER session's row still exists (fails today: it is deleted).
|
||||||
|
|
||||||
|
### H2. `order_items.description` Zod cap (8000) far exceeds DB column `VarChar(500)` → Prisma 500 on long descriptions
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:12`
|
||||||
|
- **Failure scenario:** `OrderItemSchema.description = z.string().max(8000)` but `order_items.description` is `@db.VarChar(500)` (`schema.prisma:327`). `createOrder`/`updateOrder` map the value straight into `tx.order_items.createMany` with no truncation. A 501–8000 char description passes Zod, overflows the column under MySQL strict mode (P2000), and `createOrder`'s catch only re-maps errors carrying a `status` property — so it surfaces as HTTP 500 "Interní chyba serveru" instead of a clean Czech 400, with the whole order transaction rolled back.
|
||||||
|
- **Repro:** `POST /api/admin/orders` (Content-Type application/json so the manual-JSON branch runs), `items:[{ description: "x".repeat(600), quantity:1, unit_price:100 }]` → HTTP 500 instead of 400; no order persisted. Same on `PUT /api/admin/orders/:id` for an order in `prijata`/`v_realizaci`.
|
||||||
|
- **Pinning test:** `parseBody(OrderItemSchema, { description: "x".repeat(600), ... })` should fail Zod with a 400-class error (fails today: passes, then 500s at Prisma). Fix: `.max(500)`.
|
||||||
|
|
||||||
|
### H3. Issued-order status transition silently discards unsaved item/section edits and marks them clean
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/pages/IssuedOrderDetail.tsx:524`
|
||||||
|
- **Failure scenario:** A `sent` issued order is both editable AND has valid transitions. `handleStatusChange` (line 516) sends ONLY `{ status: newStatus }`; the server's `replaceItems = editable && Array.isArray(body.items)` is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 calls `markClean({ form, items, sections })` with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard so `isDirty` flips false and the `beforeunload` warning never fires. After the transition the order is `confirmed` → read-only, so the lost edits cannot be re-applied. (Contrast: `OfferDetail`/`InvoiceDetail` do NOT call `markClean` on transition.)
|
||||||
|
- **Repro:** Open a `sent` issued order with `orders.edit`, edit a line item / section / supplier (form dirty), click "Potvrdit" and confirm. The transition succeeds, the edits are silently dropped, and on refetch the items/sections revert to stored values with no warning.
|
||||||
|
- **Pinning test:** Render `IssuedOrderDetail` for a `sent` order, mutate an item, invoke a status transition; assert the PUT payload includes the edited `items` (or that the guard stays dirty / a warning is shown) — fails today.
|
||||||
|
|
||||||
|
### H4. Received-invoices list is paginated server-side (25/page) but the UI sends no `page` param and renders no Pagination control — rows 26+ are permanently hidden
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/pages/ReceivedInvoices.tsx:265`
|
||||||
|
- **Failure scenario:** `receivedInvoiceListOptions` hits `/received-invoices`, which applies `skip`/`take` with `parsePagination`'s default limit of 25. The component builds the query with no `page`/`perPage`, reads only `listQuery.data?.data` (the first 25 rows), and renders NO `<Pagination>`. The "Celkem" footer is sourced from the separate `/received-invoices/list-totals` over the FULL filtered set, so with 26+ rows the displayed total visibly disagrees with the on-screen rows. The sibling issued-invoices tab (`Invoices.tsx`) correctly uses `usePaginatedQuery` + `<Pagination>`.
|
||||||
|
- **Repro:** Seed 26 CZK received invoices in one month. `GET /received-invoices?month=6&year=2026` returns `data.length=25`, `pagination.total=26`; `GET /received-invoices/list-totals?...` returns 26000 CZK. In the UI (Faktury → Přijaté, June 2026) the table shows 25 rows summing to 25000 while the footer shows 26000 — and the 26th invoice is unreachable.
|
||||||
|
- **Pinning test:** With 26 received invoices in a month, assert the page requests page 2 (or renders a pager) and that all 26 are reachable — fails today.
|
||||||
|
|
||||||
|
### H5. Odin invoice import: Czech-format dates silently corrupt month/year (wrong month or NaN) in `received_invoices`
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/components/odin/InvoiceReviewCard.tsx:79`
|
||||||
|
- **Failure scenario:** The review card exposes "Datum vystavení"/"Datum splatnosti" as free-text fields; `OdinChat.saveInvoice` sends the raw string. The multipart schema validates `issue_date` only as `z.string().max(255).nullish()`, so it passes. The route derives `invoiceMonth = new Date(issue_date).getMonth()+1` / `getFullYear()` with no validity guard. `new Date("15.03.2026")` → Invalid Date → `NaN` (day>12); `new Date("12.6.2026")` → December 2026 (wrong month). NAS save (line 410) runs BEFORE the DB create (line 420), so for the NaN case Prisma rejects into the `UnsignedTinyInt`/`UnsignedSmallInt` columns → HTTP 500 with an orphaned NAS file; for day≤12 it silently files under the wrong month/year.
|
||||||
|
- **Repro:** In Odin, edit an extracted invoice's issue date to `15.03.2026` and Save → 500 + orphaned NAS file. Use `12.6.2026` → invoice silently filed under month 12. Equivalent: `POST /api/admin/received-invoices` (multipart) with `issue_date:"15.03.2026"`.
|
||||||
|
- **Pinning test:** `POST /received-invoices` with `issue_date="15.03.2026"` should return a 400 (or normalize) — fails today (500 + NaN write). Fix: use `isoDateString` coercion / validity guard.
|
||||||
|
|
||||||
|
### H6. Leave-request approval charges vacation/sick balance for Czech public holidays that fall inside the range
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/routes/admin/leave-requests.ts:222`
|
||||||
|
- **Failure scenario:** Both the request-creation loop (lines 90–97) and the approval loop (lines 222–244) count business days with a weekend-only filter (`dow !== 0 && dow !== 6`); the file never imports `isHoliday`. So weekday Czech public holidays inside the range are counted as charged leave days, and approval increments `vacation_used`/`sick_used` by `totalBusinessDays * 8` including them. The sibling `attendance.service.createLeave` (line 1270) correctly skips `isHoliday(...)`, and `getBusinessDaysInMonth` excludes holidays from the work fund — so the deduction is a double charge for a day already paid/free.
|
||||||
|
- **Repro:** Employee `POST /api/admin/leave-requests` `{leave_type:"vacation", date_from:"2026-05-01", date_to:"2026-05-09"}` (1.5. and 8.5. are weekday holidays in 2026) → `total_hours=48`. Approver `PUT /.../{id}` `{status:"approved"}` → `vacation_used` grows by 48h instead of 32h; holiday dates are recorded as consumed vacation in `attendance`.
|
||||||
|
- **Pinning test:** Approve a vacation request spanning a weekday public holiday; assert `vacation_used` increased only by the non-holiday business hours (fails today: includes the holiday).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### M1. Invoice date fields accept arbitrary strings and write a day early to `@db.Date` when a datetime value is submitted
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/invoices.service.ts:533`
|
||||||
|
- **Failure scenario:** `CreateInvoiceSchema`/`UpdateInvoiceSchema` type `issue_date`/`due_date`/`tax_date`/`paid_date` as plain `z.string().max(255).nullish()` (not `isoDateString`). `createInvoice`/`updateInvoice` do `new Date(String(...))` straight into the `@db.Date` columns. A round-tripped API value like `"2025-03-15T00:00:00"` (the `toJSON` override emits local time, no `Z`) parses as local Prague → `2025-03-14 22:00 UTC` → Prisma truncates to `2025-03-14`. The invoice then also lands in the wrong month bucket in `getInvoiceStats`/`buildInvoiceWhere`. Every other `@db.Date`-writing module uses `isoDateString` (which slices the time off); invoices is the lone outlier.
|
||||||
|
- **Repro:** `POST /api/admin/invoices` with `issue_date:"2025-03-01T00:00:00"` → DB stores `2025-02-28`; `GET .../stats?month=3&year=2025` excludes it and inflates February.
|
||||||
|
- **Pinning test:** Create an invoice with `issue_date="2025-03-01T00:00:00"`; assert the stored date is `2025-03-01` (fails today: `2025-02-28`). Fix: `isoDateString.nullish()`.
|
||||||
|
|
||||||
|
### M2. `order_items.unit` Zod cap (255) exceeds DB column `VarChar(20)` → Prisma 500 on long unit strings
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:15`
|
||||||
|
- **Failure scenario:** `unit: z.string().max(255)` vs `order_items.unit @db.VarChar(20)` (`schema.prisma:330`). A 21–255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-maps `status`-bearing errors; update has no try/catch). The sibling offers/issued-orders schemas correctly use `.max(20)`.
|
||||||
|
- **Repro:** `POST /api/admin/orders` (JSON) `items:[{ description:"Test", quantity:1, unit_price:100, unit:"abcdefghijklmnopqrstuvwxyz" }]` → HTTP 500 instead of 400. (The MUI form enforces `maxLength:20` client-side, so this is an API/non-browser vector.)
|
||||||
|
- **Pinning test:** `parseBody(OrderItemSchema, { unit: "x".repeat(21), ... })` should 400 at Zod — fails today. Fix: `.max(20)`.
|
||||||
|
|
||||||
|
### ~~M3. Offer PDF page footer ("Strana X z Y") never renders~~ — WITHDRAWN 2026-06-12 (false positive)
|
||||||
|
|
||||||
|
- **Status:** **Withdrawn** — user verified the footer DOES render in production.
|
||||||
|
- **Why the audit got it wrong:** the finding (and all three verification lenses) trusted the repo's own documentation — the `html-to-pdf.ts:68-74` comment and the CLAUDE.md line "Chromium has no CSS margin-box footers". That fact has been stale since **Chrome 131 (Nov 2024), which implemented CSS `@page` margin boxes**; the project runs Puppeteer 24.40 (bundled Chrome 146 locally) and prod renders with system Chromium 149, both far past 131, so the offer's `@bottom-center { counter(page) }` footer renders fine. No lens rendered an actual PDF — documentation poisoning, not a code bug.
|
||||||
|
- **Real follow-up (doc-only, Low):** correct the outdated claim in `html-to-pdf.ts` and CLAUDE.md so future work doesn't repeat this; the `footerTemplate` mechanism and the CSS margin-box mechanism are now BOTH valid, coexisting approaches.
|
||||||
|
|
||||||
|
### M4. `customer_order_number` Zod cap (255) exceeds DB column `VarChar(100)` → Prisma 500
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:54`
|
||||||
|
- **Failure scenario:** `customer_order_number: z.string().max(255)` vs `orders.customer_order_number @db.VarChar(100)` (`schema.prisma:356`). A 101–255 char value passes Zod, overflows the column (P2000 under strict mode), and surfaces as 500 instead of 400 (no P2003/P2000 mapping; create catch only handles `status`-bearing errors).
|
||||||
|
- **Repro:** `POST /api/admin/orders` (JSON) `{ customer_order_number: "A".repeat(150), currency:"CZK", language:"cs", status:"prijata" }` → HTTP 500; no order created. Same on `PUT /api/admin/orders/:id`.
|
||||||
|
- **Pinning test:** `parseBody(CreateOrderSchema, { customer_order_number: "A".repeat(150), ... })` should 400 — fails today. Fix: `.max(100)` on both schemas.
|
||||||
|
|
||||||
|
### M5. Foreign-currency invoice PDF returns HTTP 500 (and fails to archive) when the CNB rate lookup throws
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/invoices-pdf.ts:411`
|
||||||
|
- **Failure scenario:** For a EUR invoice, `const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0` is unguarded. When CNB is unreachable and no cached entry exists for the issue date, `getRate` throws "Nepodařilo se získat aktuální kurzy z ČNB", which propagates to the route's catch and returns the 500 error page — blocking BOTH preview and NAS archival, even though the rate only feeds the cosmetic CZK-conversion footnote. Sibling consumers (`received-invoices` stats, `ai-tools`) wrap `toCzk` and degrade per-row; the PDF path has no fallback.
|
||||||
|
- **Repro:** EUR invoice with a back-dated/never-fetched issue date, CNB unreachable → `GET /api/admin/invoices-pdf/:id` (preview) or `?save=1` (archive) returns 500; the `save=1` archival is silently swallowed client-side, leaving no NAS copy.
|
||||||
|
- **Pinning test:** Mock `getRate` to throw, render a foreign-currency invoice PDF; assert a non-error response (PDF rendered with the conversion line omitted) — fails today (500).
|
||||||
|
|
||||||
|
### M6. `updateProject` sets FK fields with no existence check → FK violation 500
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/projects.service.ts:115`
|
||||||
|
- **Failure scenario:** `updateProject` writes `customer_id`/`responsible_user_id`/`quotation_id`/`order_id` straight into `prisma.projects.update` with only int coercion and no `findUnique`. The FKs are `onDelete: Restrict`, so a non-existent id raises P2003 → generic 500. `createProject` validates `customer_id`/`responsible_user_id` and returns clean Czech 400s; the update path is the inconsistent gap.
|
||||||
|
- **Repro:** `PUT /api/admin/projects/1` `{ "customer_id": 999999 }` → HTTP 500 "Interní chyba serveru" instead of "Zákazník nenalezen" 400. Same for the other three FK fields.
|
||||||
|
- **Pinning test:** `PUT /projects/:id` with a non-existent `customer_id`; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirror `createProject`'s existence checks.
|
||||||
|
|
||||||
|
### M7. Date-range filters/reports exclude the whole `date_to` day (`lte` against UTC-midnight) — inclusive end date silently drops same-day records
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/warehouse.ts:1038`
|
||||||
|
- **Failure scenario:** Receipts/issues filters and the project-consumption / movement-log reports build `created_at: { lte: new Date(date_to) }`. `new Date("2026-06-12")` parses as UTC midnight = 02:00 Prague; `created_at` is `@db.DateTime(0)` storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is `> 00:00Z` and excluded. The entire requested end day disappears from filtered lists and report totals.
|
||||||
|
- **Repro:** Confirm a receipt today at 09:00 Prague. `GET /admin/warehouse/receipts?date_from=2026-06-12&date_to=2026-06-12` returns zero rows. Reports for a period ending 2026-06-12 under-report the last day.
|
||||||
|
- **Pinning test:** Create a same-day receipt at 09:00 local, filter `date_to` = that date; assert the receipt is returned — fails today. Fix: half-open `lt utcMidnightOfLocalDay(date_to + 1 day)`.
|
||||||
|
|
||||||
|
### M8. POST/PUT receipts & issues 500 on nonexistent or deleted FK ids instead of a clean Czech 400
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/warehouse.ts:1117`
|
||||||
|
- **Failure scenario:** The receipts/issues create+update handlers write rows directly with no FK-existence pre-check and no try/catch; schemas only int-coerce ids. A deleted/nonexistent `supplier_id`/`location_id`/`item_id`/`project_id`/`batch_id` raises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) and `trips.ts` all pre-validate FK existence precisely to avoid this; warehouse omits the guard.
|
||||||
|
- **Repro:** `POST /receipts` `{ items:[{ item_id: 99999999, quantity:1, unit_price:10 }] }` → 500. `POST /issues` `{ project_id: 99999999, items:[{ item_id:<in-stock>, quantity:1 }] }` → 500. `POST /issues` with a valid item + nonexistent explicit `batch_id` → 500.
|
||||||
|
- **Pinning test:** `POST /receipts` with a nonexistent `item_id`; assert a 400 ("Položka nenalezena") — fails today (500).
|
||||||
|
|
||||||
|
### M9. `updateEntry` never re-checks the per-cell cap, so expanding an entry's range pushes a day past `MAX_RECORDS_PER_CELL` and silently hides existing records
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/plan.service.ts:597`
|
||||||
|
- **Failure scenario:** `createEntry`/`bulkCreateEntries`/`createOverride` all guard the per-cell cap of 3 via `assertEntryCapAvailable`, but `updateEntry` does not. Expanding an entry's `date_from`/`date_to` onto a day that already holds 3 active entries succeeds, producing 4. `resolveCell`/`resolveGrid` cap display at 3 (newest-first), so the oldest entry silently vanishes from the grid while remaining active in the DB.
|
||||||
|
- **Repro:** Create 3 active entries on 2099-07-01 for user 5; create a 4th on 2099-07-02; `PATCH /plan/entries/<4th>` `{ date_from:"2099-07-01", date_to:"2099-07-01" }` → 200; the grid for that day now shows only 3 of the 4 entries.
|
||||||
|
- **Pinning test:** Fill a day to the cap, then `PATCH` another entry to overlap that day; assert the update is rejected with the cap error — fails today.
|
||||||
|
|
||||||
|
### M10. `POST /users` does not strip `role_id` for non-admins — a `users.create` holder can grant any non-admin role
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/users.ts:63`
|
||||||
|
- **Failure scenario:** The POST handler forwards `role_id` straight to `createUser`; `createUser` only rejects the literal `admin` role. The PUT handler explicitly strips `role_id`/`is_active` for non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: a `users.create` holder can mint an account on any high-privilege non-admin role (e.g. one bundling `settings.roles` or `users.edit`) the caller itself lacks. Violates the documented "Role-management writes are admin-only and re-checked" rule.
|
||||||
|
- **Repro:** As account M with only `users.create`, `POST /api/admin/users` `{ ..., role_id: N }` where N is a non-admin role bundling `settings.roles`/`users.edit` → 201; the new account holds permissions M never had.
|
||||||
|
- **Pinning test:** As a non-admin `users.create` caller, POST a user with a privileged `role_id`; assert the created user's role is NOT the privileged one (role stripped, as on PUT) — fails today.
|
||||||
|
|
||||||
|
### M11. `route_from`/`route_to` Zod cap (255) exceeds DB column `VarChar(100)` — 500 or silent truncation
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/trips.schema.ts:17`
|
||||||
|
- **Failure scenario:** `route_from`/`route_to` cap at `.max(255)` (create lines 17–18, update 29–30) vs `trips.route_from/route_to @db.VarChar(100)` (`schema.prisma:668-669`). The route writes `String(body.route_from)` directly. A 101–255 char route passes Zod, then either 500s (P2000 strict mode, no global Prisma mapping) or silently truncates to 100 chars (data loss). The frontend TextFields have no `maxLength`, so the UI triggers it too.
|
||||||
|
- **Repro:** `POST /api/admin/trips` with a 150-char `route_from` → 500 (or truncated 201). Same on `PUT`.
|
||||||
|
- **Pinning test:** `parseBody(CreateTripSchema, { route_from: "x".repeat(101), ... })` should 400 — fails today. Fix: `.max(100)`.
|
||||||
|
|
||||||
|
### M12. Shared Puppeteer browser disconnecting mid-render fails concurrent PDF requests (no re-acquire/retry)
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/utils/html-to-pdf.ts:92`
|
||||||
|
- **Failure scenario:** `getBrowser()` returns the cached module-level browser after a point-in-time `connected` check; `htmlToPdf` immediately does `b.newPage()` with no re-check. Two concurrent renders capture the same handle; if Chromium crashes (OOM/killed) after the guard, both reject. A's finally nulls the shared handle out from under B. Both return 500 even though an immediate retry would relaunch and succeed — there is no re-acquire/retry wrapper.
|
||||||
|
- **Repro:** Fire two simultaneous `GET /api/admin/orders-pdf/5` (always renders, no NAS short-circuit), then `pkill -9 chrome` during the render window → both requests 500; the next request succeeds.
|
||||||
|
- **Pinning test:** Stub the cached browser so `connected` is true at guard time and false at `newPage` time; assert `htmlToPdf` re-acquires and resolves (or both concurrent calls succeed on retry) — fails today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### L1. Client-supplied TOTP secret up to 255 chars overflows `VarChar(255)` after encryption, yielding a 500 instead of a 400
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/schemas/auth.schema.ts:30`
|
||||||
|
- **Failure scenario:** `TotpEnableSchema.secret` caps at `.max(255)` on the plaintext, but the stored value is the AES-256-GCM ciphertext (`base64(IV[12] + ciphertext + tag[16])`), which exceeds 255 chars for any plaintext over ~164 chars. `users.totp_secret` is `@db.VarChar(255)`. The pre-write `totp.validate` does not bound secret length, and the attacker controls both `secret` and `code`, so a long secret reaches the write → P2000 → 500 (strict mode) or silent ciphertext truncation that permanently locks the account out.
|
||||||
|
- **Repro:** Authenticated, 2FA-not-enabled user: generate a 250-char base32 secret + matching TOTP code, `POST /api/admin/totp/enable` with the correct password → 500 (or corrupted secret).
|
||||||
|
- **Pinning test:** Enable TOTP with a 250-char secret; assert a 400 (not 500) — fails today. Fix: `.max(64)` (real secrets are 16–32 chars).
|
||||||
|
|
||||||
|
### L2. Offer number sequence is never released on delete when created and finalized in different calendar years
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/services/offers.service.ts:522`
|
||||||
|
- **Failure scenario:** `generateOfferNumber` keys on `new Date().getFullYear()` (finalize year), assigning e.g. `2026/NA/001`. `deleteOffer` derives the release year from `existing.created_at.getFullYear()` (2025) and calls `releaseOfferNumber(2025, "2026/NA/001")`; `releaseSequence` reconstructs the highest as `2025/NA/<n>`, which never equals `2026/NA/001`, so the `deletedNumber === highestNumber` guard is false and the 2026 counter keeps a permanent gap. `invoices.service.ts` solves the same problem correctly by parsing the year out of the document number.
|
||||||
|
- **Repro:** Create a draft `2025-12-30`, finalize it `2026-01-02` → `2026/NA/001`. Delete it → next finalized 2026 offer is `2026/NA/002` (gap; no `2026/NA/001`).
|
||||||
|
- **Pinning test:** Create a draft in year Y, finalize+delete it in year Y+1; assert the Y+1 sequence counter was released — fails today. Fix: parse the year from the assigned number.
|
||||||
|
|
||||||
|
### L3. `customers` list paginates with non-unique sort columns and no `id` tiebreak → unstable pagination
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/routes/admin/customers.ts:54`
|
||||||
|
- **Failure scenario:** `orderBy: { [sortField]: order }` over allow-listed fields (`name`/`city`/`country`/`company_id`, all non-unique) with no `{ id }` tiebreak, used with offset pagination. Rows sharing a sort-key value that straddle a page boundary can reorder between the page-1 and page-2 queries, duplicating one and skipping another. Violates the documented determinism rule; siblings (`issued-orders.ts`, `trips.ts`) use compound `[..., { id }]` orderings. (The same defect exists at `received-invoices.ts:144`.)
|
||||||
|
- **Repro:** Seed customers with a shared `city` straddling a `limit=2` boundary; page through `?sort=city` and observe a customer appearing on two pages while another is skipped.
|
||||||
|
- **Pinning test:** Paginate a sort over duplicate-`city` customers; assert the concatenated pages contain every customer exactly once — fails today. Fix: `orderBy: [{ [sortField]: order }, { id: order }]`.
|
||||||
|
|
||||||
|
### L4. Invoice month navigation does not reset the page index → switching to a sparser month shows an empty table
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/Invoices.tsx:244`
|
||||||
|
- **Failure scenario:** `prevMonth`/`nextMonth` mutate `statsMonth`/`statsYear` but never `setPage(1)` (unlike the status/search handlers). The list query keys on `page` + month, and the server never clamps page to `total_pages`, so paging to page 3 of a busy month then switching to a 1-page month requests `?page=3&month=...` and gets an empty `data` array — empty table for a populated month, with the pager reading "3 of 1".
|
||||||
|
- **Repro:** Open a month with 60+ invoices, go to page 3, click the previous-month chevron to a month with a handful → empty "Zatím nejsou žádné faktury" table. API: `GET /api/admin/invoices?page=3&month=<sparse>&year=<y>` returns `data:[]` with non-zero `total`.
|
||||||
|
- **Pinning test (component):** With `page=3`, fire `nextMonth`; assert `page` resets to 1 — fails today.
|
||||||
|
|
||||||
|
### L5. Issued-orders sub-list keeps its page index when the parent Orders page changes month → empty list for sparser months
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/IssuedOrders.tsx:135`
|
||||||
|
- **Failure scenario:** `IssuedOrders` receives `month`/`year` as props and holds `page` in local state with no effect/key resetting it on prop change (the child is not keyed by month/year, so it doesn't remount). Paging to page 2 of a busy month then moving the parent to a sparse month requests page 2 of the new month → empty `data` → "Zatím žádné vydané objednávky." for a month that has orders on page 1.
|
||||||
|
- **Repro:** Objednávky → Vydané: navigate to a month with ≥26 issued orders, click page 2, then use the parent month chevron to a month with 1–25 orders → empty sub-list. API equivalent: `GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=<y>` → `data:[]` with non-zero `total`.
|
||||||
|
- **Pinning test (component):** With `page=2`, change the `month` prop; assert `page` resets to 1 (or is clamped to `total_pages`) — fails today.
|
||||||
|
|
||||||
|
### L6. Settings "System" tab save clobbers freshly-saved "Firma" numbering patterns with stale values
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/Settings.tsx:297`
|
||||||
|
- **Failure scenario:** Both tabs edit the same singleton `company_settings` row via `PUT /company-settings`. `sysForm` carries `offer_number_pattern`/`order_number_pattern`/`invoice_number_pattern` (populated once, gated by a never-reset `sysFormInitialized` flag). After editing numbering in the Firma tab (which invalidates `["company-settings"]`), `sysForm` stays stale; saving the System tab sends the whole stale form, and the backend applies every `!== undefined` field, silently reverting the Firma-tab change.
|
||||||
|
- **Repro:** Init the System tab; in the Firma tab change `offer_number_pattern` and save; return to System and save any unrelated field → `offer_number_pattern` reverts to its pre-edit value, with a success toast.
|
||||||
|
- **Pinning test:** PUT the offer pattern, then PUT a stale sysForm carrying the old pattern; assert the offer pattern is NOT reverted (or that System-tab saves omit the numbering fields) — fails today.
|
||||||
|
|
||||||
|
### L7. `start_km`/`end_km` accept decimals but DB columns are `Int` — non-integer odometer reading errors or truncates
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/schemas/trips.schema.ts:15`
|
||||||
|
- **Failure scenario:** `start_km`/`end_km` use `nonNegativeNumberFromForm` (only `Number.isFinite && >=0`, no integer refine), but the columns are `Int`. A decimal passes Zod, reaches the `Int` column → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated into `vehicles.actual_km`). Same mismatch for `vehicles.initial_km`/`actual_km`. The UI's `parseInt` is used only for the distance widget, not the submitted payload, so the browser is vulnerable too.
|
||||||
|
- **Repro:** `POST /api/admin/trips` `{ vehicle_id, trip_date:"2098-01-15", start_km:100.5, end_km:1200.7, route_from:"A", route_to:"B" }` → 500 (or truncated values).
|
||||||
|
- **Pinning test:** `parseBody(CreateTripSchema, { start_km: 100.5, ... })` should 400 — fails today. Fix: `nonNegativeIntFromForm` for the four km fields.
|
||||||
|
|
||||||
|
### L8. AI monthly budget control: severity-mismatched permission, date asymmetry, dead UI button, currency overflow, audit-log boundary (grouped low-severity cluster)
|
||||||
|
|
||||||
|
These five independently-confirmed low-severity findings share the "minor blast radius / narrow trigger" profile. Each is real and pinnable.
|
||||||
|
|
||||||
|
- **L8a. Company-wide AI monthly budget is mutable by any `ai.use` holder (not admin/settings).** `src/routes/admin/ai.ts:58`. `PUT /api/admin/ai/budget` is gated only by `requirePermission("ai.use")`, an ordinary grantable permission. Setting `budget_usd=0` makes `assertBudgetAvailable` return 402 for everyone (admins included) → company-wide AI DoS; setting `1000000` removes the cost cap. The parallel `company-settings` writes are gated behind `settings.company`/`settings.system`.
|
||||||
|
- **Repro:** As any `ai.use` holder, `PUT /api/admin/ai/budget {"budget_usd":0}` → all `ai/chat` and `extract-invoices` calls 402.
|
||||||
|
- **Pinning test:** Call `PUT /ai/budget` as a non-admin `ai.use`-only user; assert 403 — fails today. Fix: gate behind `settings.company`/admin.
|
||||||
|
|
||||||
|
- **L8b. Audit-log `date_from` filter boundary shifted ~2h vs `date_to` (UTC vs local parsing).** `src/routes/admin/audit-log.ts:46`. `from = new Date(date_from)` parses as UTC midnight (02:00 Prague), while `to = new Date(date_to + "T23:59:59")` parses as local — so events between 00:00 and 02:00 Prague on the from-date are silently excluded.
|
||||||
|
- **Repro:** `GET /api/admin/audit-log?date_from=2026-06-12&date_to=2026-06-12` omits a row logged at 01:30 Prague that day.
|
||||||
|
- **Pinning test:** Insert an audit row at 01:30 local, filter for that day; assert it's returned — fails today.
|
||||||
|
|
||||||
|
- **L8c. Draft invoice shows a non-functional "Zobrazit fakturu" PDF button that always errors.** `src/admin/pages/InvoiceDetail.tsx:1805`. The button render guard lacks the `status !== "draft"` check that `OfferDetail`/`IssuedOrderDetail` have; clicking it on a draft opens a blank tab, 404s `/file`, closes the tab, and toasts an error.
|
||||||
|
- **Repro:** Open a draft invoice, click "Zobrazit fakturu" → blank tab flash + "PDF soubor nenalezen" toast.
|
||||||
|
- **Pinning test:** Render `InvoiceDetail` for a draft; assert the PDF button is not rendered — fails today. Fix: add `&& invoice.invoice_number` (or non-draft) to the guard.
|
||||||
|
|
||||||
|
- **L8d. Odin "Měna" / received-invoice currency over 3 chars 500s the save (`VarChar(3)` vs schema `max(20)`).** `src/components/odin/InvoiceReviewCard.tsx:67` / `src/schemas/received-invoices.schema.ts:11`. A free-text currency like "Koruna" passes Zod (`max(20)`) but overflows `received_invoices.currency @db.VarChar(3)` (P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module's `description` (`max(8000)` vs `VarChar(500)`) and `invoice_number` (`max(255)` vs `VarChar(100)`) are also misaligned.
|
||||||
|
- **Repro:** Save an Odin invoice with `currency:"Koruna"` → 500 + orphaned NAS file. Equivalent: `POST /received-invoices` multipart with `currency:"Koruna"`.
|
||||||
|
- **Pinning test:** `parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... })` should 400 — fails today. Fix: `currency .max(3)`, `description .max(500)`, `invoice_number .max(100)`.
|
||||||
|
|
||||||
|
- **L8e. Warehouse supplier `ico`/`dic` Zod caps (255) exceed `VarChar(20)` columns.** `src/schemas/warehouse.schema.ts:34`. A >20-char `ico`/`dic` (bad paste) passes Zod and 500s at Prisma. Low realism (real IČO=8 digits) but a genuine 500-vs-400 defect; the customers schema (`company_id`/`vat_id`) shares it.
|
||||||
|
- **Repro:** `POST /api/admin/warehouse/suppliers` `{ name:"Test", ico:"123456789012345678901" }` → 500.
|
||||||
|
- **Pinning test:** `parseBody(CreateSupplierSchema, { ico:"x".repeat(21), ... })` should 400 — fails today. Fix: `.max(20)`.
|
||||||
|
|
||||||
|
> **Adjacent uncited Zod-cap mismatches confirmed during verification** (same bug class, fix alongside the above so the gap is closed once): invoice line-item `description` (`max(8000)` vs `VarChar(500)`) and `unit` (`max(255)` vs `VarChar(20)`) — `src/schemas/invoices.schema.ts:13,16`, **High** blast radius (whole invoice save rolls back); invoice bank/payment fields `payment_method`/`constant_symbol`/`bank_swift`/`bank_iban`/`bank_account` (caps 255 vs `VarChar(50)/VarChar(20)`) — `src/schemas/invoices.schema.ts:30-35`, **Medium**; warehouse `/items` list ignores the client `sort` param and sorts by non-unique `name` with no `id` tiebreak — `src/routes/admin/warehouse.ts:662`, **Low**; year-spanning leave request books all hours against the start year's balance only — `src/routes/admin/leave-requests.ts:195`, **Medium**; Dashboard "Přidat jízdu" quick action omits `["vehicles"]` invalidation — `src/admin/components/dashboard/DashQuickActions.tsx:184`, **Low**. These were verified `real` but fall outside the 29-item cited set; treat them as a fast-follow batch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Fix Order (dependencies considered)
|
||||||
|
|
||||||
|
**Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative).** These corrupt persisted state and worsen on retry; fix before anything cosmetic.
|
||||||
|
|
||||||
|
1. **C3** `confirmInventorySession` `return`→`throw` (one-line change; stops repeatable phantom inventory). Trivial, highest leverage.
|
||||||
|
2. **C2** `confirmIssue` cross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existing `confirmIssue` validation-first pattern; consider a `(issue_id, batch_id)` unique constraint as a backstop).
|
||||||
|
3. **C1** leave-balance restore on attendance delete — but **do C1 together with H6 and the year-spanning leave finding**, since all three live in the leave/attendance balance code and a single corrected helper (per-day, holiday-aware, per-year accumulation, with an inverse on delete) resolves them coherently. Fix the holiday-counting (H6) and per-year split first, then route delete through the same helper.
|
||||||
|
|
||||||
|
**Phase 1 — Security / privilege & session correctness.** 4. **H1** session-reuse misclassification (gate the reuse branch on `replaced_by_hash` alone; reorder vs the expiry check). Self-contained; high user impact. 5. **M10** `POST /users` role-strip for non-admins; **L8a** AI budget permission gate. Both are permission-boundary one-liners.
|
||||||
|
|
||||||
|
**Phase 2 — The Zod-cap-vs-DB-column batch (single coordinated sweep).** All of **H2, M2, M4, M11, L1, L8d, L8e** plus the adjacent invoice item/bank caps share one mechanism and one fix shape (`.max()` alignment + optionally a global P2000→400 mapping in `server.ts`). Do them in one pass with a shared "cap-must-fit-column" test helper so the class is closed, not whacked one at a time. Highest blast radius within the batch (whole-save rollback): invoice item caps, then H2.
|
||||||
|
|
||||||
|
> **Phase 2 follow-ups (2026-06-12, deliberately deferred):** (1) the optional
|
||||||
|
> global P2000→400 mapping in the `server.ts` error handler was NOT added —
|
||||||
|
> `server.ts` exports no app builder, so the mapping would be untestable
|
||||||
|
> without first extracting an exported `buildApp()`; do the refactor + backstop
|
||||||
|
> together. (2) Frontend `maxLength` attributes on the trips (odkud/kam) and
|
||||||
|
> invoice bank/payment form fields — pure UX polish now that Zod 400s
|
||||||
|
> over-length input with a Czech message; add when next touching those forms.
|
||||||
|
|
||||||
|
**Phase 3 — FK-existence & error-mapping consistency.** **M6** (projects update), **M8** (warehouse receipts/issues), **M5** (CNB graceful degradation). These align the lagging modules with the established offers/orders pre-validation pattern; a shared P2003→Czech-400 mapping (added in Phase 2 if you choose the global-handler route) reduces the per-site work.
|
||||||
|
|
||||||
|
**Phase 4 — Date/timezone boundaries.** **M1** (invoice date `isoDateString`), **M7** (warehouse `date_to` half-open), **H5** (Odin Czech-date), **L8b** (audit-log `from`). Group because they share the documented date-boundary helpers (`isoDateString`, `utcMidnightOfLocalDay`); fixing M1/H5 also removes a day-shift class.
|
||||||
|
|
||||||
|
**Phase 5 — Pagination determinism & frontend state.** **H4** (received-invoices pager — add the missing `usePaginatedQuery`/`<Pagination>`), **L3** (customers + received-invoices `id` tiebreak), **L4/L5** (page-reset on month change), **M9** (plan update cap), **H3** (issued-order transition flush edits + don't `markClean` stale state). H4 is the largest UI change; the rest are small, independent guards.
|
||||||
|
|
||||||
|
**Phase 6 — Cosmetic / low-blast cleanup (parallelizable, no dependencies).** ~~M3~~ (withdrawn; only the stale Chromium-footer comment in `html-to-pdf.ts`/CLAUDE.md remains as a doc fix), **M12** (Puppeteer re-acquire/retry), **L2** (offer number release year), **L6** (Settings System/Firma clobber), **L7** (km integer coercion), **L8c** (draft PDF button guard), warehouse `/items` sort param + tiebreak, and the Dashboard `["vehicles"]` invalidation.
|
||||||
|
|
||||||
|
Rationale for ordering: persisted-data corruption (Phase 0) cannot be allowed to accumulate; security boundaries (Phase 1) are cheap and high-impact; the cap batch (Phase 2) and FK/error mapping (Phase 3) are most efficient done as coordinated sweeps that share a fix shape and tests; date boundaries (Phase 4) share helpers; UI/pagination (Phase 5) depend on nothing earlier but benefit from the cleaner backend; everything in Phase 6 is independent and can be parallelized across contributors.
|
||||||
@@ -204,8 +204,11 @@ descriptions for unnumbered drafts. Audit failures are non-fatal.
|
|||||||
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||||
suffixes. The issued-order PDF gets language from the document's `language`
|
suffixes. The issued-order PDF gets language from the document's `language`
|
||||||
column and carries a Puppeteer `footerTemplate` footer on every page
|
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||||
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
|
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating-
|
||||||
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
|
footer mechanisms coexist and BOTH work: Puppeteer `footerTemplate`
|
||||||
|
(invoices + issued orders — `htmlToPdf(html, { footerTemplate })`) and CSS
|
||||||
|
`@page` margin boxes (`@bottom-center` + `counter(page)`, offer PDF —
|
||||||
|
supported since Chrome 131, Nov 2024). Don't migrate one to the other.
|
||||||
- **Shared modules — extend, never fork local copies:**
|
- **Shared modules — extend, never fork local copies:**
|
||||||
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||||
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||||
|
|||||||
407
POSSIBLE_IMPROVEMENTS.md
Normal file
407
POSSIBLE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# Possible Improvements — UX & Consistency Review
|
||||||
|
|
||||||
|
_A prioritized UX review of boha-app, grounded in 104 verified, code-referenced findings._
|
||||||
|
|
||||||
|
> **How this was produced (2026-06-24):** a multi-agent audit fanned out 16 independent
|
||||||
|
> reviewers across 5 feature domains and 11 cross-cutting UX dimensions (155 raw findings),
|
||||||
|
> deduplicated them into 118 unique items, then **adversarially re-checked every finding
|
||||||
|
> against the actual code** — dropping 14 that didn't hold up or contradicted a deliberate
|
||||||
|
> documented decision (CLAUDE.md). What remains are 104 findings each confirmed in the
|
||||||
|
> source, with file references kept intact so they can be acted on directly. Read §1–§3 for
|
||||||
|
> the opinion and the plan; §4 is the full catalogue by theme; §5 is suggested sequencing.
|
||||||
|
>
|
||||||
|
> **Independently re-verified (2026-06-24):** a second adversarial pass (9 parallel
|
||||||
|
> verifiers, different model) re-checked all 104 findings against the source:
|
||||||
|
> **100 confirmed, 4 partial, 0 refuted.** The partials were factual nits (a couple of
|
||||||
|
> miscounts, one wrong fix target, one mis-cited import), corrected inline in this
|
||||||
|
> document. The pass also surfaced one new backend finding (trips create authz — see §4.9)
|
||||||
|
> and upgraded confidence on the AttendanceAdmin and warehouse-models findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
boha-app is a **mature, broad, and genuinely well-built** back-office system. The bones are excellent: there is a real component kit (`src/admin/ui/`), shared document modules, a single-source-of-truth status module (`documentStatus.ts`), a global React Query cache with disciplined invalidation conventions, timezone-safe date handling, edit-locking on documents, and an accessibility-aware theme. Most of the "hard" platform work is done and done well. This is not a rescue job — it is a **polish-and-converge job**.
|
||||||
|
|
||||||
|
That said, the owner asked the right question. The single dominant theme across these findings is **consistency debt: the app does the same thing several different ways, and the variants disagree in ways the daily user feels.** Almost every cluster below is a story of "module A got it right, module B is an older or forked copy that drifted." The codebase even documents this pattern against itself — `documentStatus.ts` exists _because_ per-page status maps drifted; `useUnsavedChangesGuard` exists _because_ the dirty-check was copy-pasted; CLAUDE.md explicitly says "extend, never fork." The findings show those rules are followed in the newest modules (offers, issued orders) and quietly violated in the older twins (invoices, orders, attendance-admin, projects, warehouse).
|
||||||
|
|
||||||
|
Why this matters: the user's mental model breaks at the seams. Marking an invoice paid from the **detail** page leaves a project's order-status label stale, but doing it from the **list** page refreshes it. The "create" button **navigates to a page** on one Orders tab and **opens a modal** on the other. A failed list fetch shows "no records yet" — which can convince a user a customer has zero invoices when the server actually errored. Validation errors appear **inline under the field** on the supplier modal but **as a 4-second toast** on its mirror-image customer modal. None of these is catastrophic alone; collectively they make a strong app feel "not quite finished" and erode trust in what's on screen.
|
||||||
|
|
||||||
|
**The five highest-leverage moves, in order:**
|
||||||
|
|
||||||
|
1. **Stop showing stale data after writes.** Define one canonical React Query invalidation set per document family and apply it to _both_ list and detail mutations. Today detail-page edits invalidate fewer domains than their list twins (`OrderDetail`, `InvoiceDetail`, `DashProfile`, leave-cancel). Small effort, removes a whole class of "why is this number wrong" confusion. _(Cluster: data-freshness)_
|
||||||
|
|
||||||
|
2. **Guard the irreversible actions that currently aren't.** Marking an invoice **"Zaplaceno"** is a single unconfirmed chip click and is _terminal_ — the one truly irreversible document transition with **no** confirm, while every reversible status change has one. Same inversion in the warehouse: **"Potvrdit"** (posts stock) has no confirm but its **undo** does. And the **Aktivní/Neaktivní** chips are secret one-click toggles that fail silently on error. _(Cluster: destructive actions)_
|
||||||
|
|
||||||
|
3. **Fix list-page failure and loading states.** No list page reads `isError`, and there is no global `QueryCache.onError`, so failed fetches fall through to the **empty state** — actively misleading. Add a global error toast plus a distinct inline error. _(Cluster: loading/empty/error)_
|
||||||
|
|
||||||
|
4. **Make the app usable on a phone.** The shared `Tabs` uses MUI's non-scrolling `variant="standard"`, so the 5 status-filter tabs on Offers/Invoices simply **clip off-screen and become unreachable** at ~360px — a one-prop fix that touches every status filter. Modals never go full-screen on phones. _(Cluster: mobile)_
|
||||||
|
|
||||||
|
5. **Converge the shared primitives the app already owns.** Route the forked dirty-guards, status maps, empty states, page headers, save buttons, and PDF openers back through their canonical implementations. This is the structural fix that prevents the other four from re-drifting. _(Cluster: forked modules)_
|
||||||
|
|
||||||
|
The rest of this report groups all findings by theme, gives a prioritized action table, and then lists every finding with its file references intact so they can be acted on directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Top Themes
|
||||||
|
|
||||||
|
### Theme A — Data-freshness drift (the trust killer)
|
||||||
|
|
||||||
|
React Query invalidation is the app's nervous system, and CLAUDE.md lays out clear rules ("invalidate every domain that embeds the data"). The newest modules obey; older and raw-`apiFetch` paths don't. **List pages and their own detail pages invalidate different domain sets** because list pages use manual `invalidate` arrays while detail pages use `useApiMutation` arrays, and the two drifted independently. The result is subtle: a value that is correct after editing it one way is stale after editing it the identical other way. Converge on **one named invalidation constant per family** and reuse it on both surfaces. The dashboard already solved the "many domains feed me" case with `refetchOnMount: "always"` — apply the same backstop to the audit log.
|
||||||
|
|
||||||
|
### Theme B — Destructive & risky actions are gated by consequence-inverted logic
|
||||||
|
|
||||||
|
The pattern across the app is sound — `ConfirmDialog` everywhere — but the gating is **inversely correlated with actual consequence** in the few places it matters most. The _irreversible_ transitions (invoice→paid, warehouse "Potvrdit" posts stock) have **no** guard, while their _reversible_ counterparts (cancel document) get a full danger confirm. Several state changes hide as **chip toggles with no affordance** and **no `onError`**, so a rejected toggle silently reverts with zero feedback. And the "wipe the entire audit log" case uses the same low-key modal as "trim 30-day rows." Converge on: irreversibility → stronger friction, never weaker; every mutating control announces itself and reports its errors.
|
||||||
|
|
||||||
|
### Theme C — Forms & save UX speak three dialects
|
||||||
|
|
||||||
|
Validation is **inline-under-field** in some forms and **transient toast** in others — and _mirrored screens disagree_ (supplier modal inline vs customer modal toast; trip editor inline vs admin-trip editor toast; received-invoice bulk-review inline vs its single-edit toast). Enter submits page forms but **never** submits a shared-`Modal` form. Save buttons **spin** in document pages but only **swap text** elsewhere, and dual-save warehouse forms can't tell you which action is running. Failed validation never **moves focus** to the bad field. Converge on: inline `<Field error>` for validation, toasts only for transport/server failures, `<form onSubmit>` in the shared Modal, one save affordance, and focus-the-first-error.
|
||||||
|
|
||||||
|
### Theme D — Unsaved-work protection is partial and fork-ridden
|
||||||
|
|
||||||
|
The shared `useUnsavedChangesGuard` is used by exactly two pages; `OrderDetail` and `InvoiceDetail` **re-implement it inline**, and `ProjectDetail`, `CompanySettings`, all three warehouse forms, and `AttendanceCreate` have **no guard at all** — a refresh discards a 15-line receipt silently. Worse, _every_ guard only catches `beforeunload`, so clicking the in-app **"Zpět"** or a sidebar link — the most common way to leave — discards edits with no prompt (this one needs a router upgrade). And `InvoiceDetail` has **no edit-lock** while its siblings do, so two users can clobber each other's invoice line items.
|
||||||
|
|
||||||
|
### Theme E — Loading, empty & error states have no single grammar
|
||||||
|
|
||||||
|
Three different loading behaviors for the _same_ "change the month" action (dim / nothing / flash). Empty states built two ways (`<EmptyState>` vs hand-rolled `Box+Typography`). 40 pages **blank the whole page to a centered spinner** (layout shift) instead of keeping chrome; there are **zero** real MUI `<Skeleton>`s despite identifiers literally named `showListSkeleton`. The dashboard hand-rolls its own spinner. And the big one: **failed fetches render as empty**, not as errors.
|
||||||
|
|
||||||
|
### Theme F — Navigation & information architecture has thin orientation
|
||||||
|
|
||||||
|
Every browser tab reads **"Admin"** (no per-page title), there are no breadcrumbs, the back control is built **four different ways**, and `Received-order` detail sends you back to the **wrong Orders tab**. Master data is scattered across four nav sections; "Zákazníci" lives at the misleading URL `/offers/customers`; `settings.templates` opens the gear then **bounces you to the dashboard**; and there are three different "access denied" experiences. None blocks work, but together they make deep pages feel un-anchored.
|
||||||
|
|
||||||
|
### Theme G — Mobile is a second-class citizen in specific, fixable spots
|
||||||
|
|
||||||
|
Status-filter tabs **clip and become unreachable** on phones. Modals never go full-screen. Long full-page editors keep **Save only at the top**, so phone users scroll all the way back up to save. Warehouse line-items lose the labeled card-per-row layout the document editor has — worst exactly where shop-floor mobile use is likely. `FilterBar` children mix fixed and growing flex bases, leaving dead space.
|
||||||
|
|
||||||
|
### Theme H — Microcopy & localization inconsistency
|
||||||
|
|
||||||
|
The goods-receipt module is named **four ways** (Příjmy / Nový příjem / Příjmové doklady / Příjemka) and "Příjmy" also means _financial income_. "Cancelled" is spelled two ways in the same status file. Create verbs split three ways. The busy label has five competing forms plus a `"Chyba pripojeni"` diacritics typo. Autocompletes leak English **"No options"** in an otherwise fully-Czech app. Several prices bypass cs-CZ formatting (period decimals, no currency).
|
||||||
|
|
||||||
|
### Theme I — Accessibility gaps in the hot paths
|
||||||
|
|
||||||
|
Clickable table rows aren't keyboard-operable (warehouse records literally can't be opened by keyboard). Search boxes are placeholder-only with no accessible name across ~13 pages. The desktop line-item grid is unlabeled while the mobile card is fully labeled. No skip-to-content link, so keyboard users tab the whole sidebar on every navigation.
|
||||||
|
|
||||||
|
### Theme J — Bundle & performance
|
||||||
|
|
||||||
|
MUI + Emotion sit in the **781 kB entry chunk**, so every patch release busts the cached UI vendor code and daily users re-download ~232 kB gzip of unchanged MUI. The Dashboard and its 7 subcomponents are bundled into the entry chunk and downloaded **on the Login screen**. No route-transition progress bar, no link prefetch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prioritized Recommendations
|
||||||
|
|
||||||
|
Effort: **S** = hours · **M** = a focused day or two · **L** = multi-day / structural.
|
||||||
|
|
||||||
|
### Quick wins — high impact, low effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 1 | Make status-filter `Tabs` scrollable so they stop clipping off-screen on phones | `src/admin/ui/Tabs.tsx:28-49` (one prop fixes all) | High | S |
|
||||||
|
| 2 | Add a confirm (or undo toast) + hover affordance to the **"mark invoice paid"** chip — the only unguarded irreversible transition | `Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680` | High | S |
|
||||||
|
| 3 | Converge detail-page invalidation sets onto the list-page (full) sets | `OrderDetail.tsx:162,168,177`; `InvoiceDetail.tsx:1054,1067,1073`; `DashProfile.tsx:225-226`; `LeaveRequests.tsx:90` | Medium | S |
|
||||||
|
| 4 | Gate the Invoices **list** PDF icon on `invoice_number` so drafts stop dead-ending in a 404 | `Invoices.tsx:616-626` | Medium | S |
|
||||||
|
| 5 | Point `Received-order` detail Zpět / redirects at `/orders?tab=prijate` | `OrderDetail.tsx:116-121,275-287,397-405` | Medium | S |
|
||||||
|
| 6 | Add a global `QueryCache.onError` toast so failed fetches stop masquerading as empty | `src/admin/lib/queryClient.ts` | High | S |
|
||||||
|
| 7 | Debounce search on Invoices / ReceivedInvoices / AuditLog (reuse `useDebounce`) | `Invoices.tsx:282`, `ReceivedInvoices.tsx:250`, `AuditLog.tsx:202` | Medium | S |
|
||||||
|
| 8 | Add global MUI `csCZ` `localeText` (kills English "No options" everywhere) | `src/admin/ui/MuiProvider.tsx:15`; pickers `CustomerPicker.tsx:49`, `SupplierPicker.tsx:49` | Medium | S |
|
||||||
|
| 9 | Add `onError` toast to Vehicles/Users active-toggle mutations (silent failure today) | `Vehicles.tsx:146-160`, `Users.tsx:162-171` | Medium | S |
|
||||||
|
| 10 | Add a per-page `document.title` (`<TitleSync>`) so tabs/bookmarks aren't all "Admin" | `index.html:22`, `AppShell`, `navData.tsx` | Medium | S |
|
||||||
|
| 11 | Use `PROJECT_STATUS`/`ORDER_STATUS` maps for project + linked-order chips (currently wrong colors / raw DB tokens) | `Projects.tsx:50`, `ProjectDetail.tsx:43,577`, `documentStatus.ts` | Medium | S |
|
||||||
|
| 12 | Fix `"Chyba pripojeni"` diacritics typo; unify cancelled-label spelling | `Dashboard.tsx:127`, `AuthContext.tsx:256,314`; `documentStatus.ts:49,58` | Low | S |
|
||||||
|
| 13 | Add a skip-to-content link + `id="main-content"` on `<main>` | `AppShell.tsx:130-145,210-223` | Medium | S |
|
||||||
|
| 14 | Lazy-load Dashboard so its 7 subcomponents leave the Login critical path | `AdminApp.tsx:14-15`, `Dashboard.tsx:16` | Medium | S |
|
||||||
|
| 15 | Relabel the two Orders-tab "Vytvořit objednávku" buttons (identical for two doc types) | `Orders.tsx:125,129` | Medium | S |
|
||||||
|
|
||||||
|
### High-impact — worth a focused effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 16 | Add unsaved-changes guards to the heavy editors that have none; de-fork `OrderDetail`/`InvoiceDetail` onto the shared hook | `ProjectDetail`, `CompanySettings`, `Warehouse*Form`, `AttendanceCreate`; `useUnsavedChangesGuard.ts` | High | M |
|
||||||
|
| 17 | Render an inline error (distinct from empty) when `isError`, with retry | `usePaginatedQuery.ts:47`, all list pages | High | M |
|
||||||
|
| 18 | Standardize validation on inline `<Field error>`; fix the mirrored screens that disagree | `WarehouseSuppliers` vs `OffersCustomers`; `Trips` vs `TripsAdmin`; `ReceivedInvoices` bulk vs single | Medium | M |
|
||||||
|
| 19 | Wrap shared `Modal` in `<form onSubmit>` so Enter submits every dialog | `src/admin/ui/Modal.tsx:84,92` | Medium | M |
|
||||||
|
| 20 | Route the three forked invoice PDF openers through `useDocumentPdf`/`useDocumentListPdf` | `Invoices.tsx:361`, `InvoiceDetail.tsx:1175`, `ReceivedInvoices.tsx:561` | Medium | M |
|
||||||
|
| 21 | Replace inline English/technical error strings with `apiErrorMessage(...)` across ~32 pages | `mutations.ts:30-36`; per-page catch blocks | Medium | M |
|
||||||
|
| 22 | Make `Modal` full-screen on phones; fix leave-modal hardcoded 2-col date grid | `Modal.tsx:67-75`, `Attendance.tsx:1195-1199` | Medium | S |
|
||||||
|
| 23 | Give warehouse line-items the labeled card-per-row mobile layout the document editor has | `WarehouseIssueForm.tsx:513-567` vs `DocumentItemsEditor.tsx:170-342` | Medium | M |
|
||||||
|
| 24 | Add keyboard semantics to `DataTable` clickable rows (role/tabindex/Enter-Space) | `DataTable.tsx:285-303,137-161` | Medium | M |
|
||||||
|
| 25 | Add a "Nová žádost" create action to "Moje žádosti" (lift the leave modal out of Attendance) | `LeaveRequests.tsx:241`, `Attendance.tsx:936,978` | Medium | M |
|
||||||
|
| 26 | Route hand-rolled headers through `PageHeader`/`headerActionsSx` (~20 pages) so mobile buttons stack | `PageHeader.tsx:19`; `Projects`, `Users`, `Vehicles`, `TripsAdmin`, `AttendanceAdmin`, `Settings`, `CompanySettings` | Medium | M |
|
||||||
|
| 27 | Confirm before warehouse **"Potvrdit"** / **"Uložit a potvrdit"** (posts stock irreversibly) | `WarehouseReceiptDetail.tsx:354-356`, `WarehouseReceiptForm.tsx:445` | Medium | S |
|
||||||
|
| 28 | Split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk | `vite.config.ts` | Medium | M |
|
||||||
|
| 29 | Give list-page search inputs an accessible name + `type="search"` (shared `SearchField`) | `Offers.tsx:787`, `Invoices.tsx:741`, +11 pages | Medium | S |
|
||||||
|
|
||||||
|
### Strategic — larger bets
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 30 | Add edit-locking (migration + route trio) to `InvoiceDetail` (and order notes) to stop multi-user clobbering | `invoices.ts`, `orders.ts`, schema; wire `useDocumentLock`/`LockBanner` | High | L |
|
||||||
|
| 31 | Migrate `InvoiceDetail` off its fork onto shared `DocumentItemsEditor`/`useDocumentPdf`/dirty-guard (add `showVat` flag) | `InvoiceDetail.tsx:241-541`, `DocumentItemsEditor.tsx` | Medium | L |
|
||||||
|
| 32 | Block dirty in-app navigation (requires `createBrowserRouter`/`RouterProvider` migration, then centralize in the guard) | `main.tsx`, `useUnsavedChangesGuard.ts` | Medium | L |
|
||||||
|
| 33 | Move `AttendanceAdmin` reads off raw `apiFetch` onto React Query (kills the `setTimeout(300)` refetch dance) | `useAttendanceAdmin.ts:823,871,1057` | Medium | L |
|
||||||
|
| 34 | Real skeleton/page-shell first paint instead of full-page spinner on 40 pages | `LoadingState.tsx`, list pages; `skeleton-boha` skill exists | Medium | L |
|
||||||
|
| 35 | Route-transition top progress bar + sidebar link prefetch | `AppShell.tsx:222`, `AdminApp.tsx` | Medium | M |
|
||||||
|
| 36 | Consolidate duplicated status/label maps into `documentStatus.ts` (warehouse/projects/leave) | `documentStatus.ts`; 6 warehouse files, `Projects`, `Leave*` | Medium | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detailed Findings by Area
|
||||||
|
|
||||||
|
### 4.1 Data-freshness — stale data after mutations
|
||||||
|
|
||||||
|
- **Detail-page mutations invalidate fewer domains than their list twins.** `OrderDetail` status/notes/delete (`OrderDetail.tsx:162,168,177`) invalidate only `[orders,invoices]` while the `ReceivedOrders` list delete (`ReceivedOrders.tsx:175`) uses `[orders,offers,projects,invoices]`. `InvoiceDetail` save/status/delete (`InvoiceDetail.tsx:1054,1067,1073`) use `[invoices,orders]` while the `Invoices` list adds `projects` (`Invoices.tsx:328,352`). **Why it matters:** marking an invoice paid or changing an order status from the _detail_ page leaves `ProjectDetail`'s `order_status` label (`ProjectDetail.tsx:571`) and the source offer stale until staleTime/refocus. **Fix:** define one canonical set per family (e.g. `[offers,orders,issued-orders,projects,invoices]`) and apply to both surfaces; `OfferDetail`/`Offers` are the consistent reference to copy.
|
||||||
|
|
||||||
|
- **Self-profile edit refreshes fewer domains than an admin editing the same user.** `DashProfile` (`DashProfile.tsx:225-226`) invalidates only `[dashboard,users]`, but admin Users edit uses `USER_INVALIDATE=[users,trips,attendance,leave-requests,leave,projects]` (`Users.tsx:117-124`). **Why:** your display name is embedded in trips/attendance/leave/projects (none keyed under `[users]`), so renaming yourself leaves it stale there. **Fix:** export `USER_INVALIDATE` from a shared module and reuse it in `DashProfile`.
|
||||||
|
|
||||||
|
- **Cancelling a leave request omits the `[users]` balance invalidation that create/approve include.** Cancel (`LeaveRequests.tsx:90`) invalidates `[leave-requests,leave,attendance]`; create (`Attendance.tsx:305`) and approve/reject (`LeaveApproval.tsx:164,177`) add `users,dashboard`. **Why:** the genuinely-stale gap is `[users]` balance/history when an approved future request is cancelled (the `[dashboard]` part self-heals via `refetchOnMount`). **Fix:** hoist a shared `LEAVE_INVALIDATE` set across all three flows.
|
||||||
|
|
||||||
|
- **Audit Log is never invalidated by the mutations that write audit rows.** `logAudit()` runs on every mutation, but `[audit-log]` is invalidated only by plan flows (`usePlanWork.ts:167`) and the page's own cleanup (`AuditLog.tsx:191`). **Why:** recent actions don't appear within the 30s default staleTime. **Fix:** add `refetchOnMount:'always'` (and `staleTime:0`) to the AuditLog page's inline `useQuery` (`AuditLog.tsx:135`), mirroring `dashboardOptions` — don't sprinkle `[audit-log]` into dozens of arrays. _(Verification note: `auditLogOptions` in `auditLog.ts` is dead code — nothing imports it; either delete it or wire the page onto it and put the option there.)_
|
||||||
|
|
||||||
|
- **Odin invoice import invalidates only `[invoices]`, not `[suppliers]`/`[company-settings]`.** `OdinChat.tsx:362` vs manual `[invoices,suppliers,company-settings]` (`ReceivedInvoices.tsx:331,341`). **Why:** an Odin import that introduces a new vendor name won't appear in the `[suppliers]`-keyed autocomplete until staleTime expires. **Fix:** use the same set on both paths (they POST to the same endpoint).
|
||||||
|
|
||||||
|
- **Received-invoice supplier picker is disjoint from the warehouse/issued-order supplier registry.** Received invoices use a free-text `string[]` under `[suppliers]` → `/received-invoices/suppliers` (`common.ts:22-28`); issued orders/warehouse use the structured `sklad_suppliers` table (`issued-orders.ts:76`, `warehouse.ts:241`). **Why:** a vendor added in Warehouse→Suppliers never shows when entering a received invoice, and vice-versa — same conceptual entity, two disjoint stores. **Fix:** don't force an FK (keep one-off vendors as quick free-text), but **union** `sklad_suppliers` names into the received-invoice autocomplete source.
|
||||||
|
|
||||||
|
### 4.2 Destructive & risky actions — confirmation and undo
|
||||||
|
|
||||||
|
- **Marking an invoice "Zaplaceno" is a single unconfirmed chip click — the only irreversible transition with no guard.** In both invoice lists the chip instantly PUTs `status=paid` with no confirm and no undo (`Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680`); for issued invoices "paid" is terminal (detail goes read-only, row undeletable, `InvoiceDetail.tsx:1220-1314`). Every _detail_ page gates status changes behind a `ConfirmDialog`, but this one doesn't, and the chip only differs from a static one by a pointer cursor. **Fix:** route the chip click through a `ConfirmDialog` ("Označit fakturu … jako zaplacenou?") or an undo toast, and add a hover/tooltip affordance.
|
||||||
|
|
||||||
|
- **Aktivní/Neaktivní chips are hidden one-click toggles with no affordance, and fail silently.** On Vehicles, Users, Warehouse Locations and Suppliers the `StatusChip` is secretly an `onClick` toggle with no tooltip/label (`Vehicles.tsx:263`, `Users.tsx:288`). Deactivation fires on one click while the less-destructive delete gets a full `ConfirmDialog`. The Vehicles/Users toggle mutations declare only `onSuccess`, **no `onError`** and no try/catch (`Vehicles.tsx:146-160`, `Users.tsx:162-171`), so a rejected toggle gives zero feedback and the chip silently reverts. **Fix:** make activation an explicit labelled control (switch/menu action) with a tooltip; add a deactivation confirm for user/vehicle; add `onError` toasts. (`WarehouseLocations`/`WarehouseSuppliers` already toast via try/catch — copy them.)
|
||||||
|
|
||||||
|
- **`ConfirmDialog` in-flight `loading` is applied inconsistently — some dialogs allow duplicate submits.** `IssuedOrderDetail` passes `loading` to both status and delete dialogs (`:958`); `OrderDetail`/`InvoiceDetail` pass it to delete but not status (`OrderDetail.tsx:780-790`, `InvoiceDetail.tsx:2453-2463`); Vehicles delete (`:417-429`), `AttendanceAdmin` delete (raw `apiFetch`, no `isPending`, `useAttendanceAdmin.ts:1293-1320`) and CompanySettings bank delete omit it entirely → a second click fires a duplicate request. **Fix:** pass `loading={mutation.isPending}` to every `ConfirmDialog`; convert `AttendanceAdmin`'s raw delete to `useApiMutation`; standardize on `IssuedOrderDetail`'s keep-open "Zpracovávám…" convention.
|
||||||
|
|
||||||
|
- **Irreversible "Potvrdit" (posts stock) has no confirm, while its reversible "Zrušit doklad" undo does.** `WarehouseReceiptDetail.tsx:354-356` commits stock movements on one click; `:508-517` requires a danger confirm to reverse it. Same single-click commit in the form's "Uložit a potvrdit" (`WarehouseReceiptForm.tsx:445`). **Why:** the gating is inverted relative to consequence; a misclick commits stock. **Fix:** add a `ConfirmDialog` before "Potvrdit" (and ideally the form's commit).
|
||||||
|
|
||||||
|
- **Cancellation confirms show two near-identical "Zrušit" buttons.** When the confirmed action is itself a cancellation, both the dismiss and the confirm start with "Zrušit" (`WarehouseReceiptDetail.tsx:508-516`, `LeaveRequests.tsx:260-268`) — same leading word, opposite meanings (the buttons do differ visually: contained vs text). **Fix:** relabel the dismiss to "Zpět"/"Ne" via `cancelText` (`ConfirmDialog.tsx:31-33`).
|
||||||
|
|
||||||
|
- **Wiping the ENTIRE audit log uses the same low-key modal as trimming old rows.** "Vše" sits in the same dropdown as "30 dní"/"90 dní" and the only warning is a 0.6-opacity caption (`AuditLog.tsx:297-319,207-218`). The backend deliberately requires a special literal to wipe everything (`routes/admin/audit-log.ts:92-109`), but the UI auto-supplies it with no extra friction. **Fix:** when "Vše" is selected, switch to danger styling, show a prominent warning, ideally require typing a confirmation word.
|
||||||
|
|
||||||
|
- **Error toasts auto-dismiss after 4s identically to successes, with no history.** `AlertContext.tsx:53-70` defaults every severity to 4000ms; a long Czech validation message vanishes as fast as "Uloženo," with no toast history (`AlertContainer.tsx:20-27`). **Fix:** key the default off severity — error/warning longer or sticky-until-dismissed, success ~4s.
|
||||||
|
|
||||||
|
### 4.3 Error & message feedback fidelity
|
||||||
|
|
||||||
|
- **Raw English/technical error strings leak into Czech toasts on ~30 pages.** A helper `apiErrorMessage(err, fallback)` converts client-generated English ("Unauthorized", "Failed to fetch", "Invalid JSON response", "Request failed (NNN)") into a Czech fallback (`mutations.ts:30-36,69-84`), but only `OfferDetail`/`IssuedOrderDetail` use it. ~30 pages do `alert.error(e instanceof Error ? e.message : "Chyba připojení")` (`Vehicles.tsx:203`, `OrderDetail.tsx:191`), passing the raw English through on dropped connections / non-JSON 500s / 401s. **Fix:** replace the inline pattern with `apiErrorMessage(e, "<Czech fallback>")` across the ~32 files. **Do not** add a blanket `useApiMutation` default `onError` — pages call `mutateAsync` inside try/catch and also reset local state there, so a default handler would double-toast.
|
||||||
|
|
||||||
|
### 4.4 Unsaved-changes & data-loss protection
|
||||||
|
|
||||||
|
- **The dirty guard is absent on heavy editors and forked twice.** Shared `useUnsavedChangesGuard` is used only by `OfferDetail`/`IssuedOrderDetail`; `OrderDetail` (`:130-143`) and `InvoiceDetail` (`:942-958`) re-implement `isDirty`+`beforeunload` inline; `ProjectDetail`, `CompanySettings`, `WarehouseIssueForm/ReceiptForm/InventoryForm` and `AttendanceCreate` register **no guard at all** — a refresh discards everything typed. **Fix:** route the two forks through the shared hook and add it to the six unguarded editors.
|
||||||
|
|
||||||
|
- **Dirty guards block only tab-close, not in-app navigation.** Every guard registers only `beforeunload` (`useUnsavedChangesGuard.ts:25-33`), which doesn't fire on React-Router navigation — so the in-app "Zpět" or a sidebar link discards edits silently. **Fix (structural):** the app uses classic `<BrowserRouter>` (react-router 6.30.3) where `useBlocker`/`usePrompt` aren't available — this needs a migration to `createBrowserRouter`/`RouterProvider` (or a custom nav-intercept context), then centralize the blocker inside the shared hook.
|
||||||
|
|
||||||
|
- **Always-editable page forms offer no explicit Discard.** Modals have "Zrušit" and `WarehouseItemDetail` has an edit/cancel toggle that restores the snapshot (`:247-269`), but `ProjectDetail` (only Uložit+Smazat, `:343`) and `CompanySettings` (only Uložit, `:647`) give no way to abandon edits — you must reload (and they don't even warn). **Fix:** add a "Zrušit změny" button that restores the loaded snapshot from query cache.
|
||||||
|
|
||||||
|
- **Invoices have no edit-lock while offers and issued orders do (multi-user clobbering).** `OfferDetail`/`IssuedOrderDetail` acquire a server lock, heartbeat, render `LockBanner` and force read-only when another user holds it; `InvoiceDetail` imports none of it (`:22-88`), so two users can clobber each other's line items (last-save-wins). Same gap for received-order notes on `OrderDetail`. **Fix (structural):** the _client_ pieces exist (`useDocumentLock`+`LockBanner`) but the _server_ trio does **not** — needs a migration adding `locked_by`/`locked_at` to invoices (and orders), a lock/heartbeat/unlock route trio + `locked_by` enrichment in the detail endpoint (mirror `issued-orders.ts:138-245`), then wire the existing hook + banner.
|
||||||
|
|
||||||
|
### 4.5 Form validation & submission
|
||||||
|
|
||||||
|
- **Validation is inline in some forms, transient toasts in others — and mirrored screens disagree.** Inline `<Field error>` camp: Trips, Users, Vehicles, WarehouseSuppliers, OfferDetail. Toast camp: ProjectDetail, AttendanceCreate, CompanySettings, OffersCustomers, TripsAdmin, ReceivedInvoices-edit. The disagreements: supplier modal inline (`WarehouseSuppliers.tsx:266-270`) vs its mirror customer modal toast (`OffersCustomers.tsx:338-341`); regular trip editor inline `end_km>start_km` (`Trips.tsx:294-300`) vs admin trip editor toast (`TripsAdmin.tsx:485-490`); received-invoice bulk-review inline per-row (`:431-446`) vs single-edit toast (`:508-519`). **Fix:** standardize on inline `<Field error>`; reserve toasts for server/transport failures.
|
||||||
|
|
||||||
|
- **Enter submits page forms but never any shared-Modal form.** `Modal` renders children in `DialogContent` with an `onClick` button (default `type=button`) and no surrounding `<form>` (`Modal.tsx:84,92`), so Enter never submits a modal. `OfferDetail` even hand-rolled an `onKeyDown` Enter workaround (`:1059-1061`). **Fix:** wrap `DialogContent`+`DialogActions` in `<form onSubmit>`, make the primary action `type=submit`, mark secondary/in-content buttons `type=button`, and remove the workaround.
|
||||||
|
|
||||||
|
- **Failed validation doesn't move focus to the first invalid field nor announce to AT.** Handlers just `setErrors` and return (`Vehicles.tsx:192-197`); the `Field` error is a plain caption wired via `aria-describedby`/`aria-invalid`, not `aria-live`/`role=alert` (`Field.tsx:81-89`). In a long modal the error can be off-screen. **Fix:** after `setErrors`, focus+`scrollIntoView` the first errored field and render the inline error with `role=alert`; centralize in `Field.tsx`/`Modal.tsx`.
|
||||||
|
|
||||||
|
- **Required-asterisk marking is inconsistent with what's validated; DateField/TimeField can't show an error border.** `Field` paints the asterisk only when `required` is passed (`Field.tsx:74`), but it's applied unevenly: `WarehouseIssueForm` validates Projekt-required with no asterisk (`:474` vs `:328-329`); `ProjectDetail` validates Název-required with no asterisk (`:377` vs `:192`); `DateField`/`TimeField` take no `error` prop so a failed required date only reddens helper text, not the border. **Fix:** make `required` the single source of truth (drives asterisk + ideally validation); thread an `error` prop through `DateField`/`TimeField`.
|
||||||
|
|
||||||
|
- **Save-button feedback differs (spinner vs text swap); dual-save warehouse forms don't show which action is running.** Document pages render a `CircularProgress` and track `savingAction` so only the clicked button spins (`OfferDetail.tsx:764`); most other forms just swap to text (`ProjectDetail.tsx:346`). On `WarehouseIssueForm` both save buttons swap to identical "Ukládání..." and disable (`:455-465`) — you can't tell which is in flight. **Fix:** adopt one affordance app-wide (spinner+disabled with per-action targeting); spin only the clicked warehouse action.
|
||||||
|
|
||||||
|
- **Note typed in the create-project modal is saved to the legacy column and shown as non-editable.** The "Přidat projekt" modal's Poznámka writes `projects.notes` (`Projects.tsx:164`, `projects.service.ts:222`), but `ProjectDetail` renders that column read-only under "Starší poznámka (před zavedením systému)" (`:477,491`). So a note typed at creation is instantly mislabeled as legacy and uneditable, while the real editable `project_notes` system lives only on the detail page. **Fix:** drop the Poznámka field from the create modal, or route the create-time note through `createProjectNote`.
|
||||||
|
|
||||||
|
### 4.6 Loading, empty & error states
|
||||||
|
|
||||||
|
- **Failed list fetches silently render the empty state.** `usePaginatedQuery` exposes `isError`/`error` (`:47-48`) but no list page reads them, and `queryClient.ts` has no `QueryCache.onError`. So a 500/network drop falls through to the `DataTable` empty slot — Projects shows "Zatím nejsou žádné projekty" (`:480`). Detail pages handle errors loudly; `WarehouseReports.tsx:350` shows an inline `<Alert severity=error>`. **Fix:** add a `QueryCache.onError` toast and/or render an inline error distinct from empty when `isError`, with retry wired to `refetch`.
|
||||||
|
|
||||||
|
- **Empty states built two ways, and clickable-CTA vs text-only split.** Shared `<EmptyState>` is used by document/warehouse lists, but Projects/Vehicles/Users hand-roll `Box(textAlign,py:6)+Typography+Button` (`Projects.tsx:471/480`, `Vehicles.tsx:323`, `Users.tsx:350`), reinventing the `action` prop. Offers renders a clickable "Vytvořit první nabídku" (`:835`) while Invoices/ReceivedInvoices only name the button in description text (`Invoices.tsx:770`). **Fix:** migrate hand-rolled empties to `<EmptyState title description action>` and standardize the clickable-create-CTA convention.
|
||||||
|
|
||||||
|
- **Empty-state microcopy is inconsistent** (fragments vs sentences, trailing-period split, three filtered-empty phrasings, 2nd person). Fragments with no period (`AuditLog.tsx:368`, `WarehouseReports.tsx:223,364`) vs full sentences (`AttendanceHistory.tsx:653`). Filtered-empty worded three ways: "neodpovídají filtru" (`Offers.tsx:827`) / "neodpovídají filtru." (`Projects.tsx:473`) / "neodpovídají hledání." (`ReceivedInvoices.tsx:859`). Nothing-yet mixes "Zatím nejsou žádné X." with 2nd-person "Zatím nemáte žádné žádosti" (`LeaveRequests.tsx:253`). **Fix:** adopt one canonical filtered-empty string, one unfiltered-empty string, one trailing-period rule.
|
||||||
|
|
||||||
|
- **Changing a month/filter gives three different loading behaviors.** Document/warehouse lists dim the card via `isFetching` opacity (`Offers.tsx:815`). `TripsHistory`/`TripsAdmin` keep stale data with NO feedback (`:304-306`/`:810-812`). `AttendanceHistory` uses plain `useQuery` with no `placeholderData`, so a month change flips `isPending` and swaps the whole table for a centered spinner (`:523,646`). **Fix:** standardize on the `isFetching` card-dim; give Trips screens the dim and `AttendanceHistory` `keepPreviousData`.
|
||||||
|
|
||||||
|
- **No real skeletons; 40 list pages blank the whole page to a centered spinner (layout shift); "skeleton" identifiers are misnamed.** The only first-paint affordance is `LoadingState` (centered spinner, `LoadingState.tsx:10-22`); 40 pages early-return it before rendering chrome (`Projects.tsx:267`, `Offers.tsx:492`), so title/tabs/filter bar vanish then snap in. There are zero MUI `<Skeleton>`s despite identifiers like `showListSkeleton` (`ReceivedInvoices.tsx:357`). **Fix:** render `PageHeader`+`FilterBar` immediately and confine the spinner (or a real DataTable skeleton — the `skeleton-boha` skill exists) to the table area; at minimum rename the misleading identifiers.
|
||||||
|
|
||||||
|
- **Redundant sequential/stacked spinners.** First visit to a lazy section shows the Suspense fallback spinner (chunk download, `AdminApp.tsx:112`), then the page's own `isPending` shows a SECOND centered spinner. Separately `ReceivedInvoices` never early-returns: `renderKpi` shows `<LoadingState/>` (`:752`) AND the list shows one (`:846`) — two stacked spinners on cold load, where sibling `Invoices` shows one. **Fix:** render a single unified loading state per page (have lazy pages supply their own shell as the Suspense fallback).
|
||||||
|
|
||||||
|
- **Dashboard hand-rolls its loading spinner and mixes empty-state styles.** `Dashboard.tsx:313-316` and `DashSessions.tsx:161-164` inline `Box+CircularProgress` (different padding/size, no aria-label) vs the shared `<LoadingState/>`. `DashTodayPlan`/`DashSessions` hand-roll Typography empties while `DashActivityFeed`/`DashAttendanceToday` use `<EmptyState>`. On the most-visited page. **Fix:** use `<LoadingState/>` and standardize card empties on `<EmptyState>`.
|
||||||
|
|
||||||
|
- **Profile, 2FA and active sessions are gated behind the heavy dashboard aggregate query.** `DashProfile` and `DashSessions` render only inside `{!dashLoading && (...)}` (`Dashboard.tsx:505`), where `dashLoading` is the multi-domain aggregate (attendance+offers+invoices+orders+projects+leave). Neither block depends on that data — `DashProfile`'s 2FA comes from `totpStatusOptions`, `DashSessions` has its own query (`:74`). **Fix:** render `DashProfile`/`DashSessions` independently; gate only the data-dependent cards.
|
||||||
|
|
||||||
|
- **Autocomplete pickers show English "No options."** `MuiProvider` sets only the date-picker locale, no global `csCZ` `localeText` (`:15-22`); `CustomerPicker.tsx:49`/`SupplierPicker.tsx:49` omit `noOptionsText`, so empty search shows MUI's built-in English. `ItemPicker.tsx:61` correctly sets "Žádné položky." **Fix:** add MUI `csCZ` component `localeText` (fixes every Autocomplete/pagination at once) or at minimum `noOptionsText` on the two pickers.
|
||||||
|
|
||||||
|
### 4.7 List-page consistency
|
||||||
|
|
||||||
|
- **Invoices/ReceivedInvoices/AuditLog search fires a query per keystroke (no debounce).** Offers/IssuedOrders/ReceivedOrders/Projects/Warehouse use `useDebounce(value,300)`, but `Invoices.tsx:282`, `ReceivedInvoices.tsx:250` and `AuditLog.tsx:202` feed the raw value into the query key — laggier and hammering the API on the busiest financial screens. **Fix:** wrap in `useDebounce` (or a shared `SearchField`).
|
||||||
|
|
||||||
|
- **DataTable `onRowClick` rows aren't keyboard-operable; row-click only exists on warehouse lists.** Warehouse Items/Issues/Inventory/Receipts open via clicking the row (their _only_ entry), but Offers/Invoices/Orders/Projects/Users/Vehicles don't set `onRowClick`. Worse, the clickable `TableRow` has no `tabIndex`/`role`/`onKeyDown` (`DataTable.tsx:285-303,137-161`), so a keyboard user **cannot** open a warehouse record. `OdinSidebar` already has the correct pattern (`:126-140`). **Fix:** add `role="button"`, `tabIndex=0`, Enter/Space handling when `onRowClick` is set; then pick one row convention.
|
||||||
|
|
||||||
|
- **Column sorting absent on warehouse-document, audit-log and trips tables that look sortable.** `DataTable` renders sort headers only when columns carry `sortKey` and the page wires `onSort`. Offers/Invoices/Orders/Projects/WarehouseItems do; `WarehouseIssues.tsx:258`, `AuditLog.tsx:364`, `TripsAdmin.tsx:815` and WarehouseReceipts don't — you can't reorder warehouse docs by date/status nor the audit log by user, with no cue distinguishing a sortable header from a dead one. **Fix:** wire `useTableSort`+`sortKey`+`onSort` into the large paginated tables.
|
||||||
|
|
||||||
|
- **Sibling Order tabs disagree on the "all" status label.** `IssuedOrders` uses "Všechny" (`:60`), `ReceivedOrders` "Všechny stavy" (`:54`) under the same parent Orders tabs. **Fix:** unify the label (don't force the Tabs-vs-Select control change — IssuedOrders deliberately keeps a Select).
|
||||||
|
|
||||||
|
- **Filter baseline diverges: search/date/reset missing where needed.** WarehouseIssues/Receipts have search+status+date-range+reset; `WarehouseInventory.tsx:149` has only a status dropdown (no search despite `session_number`); `WarehouseReservations.tsx:354` has no text search or date filter; `AuditLog.tsx:321` (5 filters) and `TripsAdmin.tsx:734` (3 filters) have **no reset**. **Fix:** standardize a list-filter baseline; add the missing search/date/reset.
|
||||||
|
|
||||||
|
- **Offers/IssuedOrders filter by customer/supplier but the Invoices list cannot.** Offers has a customer Select (`:797-812`), IssuedOrders a supplier Select (`:400-415`), but the equally customer-centric Invoices list offers only free-text search (`:739-751`). **Fix:** add a customer Select (load via `offerCustomersOptions`, wire `customer_id` into list+totals; may need a small backend add).
|
||||||
|
|
||||||
|
- **Count subtitle missing on Projects/Users/Vehicles/Customers and the Přijaté tab; count copy splits `czechPlural` vs hand-rolled ternary.** Projects/Users/Vehicles show a bare title (`Projects.tsx:427`, `Users.tsx:338`, `Vehicles.tsx:311`), Customers a static description (`:492`), and Vydané shows a count line but Přijaté doesn't. The plural is hand-rolled five times (`WarehouseIssues.tsx:112-114`) where `czechPlural()` exists (`formatters.ts:69`). **Fix:** add a result-count subtitle via `PageHeader`; replace the hand-rolled ternaries with `czechPlural()`.
|
||||||
|
|
||||||
|
- **Many pages hand-roll the title bar instead of `PageHeader`, so multi-button headers wrap raggedly on mobile.** `PageHeader`'s `headerActionsSx` makes header buttons stack full-width on phones (`PageHeader.tsx:19-30`), but ~20 pages hand-roll `Box+Typography h4 + raw action Box` (Projects, Users, Vehicles, TripsAdmin, Attendance, AttendanceAdmin, Settings, CompanySettings). The multi-button ones (AttendanceAdmin Tisk/Vyplnit/Přidat `:179-198`; TripsAdmin Tisk/Vozidla `:699-731`) wrap into a ragged half-width grid on phones. **Fix:** route through `<PageHeader>` or wrap the action Box in `headerActionsSx`.
|
||||||
|
|
||||||
|
- **Three forked invoice PDF openers → inconsistent feedback and different error copy.** Shared `useDocumentListPdf`/`useDocumentPdf` pre-opens the tab, shows a per-row spinner, guards double-clicks, handles 401 and revokes the blob URL (`useDocumentPdf.ts:37-86`); Offers/IssuedOrders use it. But Invoices reimplements it inline (`:361`), ReceivedInvoices reimplements with **no** loading state (`:561`), and InvoiceDetail forks its own (`:1175`). Same failure yields three different sentences. **Fix:** route all three through the shared hook (make the error string a parameter — received-invoice `openFile` streams a stored upload, not a generated PDF).
|
||||||
|
|
||||||
|
- **Invoices LIST shows a PDF button on draft invoices, dead-ending in a 404.** Offers/IssuedOrders hide the PDF icon for drafts and InvoiceDetail's own header hides it on drafts (`:1890`), but the Invoices list renders it for every invoice gated only on permission (`:616-626`). **Fix:** gate on `inv.invoice_number` too, matching `Offers.tsx:660`/`IssuedOrders.tsx:303`.
|
||||||
|
|
||||||
|
- **Customers and Suppliers directories use opposite data/search/pagination models.** Built as mirrors, but `OffersCustomers` fetches the entire list once and filters client-side with no debounce/pagination (`:159,380`), while `WarehouseSuppliers` is server-paginated, debounced, page-sized with real Pagination (`:139,449`). **Fix:** align Customers on the Suppliers model (server pagination/search), preserving instant responsiveness via `keepPreviousData`/quick debounce.
|
||||||
|
|
||||||
|
### 4.8 Navigation & information architecture
|
||||||
|
|
||||||
|
- **Received-order detail returns you to the wrong Orders tab.** Received orders live under Orders→"Přijaté", but `OrderDetail`'s Zpět/post-delete/fetch-error redirects all go to `/orders` with no tab, and Orders defaults to "vydane" (`Orders.tsx:78-79`) — so you land on the issued-orders list (a different doc type). IssuedOrderDetail does it right with `/orders?tab=vydane`. **Fix:** point `OrderDetail`'s links (`:116,280,399`) at `/orders?tab=prijate`.
|
||||||
|
|
||||||
|
- **The "Zpět" back control is built four different ways.** No shared back primitive: document detail puts a labeled outlined Zpět on the LEFT (`OfferDetail.tsx:643`); warehouse detail puts it on the RIGHT inside PageHeader (`WarehouseItemDetail.tsx:372`); warehouse forms use an icon-only IconButton on the left (`WarehouseReceiptForm.tsx:423`); `AttendanceCreate` uses a text "← Zpět na správu" on the right (`:173`). All hardcode the list route. **Fix:** add an optional `back={{to,label}}`/`onBack` prop to `PageHeader` rendering one canonical left-aligned icon+label control.
|
||||||
|
|
||||||
|
- **No per-page browser title — every tab/bookmark/history entry reads "Admin."** `index.html:22` hardcodes `<title>Admin</title>` and nothing updates it (zero `document.title` refs). With multiple tabs open (common here), the tab strip/history/bookmarks all read "Admin." **Fix:** add a `<TitleSync>` setting `document.title` per route from `navData.tsx` labels (`+ " · BOHA"`).
|
||||||
|
|
||||||
|
- **No route breadcrumbs on deep detail/form pages.** Deep destinations (`/warehouse/items/:id`, `/warehouse/receipts/:id/edit`, `/orders/issued/:id`) give only one back link to a single hardcoded list, with no "Sklad › Položky › <item>" trail. Combined with the missing title, deep-page orientation is thin. **Fix:** add a lightweight section › list › record breadcrumb derived from route + `navData` metadata.
|
||||||
|
|
||||||
|
- **`settings.templates` gates the Nastavení nav item but Settings bounces such users to the dashboard.** The gear shows if the user holds any of five permissions including `settings.templates`, but `canAccessSettings` checks only roles/company/system/banking and `Navigate`-to-`/` otherwise (`Settings.tsx:199-200,421-423`). So a templates-only user sees the gear, clicks, and is teleported away. Meanwhile the offer-templates editor (`/offers/templates`, `OffersTemplates.tsx:137`) has **no** nav entry — only a "Šablony" button buried in the Offers header (`Offers.tsx:750`). **Fix:** drop `settings.templates` from the Nastavení gate (`navData.tsx:486-492`) and give the templates editor a discoverable home.
|
||||||
|
|
||||||
|
- **Three different "access denied" experiences.** ~40 pages render the polished `<Forbidden/>` (403 + explanation + "Zpět na Dashboard"); Settings silently `Navigate`-to-`/` (`:421-423`); Odin shows a bare unstyled line of text (`Odin.tsx:9-17`). **Fix:** standardize on `<Forbidden/>` everywhere.
|
||||||
|
|
||||||
|
- **"Zákazníci" master data sits under the misleading URL `/offers/customers`.** Customers are a top-level entity referenced by offers, orders and invoices, yet the route implies a sub-resource of offers — visible in code as the `matchExclude:['/offers/customers','/offers/templates']` hack the Nabídky nav item needs (`navData.tsx:250`). **Fix:** move customers to a top-level `/customers` (redirect from the old URL), removing the hack.
|
||||||
|
|
||||||
|
- **Master-data lists are scattered across four nav sections with no cross-links.** Zákazníci under Administrativa, Dodavatelé under Sklad, Vozidla under Kniha jízd, Uživatelé under Systém (`navData.tsx:226,297,448,470`). Salient consequence: issued orders (Administrativa) pull suppliers from `sklad_suppliers` (Sklad), so fixing a supplier means jumping to Sklad. **Fix:** at minimum add cross-links (e.g. "spravovat dodavatele" from the issued-order supplier picker → `/warehouse/suppliers`).
|
||||||
|
|
||||||
|
- **Sklad nav is a flat 10-item list mixing daily ops with one-time config.** Ten ungrouped items (`navData.tsx:314-463`) intermix `warehouse.operate` daily drivers with rarely-touched `warehouse.manage` config (Kategorie/Lokace/Dodavatelé). **Fix:** split into operations vs "nastavení skladu" subgroups (or move the three config screens behind one entry).
|
||||||
|
|
||||||
|
- **One "Vytvořit objednávku" button navigates to a page on one tab and opens a modal on the other.** On Objednávky: Vydané routes to `/orders/issued/new`, Přijaté opens a modal (`Orders.tsx:119-132`, `ReceivedOrders.tsx:606`). Across modules, offers/invoices/issued orders use routed full-page forms while received orders create in a modal. **Fix:** pick one pattern per tier or split into two distinct labelled actions.
|
||||||
|
|
||||||
|
- **No skip-to-content link — keyboard users tab the whole sidebar on every page.** A permanent 248px sidebar with many links renders before `<main>` (`AppShell.tsx:130-145,210-223`), with no skip link and no id on `<main>`. **Fix:** add a visually-hidden "Přeskočit na obsah" link (visible on focus) targeting `id="main-content"`.
|
||||||
|
|
||||||
|
### 4.9 Discoverability & workflow friction
|
||||||
|
|
||||||
|
- **"Moje žádosti" has no create action.** Its header has no actions and its empty state literally instructs filing on the Docházka page (`LeaveRequests.tsx:241,254`); the only way to open the leave form is a button buried on the punch screen (`Attendance.tsx:936,978`). **Fix:** add a "Nová žádost" button to `LeaveRequests` opening the same modal; lift the modal into a shared component.
|
||||||
|
|
||||||
|
- **Dashboard "Vaše dnešní zařazení" card shows the project as plain text — every other dash card links onward.** `DashTodayPlan` renders `projectLabel` as plain text (`:69`) while other cards link "Vše →"/"Detail →." **Fix:** wrap `projectLabel` in a `RouterLink` to `/projects/:id` (id is already in `TodayPlan`); optionally add a "Plán prací →" header button.
|
||||||
|
|
||||||
|
- **Issue detail shows the consumed reservation as a raw "ID: n" database identifier** (`WarehouseIssueDetail.tsx:204`), unlike the project reference beside it (a real link). **Fix:** at minimum render "R{id}" to match `ReservationPicker`'s own label (`:65`).
|
||||||
|
|
||||||
|
- **`ReservationPicker` is dead code — a manual Výdejka can't draw down an existing reservation.** A ready-made picker exists (`ReservationPicker.tsx:14`) but is imported nowhere; the issue form only sets `reservation_id` from router prefill (`WarehouseIssueForm.tsx:183`). **Fix:** render it in the issue-form line (gated on item_id+project_id, mirroring `BatchSelect`) — or delete the unused component.
|
||||||
|
|
||||||
|
- **Audit log surfaces only the one-line description — the stored old/new values are on the wire but never shown.** `audit_logs` stores `old_values`/`new_values` and the route returns full rows (`routes/admin/audit-log.ts:64-74`), but the page's row type omits them and rows aren't expandable (`AuditLog.tsx:97-105,229-275`). The most valuable part of an audit trail is invisible. **Fix:** add the fields to the type and open a detail modal rendering old→new.
|
||||||
|
|
||||||
|
- **Odin's empty state never hints it can query real system data.** The landing hero only says "Zeptejte se na cokoli, nebo přiložte fakturu…" (`OdinThread.tsx:82-149`), giving no hint Odin can query invoices/offers/orders/projects/warehouse via its read-only tools. **Fix:** add 3-4 clickable example-prompt chips that prefill the composer (`OdinChat.tsx:218-219`).
|
||||||
|
|
||||||
|
- **Keyboard shortcuts ship undiscoverable; `ShortcutsHelp` is a permanent no-op.** `ShortcutsHelp` is mounted but returns null (`:1-7`, `AppShell.tsx:226`), while Ctrl+Enter (note save), Enter (Odin send), Enter (plan category) ship with no help. **Fix:** build the "?"-opens-cheat-sheet overlay, or as a cheap first step add `title` hints (copy `OdinComposer`'s `title="Odeslat (Enter)"` onto `NoteCard`'s Ctrl+Enter).
|
||||||
|
|
||||||
|
- **Plan grid resets to the current week on every remount.** `usePlanWork` inits view/anchor to `new Date()` with no persistence (`:112-113`), so leaving and returning to `/plan` loses your scroll position. **Fix:** persist view+anchor (URL query or localStorage).
|
||||||
|
|
||||||
|
- **"Zpět na správu" / post-save links pass `?month=YYYY-MM` but AttendanceAdmin ignores it.** `AttendanceCreate` (`:141`) and `AttendanceLocation` (`:211`) link to `/attendance/admin?month=…`, but `useAttendanceAdmin` never reads the URL (hard-inits month to current). **Fix:** seed the initial month from `useSearchParams()` and keep it synced.
|
||||||
|
|
||||||
|
- **Receipt/Issue details never show a document-level total.** They compute per-line "Celkem" but the `DataTable` has no footer/sum (`WarehouseReceiptDetail.tsx:250`, `WarehouseIssueDetail.tsx:182`); forms show no running total. Every other money-bearing document leads with a prominent total. **Fix:** add a summary line (sum of qty×unit_price via `formatCurrency`) under the Položky table and a live running total in the forms.
|
||||||
|
|
||||||
|
- **Inventory form only creates a draft; Receipt/Issue forms offer "Uložit a potvrdit" in one action.** `WarehouseInventoryForm` offers only "Vytvořit inventuru" (`:204`); to post you must go to the detail and click "Potvrdit inventuru" (`:209`). **Fix:** add a "Vytvořit a potvrdit" button (the form already collects `actual_qty`), OR surface a hint explaining the deliberate two-step variance-review flow.
|
||||||
|
|
||||||
|
- **Trips admin can't enter a trip on behalf of a driver, though attendance admin can create for any employee.** `TripsAdmin` offers only Tisk + a Vozidla link (`:710`), no "add trip," and its edit modal has no driver picker. The `/trips` POST already accepts `body.user_id` (`trips.ts:341`). **Fix:** add an "Přidat jízdu" action with a driver selector, mirroring `AttendanceAdmin`.
|
||||||
|
|
||||||
|
- **⚠ Found during verification (backend, not UX): `POST /trips` accepts `body.user_id` without a `trips.manage` check.** Any user holding only `trips.record` can create a trip attributed to another user's id (`src/routes/admin/trips.ts:341`, `trips.schema.ts:13`) — reads are correctly scoped (`buildTripsWhere` limits non-managers to their own trips) but the create path is not. Minor impersonation-on-create authz gap. **Fix:** ignore `body.user_id` (force `authData.userId`) unless the caller holds `trips.manage`.
|
||||||
|
|
||||||
|
- **Personal Trips "Záznam" shows company-wide trips/stats for managers.** Unlike "Moje historie" (filters by `userId`), the personal Trips page calls `tripListOptions`/`tripStatsOptions` with no `userId` and renders a "Řidič" column (`Trips.tsx:166,359`) — surfacing all drivers' trips and company-wide totals, unlike the strictly-personal attendance punch screen. **Fix:** pass `userId` (matching `TripsHistory`), or relabel/drop the Řidič column. Keep list and stats on one scope.
|
||||||
|
|
||||||
|
### 4.10 Forked/duplicated shared modules & code drift
|
||||||
|
|
||||||
|
- **`InvoiceDetail` forks the shared document editor (and drops the item-description maxLength cap).** Offers/issued orders compose shared `DocumentItemsEditor`/`useDocumentPdf`/`useUnsavedChangesGuard`; `InvoiceDetail` reimplements all three (`:241-541,1175-1196,942-958`). Divergence: the invoice item description textarea has **no maxLength** while the shared editor caps it (`DocumentItemsEditor.tsx:390`) — an over-long description 500s at Prisma instead of capping client-side. **Fix:** add a `showVat`/`vatOptions` flag to `DocumentItemsEditor` (respecting "VAT only on invoices") and migrate `InvoiceDetail` onto it plus the shared hooks.
|
||||||
|
|
||||||
|
- **Status label/color maps duplicated across warehouse, projects and leave.** `documentStatus.ts` exists specifically to kill per-page maps, yet DRAFT/CONFIRMED/CANCELLED recur across 6 warehouse files (`WarehouseIssues.tsx:35-48`), `Projects.tsx:44-57`/`ProjectDetail.tsx:37-50` are byte-identical, and `LeaveRequests.tsx:25-55`/`LeaveApproval.tsx:36-66` are byte-identical. **Fix:** add `WAREHOUSE_DOC_STATUS`, `MOVEMENT_TYPE`, `RESERVATION_STATUS`, `PROJECT_STATUS`, `LEAVE_STATUS`/`LEAVE_TYPE` to `documentStatus.ts` and import them.
|
||||||
|
|
||||||
|
- **`AttendanceAdmin` fetches outside React Query (raw `apiFetch` in `useEffect` + manual refetch dance).** `useAttendanceAdmin` loads projects/users/records via raw `apiFetch` with its own `setData`/`setLoading` (`:823,871`), so it doesn't participate in `['attendance']` invalidations — each mutation must invalidate AND manually re-run `fetchData` with a `setTimeout(300)` (`:1057-1063`). The single biggest structural inconsistency and a latent stale-data risk. **Fix:** migrate reads to React Query options.
|
||||||
|
|
||||||
|
- **Orphaned standalone attendance-create page diverges from the admin modal (no project logs).** Adding a record exists twice: a standalone full-page form at `/attendance/create` AND `ShiftFormModal` (`AttendanceAdmin.tsx:194`). The modal supports per-shift project-time logs; the standalone page can't (`AttendanceCreate.tsx:63-147`), and nothing navigates to it. **Fix:** delete `AttendanceCreate.tsx` and its route — the modal is the live superset.
|
||||||
|
|
||||||
|
- **"Vytvořit objednávku" modal uses two different file pickers.** The Offers-list version uses the shared `FileUpload`; the Offer-detail version reimplements it with a raw `<input type=file>` + manual readout + custom remove button (`OfferDetail.tsx:1066-1112` vs `Offers.tsx:894-929`). Same dialog, two attachment UIs. **Fix:** replace the raw input with `FileUpload` (gains drag-drop/validation).
|
||||||
|
|
||||||
|
- **Warehouse entities use three different create/edit interaction models.** Items edit inline on the detail page via a toggle (`WarehouseItemDetail.tsx:112`); Receipts/Issues use a full-page Form + read-only Detail; Inventory has a Form for creation only; Reservations/Categories/Locations/Suppliers use a list-page Modal. Items (a flat record like Suppliers) using inline edit is the main outlier. **Fix:** document one convention (line-item records = Form+Detail; flat lookups = list modal) and reconcile the Items outlier — treat as a documented rule, not a forced risky migration.
|
||||||
|
|
||||||
|
- **Inconsistent "record not found": Item detail redirects away, siblings keep a stable URL.** `WarehouseItemDetail` toasts + `navigate('/warehouse/items')` (`:153-158`); `WarehouseIssueDetail`/`ReceiptDetail`/`InventoryDetail` render an in-place "nebyla nalezena" EmptyState with a Back button and keep the URL (`:122`/`:200`/`:99`). **Fix:** align Item detail to the in-place EmptyState pattern.
|
||||||
|
|
||||||
|
- **Settings save model is inconsistent.** The System tab has **two** "Uložit" buttons doing the same thing (`Settings.tsx:997-1007,1193-1199`); other cards have no save; 2FA saves instantly; on Firma, bank accounts/logo apply instantly while company info/numbering/currency need the bottom save (`CompanySettings.tsx:990-1013,1457-1461`). The user can't predict whether a change is live or pending. **Fix:** drop one duplicate System Save; visually distinguish auto-apply vs pending controls.
|
||||||
|
|
||||||
|
- **Attendance location page formats datetime with `new Date()` instead of the timezone-safe shared helper.** `AttendanceLocation` defines a local `formatDatetimeLocal` parsing with `new Date()` (`:176`); the rest of attendance uses the timezone-safe `formatDatetime` (`attendanceHelpers.ts:42`), so the same timestamp renders differently. **Fix:** build a regex-based timezone-safe formatter that keeps the year (the location detail legitimately wants the year, which `formatDatetime` omits).
|
||||||
|
|
||||||
|
- **Document editor uses two reorder gestures side by side.** Line items reorder via a drag handle (`DocumentItemsEditor.tsx:350-364`), but sections (`SectionsEditor.tsx:200-219`) and company custom fields (`CompanySettings.tsx:1049-1067`) use up/down arrows — on the same page. The drag-only items are also less keyboard-discoverable. **Fix:** add up/down arrow buttons alongside the line-item drag handle (matching SectionsEditor) — also fixes the keyboard-affordance gap.
|
||||||
|
|
||||||
|
- **Login re-implements the theme toggle.** The shell uses shared `<ThemeToggle/>` (animated crossfade, title + aria-label); Login builds its own IconButton with hand-duplicated SVGs, only a title, no aria-label, no animation (`Login.tsx:264-313` vs `ThemeToggle.tsx:36-57`). The first screen every user sees is less accessible. **Fix:** reuse the shared `<ThemeToggle/>`.
|
||||||
|
|
||||||
|
### 4.11 Visual & theming consistency
|
||||||
|
|
||||||
|
- **Projects status-chip colors diverge from the app-wide convention (and duplicate the label map).** `documentStatus.ts` establishes success=done/paid, error=cancelled, info=open. Projects breaks all three — ACTIVE=green, COMPLETED=**blue**, CANCELLED=**grey** (`Projects.tsx:50`, `ProjectDetail.tsx:43`). So a finished project shows blue while a finished order shows green. **Fix:** add a `PROJECT_STATUS` map (aktivni→info, dokonceny→success, zruseny→error) and consume via `statusLabel`/`statusColor`.
|
||||||
|
|
||||||
|
- **Linked order status on Project detail renders as a raw untranslated token.** `ProjectDetail` shows `STATUS_LABELS[project.order_status]` but `STATUS_LABELS` is the _project_ map while `order_status` is `prijata`/`v_realizaci`/… — zero key overlap (`:577`), so a completed order shows "(dokoncena)" lowercase plain text. **Fix:** render via `statusLabel(ORDER_STATUS, project.order_status)` (ideally a `StatusChip`).
|
||||||
|
|
||||||
|
- **Settings admin-role callout uses a solid `info.light`/`info.dark` fill (the forbidden .light-fill anti-pattern).** The "Administrátor má vždy plný přístup" box is hand-styled with `bgcolor:'info.light'`/`color:'info.dark'` (`Settings.tsx:1228-1229`) — the single solid palette-.light fill in the admin, violating the documented channel-alpha rule; in dark mode it's garish low-contrast blue-on-blue. **Fix:** use the kit `<Alert severity='info'>` (`Alert.tsx:8`), or a channel-alpha wash.
|
||||||
|
|
||||||
|
- **Dashboard "Vystavit fakturu" quick action reuses the destructive error/red palette.** `DashQuickActions` sets color `'danger'` → MUI error (`:296`,`:59`), the same red as every Delete button, so "Issue invoice" reads as destructive beside green/blue/orange create actions. _(Caveat: red is also the invoice PDF brand family (#de3a3a), and "Odchod" uses `danger` too — this may be deliberate branding; treat as optional.)_ **Fix:** if not intentional, change to a non-destructive color (info/primary); reserve error/red for deletes.
|
||||||
|
|
||||||
|
- **Plan `DayInRangeModal` shows raw ISO dates while the rest of the plan dialogs format cs-CZ.** It prints "2026-06-28 – 2026-07-02" (`PlanCellModal.tsx:466,474,495`) while ViewModal/DayPanel format "28. 6. 2026." **Fix:** wrap the dates in `formatDate()`.
|
||||||
|
|
||||||
|
- **Project delete confirm differs between list and detail.** The list ConfirmDialog always renders the "Smazat i soubory na disku" checkbox even for projects with no NAS folder, with terse copy, no name, no irreversibility warning (`Projects.tsx:508,516`); the detail gates the checkbox on `has_nas_folder` and uses richer copy + "Tato akce je nevratná." (`ProjectDetail.tsx:621,626`). **Fix:** gate the list checkbox on `has_nas_folder` and use the same copy (name + warning).
|
||||||
|
|
||||||
|
### 4.12 Microcopy & localization
|
||||||
|
|
||||||
|
- **Goods-receipt module named four ways; "Příjmy" also reads as financial income.** Nav "Příjmy" (`navData.tsx:348`), overview button "Nový příjem" (`Warehouse.tsx:203`), list title "Příjmové doklady" + "Nový doklad" (`WarehouseReceipts.tsx:170,178`), detail/audit "Příjemka" (`WarehouseReceiptDetail.tsx:204`, `entityTypeLabels.ts:29`). Issues mirror this (Výdeje/Výdejky/Výdejka). "Příjmy" is the standard Czech word for _income_, so the nav label is actively misleading. **Fix:** settle on "Příjemka"/"Výdejka" everywhere.
|
||||||
|
|
||||||
|
- **Saving/busy labels inconsistent; one "Chyba pripojeni" diacritics typo; Modal overrides caller `submitText` mid-save.** Competing forms: "Ukládám…" (Modal, AttendanceCreate, NoteCard, DashProfile), "Ukládání..." (most pages), "Zpracovávám…" vs "Zpracovávám...", plus "Vytváření..."/"Odesílám..."; ellipsis sometimes "…" sometimes three dots. The connection-error fallback is "Chyba pripojeni" (no diacritics) on the dashboard (`Dashboard.tsx:127`, `AuthContext.tsx:256,314`) vs "Chyba připojení" elsewhere. Modal hardcodes `loading?'Ukládám…':submitText` (`:96`), overriding "Vytvořit uživatele" mid-save. **Fix:** fix the typo (outlier vs ~90 sites); standardize the busy label + Unicode ellipsis; the Modal hardcoding is acceptable for save forms (lower priority).
|
||||||
|
|
||||||
|
- **"Cancelled" order status spelled two ways in `documentStatus.ts`.** Received orders use `stornovana`→"Stornována" (`:49`); issued orders use `cancelled`→"Stornovaná" (`:58`) — side-by-side under the same `/orders` page. These are display labels (safe to change). **Fix:** pick one Czech form for both.
|
||||||
|
|
||||||
|
- **Add-line button uses literal "+ Přidat položku" in document editors vs icon + "Přidat položku" in warehouse forms.** `DocumentItemsEditor.tsx:596` (literal plus, no startIcon) vs warehouse forms with `startIcon={PlusIcon}` (`WarehouseIssueForm.tsx:601` etc.). **Fix:** standardize the affordance and label.
|
||||||
|
|
||||||
|
- **Create/edit modal submit labels mix entity-specific, bare, and default wording.** "Vytvořit zákazníka"/"Uložit změny" (`OffersCustomers.tsx:543`), "Vytvořit uživatele" (`Users.tsx:369`), bare "Vytvořit"/"Uložit" (`OffersTemplates.tsx:361`), and Vehicles falls back to the Modal default "Uložit" (`Modal.tsx:36`). **Fix:** adopt one rule (create="Vytvořit", edit="Uložit změny") and set it as the Modal default.
|
||||||
|
|
||||||
|
- **Create-action verb differs across analogous list pages; both Orders tabs share the identical "Vytvořit objednávku" label.** Imperative "Přidat X" (master data) vs "Nový/Nová X" (documents) vs "Vytvořit objednávku" (Orders, identical on both tabs for two different doc types, `Orders.tsx:125,129`); the empty-state CTA verb mirrors and compounds this. **Fix:** disambiguate the two Orders tabs first ("Nová vydaná/přijatá objednávka"), then pick one verb convention and align the empty-state CTA.
|
||||||
|
|
||||||
|
- **Several numbers/prices bypass cs-CZ formatting; file-size unit casing disagrees (kB vs KB).** Offers item-template price uses `Number().toFixed(2)`→"1234.50" (period, no separator/currency, `OffersTemplates.tsx:287`); warehouse batch picker builds "…toFixed(2) Kč" (`WarehouseIssueForm.tsx:160`); vacation/sick hour balances use `.toFixed(1)`→"12.5" (`AttendanceBalances.tsx:307`); zeros render "0 Kč" vs "0,00 Kč." File-size helper duplicated with "kB" (`FileUpload.tsx:9`) vs "KB" (`OfferDetail.tsx:1072`). **Fix:** route money through `formatCurrency` and decimals through cs-CZ/Intl; extract one `formatFileSize()` with one unit casing.
|
||||||
|
|
||||||
|
- **Leave-request modal day/hour estimate ignores public holidays.** The modal previews "X pracovních dnů" using `calculateBusinessDays` (Mon–Fri only, `Attendance.tsx:498,1242`), but the rest of the system is holiday-aware (server `leave-requests.ts:99` excludes weekends AND holidays). A vacation spanning e.g. 1 May over-counts vs the stored `total_days`/`total_hours`. **Fix:** make the modal estimate holiday-aware (reuse the holiday helper).
|
||||||
|
|
||||||
|
### 4.13 Mobile / responsive
|
||||||
|
|
||||||
|
- **Status-filter tab bars clip on phones — shared `Tabs` has no scrollable variant.** `Tabs.tsx:28-49` uses MUI's default `variant="standard"` (overflow hidden, no scroll). At ~360px the 5 offer/invoice tabs and 4 project tabs overflow the centered Box and get clipped with no scroll affordance — effectively unreachable. **Fix:** set `variant="scrollable"`, `scrollButtons="auto"`, `allowScrollButtonsMobile` — one change fixes every status filter and the issued/received toggle.
|
||||||
|
|
||||||
|
- **Shared Modal never goes full-screen on phones; leave date grid is hardcoded two-column.** `Modal` renders a plain `fullWidth+maxWidth` Dialog with no responsive `fullScreen` (`:67-75`), so the ~9-field Trip form, the admin shift form and the leave form become a narrow scrolling box at 360px. The leave modal's Od/Do grid is hardcoded `minmax(0,1fr) minmax(0,1fr)` (`Attendance.tsx:1195-1199`) while every other date-pair grid is responsive `{xs:'1fr',sm:'1fr 1fr'}` (`ShiftFormModal.tsx:254`, `Trips.tsx:528-535`). **Fix:** add `fullScreen={useMediaQuery(down('sm'))}` to Modal; fix the leave grid.
|
||||||
|
|
||||||
|
- **Long full-page editors keep Save only in the top header (no sticky save); placement differs page-to-page.** Document detail editors place all Save/Create/Activate in the top `headerActionsSx` slot (`OfferDetail.tsx:688-828`), so after scrolling through header fields/picker/line-items/rich-text/notes a phone user must scroll back to the top to save. `AttendanceCreate` pins Zrušit/Uložit at the **bottom** (`:362-374`); modals pin submit in DialogActions — three placements. **Fix:** add a sticky action bar to long editors and standardize placement.
|
||||||
|
|
||||||
|
- **Warehouse issue/receipt line items lack the labeled card-per-row mobile layout the document editor has.** `DocumentItemsEditor` has a dedicated `isMobile` branch — a bordered card per row with "Položka N" index, labeled fields, per-row total, drag/remove (`:170-342`). `WarehouseIssueForm.tsx:513-567` just collapses a grid to `xs:'1fr'` with placeholder-only fields, no border/labels/separator — once a value is typed the placeholder vanishes and rows blur together. Worst exactly where shop-floor mobile use is likely. **Fix:** reuse the card-per-row pattern (ideally a shared mobile line-item card).
|
||||||
|
|
||||||
|
- **FilterBar children mix fixed-vs-grow flex bases, leaving dead space on mobile.** `AttendanceAdmin.tsx:202,205` use `flex:'0 0 180px'`/`'0 0 220px'` and `AttendanceHistory.tsx:516` uses `flex:'0 0 180px'` (fixed, never grow), so on a phone they sit narrow with dead space beside them, while `Offers.tsx:786` uses `flex:'1 1 320px'` which fills the row. **Fix:** adopt one convention (e.g. `flex:'1 1 <min>px'`) or have `FilterBar` stretch children full-width below `sm`.
|
||||||
|
|
||||||
|
### 4.14 Accessibility
|
||||||
|
|
||||||
|
- **Desktop document line-item grid inputs are unlabeled while the mobile card layout labels every field.** The mobile branch passes explicit labels (Popis, Množství, Jednotka, Jedn. cena, Sleva %); the desktop table renders the same TextFields with **no** label/aria-label, relying on visual `<th>` headers not programmatically associated with the inputs; the desktop "V ceně" Checkbox is unnamed (`DocumentItemsEditor.tsx:374-440,445-450`). A screen-reader user editing an offer on desktop hears a column of unlabeled "edit text" boxes. **Fix:** add an `aria-label` to each desktop cell control (e.g. `Množství, položka ${index+1}`).
|
||||||
|
|
||||||
|
- **List-page search boxes are placeholder-only inputs with no accessible name.** Raw TextFields with only a placeholder (not an accessible name) across ~13 pages (`Offers.tsx:787`, `Invoices.tsx:741`, `WarehouseItems.tsx:180`, `Projects.tsx:454`, `IssuedOrders.tsx:386`). **Fix:** add `aria-label="Hledat"` + `type="search"` — best via a shared `SearchField` (also adds the native clear button and mobile search keyboard).
|
||||||
|
|
||||||
|
- **(Cross-listed) DataTable clickable rows aren't keyboard-operable; no skip-to-content link.** See §4.7 (row semantics) and §4.8 (skip link).
|
||||||
|
|
||||||
|
### 4.15 Performance & bundle
|
||||||
|
|
||||||
|
- **MUI + Emotion live in the 781 kB entry chunk, so every deploy busts the cached UI vendor code.** `vite.config.ts` `manualChunks` only carves out react/react-dom/react-router (436 kB) and framer-motion (147 kB); the entire MUI library + Emotion fall into the entry chunk (781 kB raw / 232 kB gzip). With frequent same-day patch releases, daily users re-download ~232 kB gzip of unchanged MUI every deploy. **Fix:** split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk.
|
||||||
|
|
||||||
|
- **framer-motion (147 kB) is a static dependency of the eager AppShell, loading on first paint incl. Login.** `AppShell`/`PageEnter`/`ThemeToggle` statically import framer-motion (`PageHeader` deliberately does not); `AppShell` is eagerly imported by `AdminApp` (`:8`, `AppShell.tsx:101`). Login pays for it too. **Fix:** migrate to `LazyMotion` + the lightweight `m` component (`domAnimation`) — note Login and the Dash\* components import framer-motion directly, so lazy-gating only `PageEnter` won't remove it from the login critical path.
|
||||||
|
|
||||||
|
- **Dashboard + its 7 Dash\* subcomponents are bundled into the entry chunk, downloaded on the Login screen.** Dashboard and Login are statically imported in `AdminApp` (`:14-15`), so an unauthenticated user hitting `/login` downloads the entire authenticated dashboard's code before typing a password. **Fix:** lazy-load Dashboard via `lazyWithReload` like every other route; keep Login eager.
|
||||||
|
|
||||||
|
- **No route-transition progress indicator and no link prefetch — heavy pages blank to a spinner on click.** Clicking a sidebar item blanks main content to a centered spinner while the chunk downloads (PlanWork 44 kB, InvoiceDetail 35 kB, AttendanceLocation 155 kB with Leaflet), with no top progress bar and no prefetch (zero `prefetchQuery`/`onMouseEnter`/`preload` hits). A `ProgressBar` exists but is reused only as an attendance-fund meter. **Fix:** add an unobtrusive top `LinearProgress` during route transitions and prefetch the lazy chunk (+ primary query) on sidebar link hover/focus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Suggested Sequencing
|
||||||
|
|
||||||
|
**Sprint 1 — "Stop lying to the user" (mostly Quick Wins, ~2-3 days).**
|
||||||
|
Ship the data-freshness convergence (one invalidation set per family), the global `QueryCache.onError` so failed fetches stop showing as empty, the scrollable mobile tabs, the invoice-paid confirm, the silent-toggle `onError` toasts, the draft-PDF 404 gate, the wrong-Orders-tab redirect, the search debounces, the `csCZ` localeText, the `document.title`, the project/order status-color fixes, and the diacritics/label typos. These are individually small, collectively high-trust, and low-risk. Pair each with a quick Playwright check on the dev server (the user runs it).
|
||||||
|
|
||||||
|
**Sprint 2 — "Converge the primitives" (the structural anti-drift work, ~1 week).**
|
||||||
|
De-fork the dirty-guards and add them to the six unguarded editors; route the invoice PDF openers through `useDocumentPdf`; consolidate the status maps into `documentStatus.ts`; standardize validation on inline `<Field error>` (starting with the mirrored screens); add `<form onSubmit>` to Modal; migrate hand-rolled headers to `PageHeader`; replace inline English errors with `apiErrorMessage`. This is where you stop the Sprint-1 fixes from re-drifting, and it's the cleanest fit for the codebase's "extend, never fork" rule.
|
||||||
|
|
||||||
|
**Sprint 3 — "Mobile + a11y + warehouse parity" (~1 week).**
|
||||||
|
Full-screen modals, sticky save bars, warehouse card-per-row line items, keyboard-operable rows, skip link, search-field accessible names, warehouse document totals, the leave "Nová žádost" action, and the warehouse "Potvrdit" confirm.
|
||||||
|
|
||||||
|
**Strategic backlog (schedule deliberately, each is multi-day and one needs a migration).**
|
||||||
|
Invoice edit-locking (DB migration + route trio — coordinate the migration per CLAUDE.md: ask the user to stop the dev server, apply to `app_test` too). The router upgrade for in-app dirty-nav blocking. `AttendanceAdmin` onto React Query. `InvoiceDetail` onto the shared editor. Real skeletons. Bundle splitting + route prefetch. Treat the navigation/IA items (breadcrumbs, master-data nav grouping, `/customers` move) as a focused IA pass once the above settle.
|
||||||
|
|
||||||
|
A note on scope discipline: nearly every item here is "make B match the already-correct A," so the safest pattern is to **read the reference implementation first** (offers/issued orders for documents, `WarehouseSuppliers` for lists, `documentStatus.ts` for status) and converge onto it — rather than inventing new patterns. That keeps the diff small and the regression surface low, which matches the user's same-day-patch-release rhythm.
|
||||||
@@ -46,6 +46,17 @@ without explicit user confirmation.**
|
|||||||
errors from prior pids).
|
errors from prior pids).
|
||||||
9. Clean up tarballs locally and in `/tmp/` on the server.
|
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||||
|
|
||||||
|
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
|
||||||
|
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
|
||||||
|
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
|
||||||
|
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
|
||||||
|
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
|
||||||
|
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
|
||||||
|
`client_max_body_size`, rate limits, etc.). When you do change it, always
|
||||||
|
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
|
||||||
|
nginx` (graceful re-read, keeps active connections; a bad config on reload is
|
||||||
|
rejected and the old one keeps running).
|
||||||
|
|
||||||
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||||
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||||
constraint names the migration DROPs, check no data violates new
|
constraint names the migration DROPs, check no data violates new
|
||||||
|
|||||||
@@ -0,0 +1,789 @@
|
|||||||
|
# Per-document custom-field print selection — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
|
||||||
|
|
||||||
|
**Architecture:** A new nullable `selected_custom_fields` column (JSON-array-in-VARCHAR) on `quotations`, `issued_orders`, `invoices`. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to `number[]`. The PDF `buildAddressLines()` company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real `app_test` DB), React 19 + MUI v7 + React Query.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for this plan
|
||||||
|
|
||||||
|
- This shell is non-interactive — `prisma migrate dev` is forbidden. Use the `migrate diff` recipe (Task 1).
|
||||||
|
- **Before the migration, ask the user to stop their dev server and wait for confirmation** (it holds DB connections). Do not run the migration until they confirm.
|
||||||
|
- Server tests run against `app_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
|
||||||
|
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must stay at 0 errors.
|
||||||
|
- Selection semantics in `buildAddressLines`: param **omitted/`undefined`** ⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an **array** (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `prisma/schema.prisma` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
|
||||||
|
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
|
||||||
|
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `parseSelectedCustomFields`.
|
||||||
|
- **Create** `src/__tests__/selected-custom-fields.test.ts` — util + integration coverage.
|
||||||
|
- **Modify** `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts` — new optional array field.
|
||||||
|
- **Modify** `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts` — persist on create/update, decode in detail.
|
||||||
|
- **Modify** `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts` — filter company custom lines.
|
||||||
|
- **Modify** `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts` — add `selected_custom_fields: number[]` to detail interfaces.
|
||||||
|
- **Create** `src/admin/components/document/CustomFieldsPrintPicker.tsx` — shared picker.
|
||||||
|
- **Modify** `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` — render picker, wire into save payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema column + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma` (models `quotations`, `issued_orders`, `invoices`)
|
||||||
|
- Create: `prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ask the user to stop their dev server**
|
||||||
|
|
||||||
|
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later `migrate deploy` step.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the column to each model in `prisma/schema.prisma`**
|
||||||
|
|
||||||
|
Add this line to the `quotations` model (near other scalar columns, e.g. after `language`):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the identical line to the `issued_orders` model and to the `invoices` model.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration SQL**
|
||||||
|
|
||||||
|
Run (Git Bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cortex/boha-app-ts
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
|
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a `migration.sql` containing three `ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL` statements (one per table). Open it and confirm it touches ONLY `quotations`, `issued_orders`, `invoices` and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "All migrations have been applied" and a regenerated client.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Apply to the test DB**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the same migration applied to `app_test`. (If the env parsing is awkward on this shell, temporarily set `DATABASE_URL` to the app_test URL from `.env.test` and run `npx prisma migrate deploy`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS (the new Prisma field is now known to the client).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations
|
||||||
|
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend encode/decode util (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils/custom-fields.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/__tests__/selected-custom-fields.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to confirm failure**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: FAIL — `encodeSelectedCustomFields is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the helpers**
|
||||||
|
|
||||||
|
Append to `src/utils/custom-fields.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-document selection of which COMPANY custom fields print on a PDF.
|
||||||
|
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
|
||||||
|
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
|
||||||
|
*/
|
||||||
|
export function encodeSelectedCustomFields(indices: unknown): string | null {
|
||||||
|
const clean = normalizeIndices(indices);
|
||||||
|
return clean.length > 0 ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
|
||||||
|
export function parseSelectedCustomFields(raw: unknown): number[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (Array.isArray(raw)) return normalizeIndices(raw);
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return [];
|
||||||
|
try {
|
||||||
|
return normalizeIndices(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON in a selection column degrades to "none" (expected
|
||||||
|
// condition — a hand-edited/legacy row should never 500 a PDF render).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndices(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const v of input) {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to confirm it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (all util cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Zod schemas
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/offers.schema.ts`
|
||||||
|
- Modify: `src/schemas/issued-orders.schema.ts`
|
||||||
|
- Modify: `src/schemas/invoices.schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers schema**
|
||||||
|
|
||||||
|
In `src/schemas/offers.schema.ts`, inside `CreateQuotationSchema`, add after the `sections` line (line 50):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateQuotationSchema` already derives from this via `.partial().omit({ quotation_number: true })` — no change needed there.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Issued-orders schema**
|
||||||
|
|
||||||
|
In `src/schemas/issued-orders.schema.ts`, inside `CreateIssuedOrderSchema`, add after the `sections` field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateIssuedOrderSchema` derives via `.partial()` — no change.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Invoices schema**
|
||||||
|
|
||||||
|
In `src/schemas/invoices.schema.ts`, add the same line to BOTH `CreateInvoiceSchema` (after its `sections` field) and `UpdateInvoiceSchema` (after its `sections` field — invoices define the two schemas separately, so it must be added in both):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
|
||||||
|
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Services — persist on write, decode in detail (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/offers.service.ts`
|
||||||
|
- Modify: `src/services/issued-orders.service.ts`
|
||||||
|
- Modify: `src/services/invoices.service.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to all three services**
|
||||||
|
|
||||||
|
At the top of each of the three service files, add (or extend the existing import from `../utils/custom-fields`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers — persist on create**
|
||||||
|
|
||||||
|
In `createOffer` (`src/services/offers.service.ts`), inside `tx.quotations.create({ data: { … } })`, add after `scope_description`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Offers — persist on update**
|
||||||
|
|
||||||
|
In `updateOffer`, inside the `const data = { … }` object (after `scope_description`, before `modified_at`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
(`undefined` = "key absent in payload, leave column untouched" — matches the sibling header fields.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Offers — decode in detail**
|
||||||
|
|
||||||
|
In `getOffer`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Issued orders — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/issued-orders.service.ts`:
|
||||||
|
|
||||||
|
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Invoices — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/invoices.service.ts`:
|
||||||
|
|
||||||
|
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
|
||||||
|
```ts
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Extend the test with an offers round-trip (real DB)**
|
||||||
|
|
||||||
|
Append to `src/__tests__/selected-custom-fields.test.ts`. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> Before running: confirm the prisma import path (`../config/prisma` vs `../config/db` — grep an existing test). Adjust the import to match.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the `customer_id`-less draft path above avoids them.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Typecheck + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PDF rendering — filter company custom lines (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/routes/admin/offers-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/issued-orders-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/invoices-pdf.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers PDF — add the filter param to `buildAddressLines`**
|
||||||
|
|
||||||
|
In `src/routes/admin/offers-pdf.ts`, change the signature (line 28-32):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildAddressLines(
|
||||||
|
entity: Record<string, unknown> | null,
|
||||||
|
isSupplier: boolean,
|
||||||
|
t: (key: string) => string,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
|
): AddressResult {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
|
const cfName = (cf.name || "").trim();
|
||||||
|
const cfValue = (cf.value || "").trim();
|
||||||
|
const showLabel = cf.showLabel !== false;
|
||||||
|
if (cfValue) {
|
||||||
|
fieldMap[`custom_${i}`] =
|
||||||
|
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers PDF — pass the document's selection into the COMPANY call**
|
||||||
|
|
||||||
|
The offer's sender/company block is `supp` (`isSupplier: true` on `settings`). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (`cust`) unchanged so customer custom fields still show in full:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings as unknown as Record<string, unknown>,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing to adjust here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/issued-orders-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
|
||||||
|
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
|
||||||
|
```ts
|
||||||
|
const buyer = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `buildSupplierLines(...)` untouched (supplier fields keep showing in full).
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm the render function's `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before assuming.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard.
|
||||||
|
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(invoice as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `cust = buildAddressLines(customer, false, t)` untouched.
|
||||||
|
- Note the separate `settings.custom_fields` read lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — **do not** filter that; leave it as-is.
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is present.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a focused render assertion (extend the test file)**
|
||||||
|
|
||||||
|
If the three PDF modules export their HTML render function (e.g. offers exports `renderOfferHtml`), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock `html-to-pdf` per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
|
||||||
|
|
||||||
|
it("offer PDF prints only selected company custom fields", () => {
|
||||||
|
const settings = {
|
||||||
|
name: "Naše Firma s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Tel.", value: "123", showLabel: true },
|
||||||
|
{ name: "Web", value: "example.cz", showLabel: true },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const quotation = {
|
||||||
|
customers: null,
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
};
|
||||||
|
const html = renderOfferHtml(quotation as never, settings as never);
|
||||||
|
expect(html).toContain("Tel.: 123");
|
||||||
|
expect(html).not.toContain("example.cz");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — detail query types + shared picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- Create: `src/admin/components/document/CustomFieldsPrintPicker.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the field to the three detail interfaces**
|
||||||
|
|
||||||
|
- `src/admin/lib/queries/offers.ts` — add to `OfferDetailData`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields?: number[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the shared picker component**
|
||||||
|
|
||||||
|
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: indices are keyed off the FULL `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<i>`, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
|
||||||
|
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — wire the picker into the three detail pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/pages/OfferDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/IssuedOrderDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/InvoiceDetail.tsx`
|
||||||
|
|
||||||
|
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import the picker (all three pages)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Seed local state from the loaded document**
|
||||||
|
|
||||||
|
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via `useState` initializers keyed on the query, match that):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
|
||||||
|
// when the detail query resolves (same place other fields are seeded):
|
||||||
|
// setSelectedCustomFields(data.selected_custom_fields ?? []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Respect Rules of Hooks: declare this `useState` with the other hooks, before any early `return`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Render the picker in the editable header/meta area**
|
||||||
|
|
||||||
|
Place near the other document-level settings (currency/language). `companySettings` is already loaded on the page via `companySettingsOptions()`. Use the page's existing "is this document editable / not locked by another user" boolean for `disabled` (e.g. `!canEdit` or the locked flag the page already computes):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use whatever the page already calls its edit-gate (e.g. `canEdit`, `editable`, `isLockedByOther`). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Include the selection in the save payload**
|
||||||
|
|
||||||
|
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.tsx`**
|
||||||
|
|
||||||
|
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors (watch `react-hooks/rules-of-hooks` — the new `useState` must precede any early return).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
|
||||||
|
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole server suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (no regressions; new selected-custom-fields cases green).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + lint + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS; client builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (user-driven, dev server is theirs)**
|
||||||
|
|
||||||
|
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final state check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean tree, the feature commits present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (addressed)
|
||||||
|
|
||||||
|
- **Spec coverage:** storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
|
||||||
|
- **Default = none:** `buildAddressLines` filters only when passed an array; the company call always passes the parsed selection (default `[]`), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅
|
||||||
|
- **Positional identity:** indices key off the full `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
|
||||||
|
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
|
||||||
|
- **Open confirmations flagged inline** (prisma import path in tests; whether each PDF route narrows its document `select`) — each has a grep-first instruction rather than an assumption.
|
||||||
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal 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).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Per-item Sleva (% discount) — Offers + Invoices
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Approved → implementing
|
||||||
|
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
|
||||||
|
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
|
||||||
|
editor and the PDF. Received invoices, issued orders and orders are NOT
|
||||||
|
touched.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Type:** percentage per item (0–100), not an absolute amount.
|
||||||
|
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
|
||||||
|
computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
- **PDF column is conditional:** render the Sleva column only when at least one
|
||||||
|
(included) item has `discount > 0`; otherwise the table is identical to today.
|
||||||
|
- **Detail-page column is always visible** (it's the input).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Add to `quotation_items` and `invoice_items`:
|
||||||
|
|
||||||
|
```
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100
|
||||||
|
```
|
||||||
|
|
||||||
|
Nullable with default `0`, matching the existing money columns. "No sleva" =
|
||||||
|
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
|
||||||
|
`prisma generate`.
|
||||||
|
|
||||||
|
## Line math (single rule)
|
||||||
|
|
||||||
|
`lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)`
|
||||||
|
|
||||||
|
Add a shared backend helper so every site is identical:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/money.ts (new)
|
||||||
|
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
|
||||||
|
qty * unitPrice * (1 - (discountPct || 0) / 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
|
||||||
|
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
|
||||||
|
`0`.
|
||||||
|
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
|
||||||
|
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
|
||||||
|
item create/update.
|
||||||
|
- **invoices.service**: every `computeMoney` `getBase` callback
|
||||||
|
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
|
||||||
|
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
|
||||||
|
net. Persist `discount` on item create/update.
|
||||||
|
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
|
||||||
|
discounted net.
|
||||||
|
|
||||||
|
## Frontend (Section 1 — detail-page editor)
|
||||||
|
|
||||||
|
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
|
||||||
|
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100);
|
||||||
|
per-line total and the footer net total apply it. Mobile card layout gets the
|
||||||
|
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
|
||||||
|
carry `discount`.
|
||||||
|
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
|
||||||
|
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
|
||||||
|
same column; net/VAT/gross totals apply it. Mobile layout too.
|
||||||
|
|
||||||
|
## PDF (Section 2 — conditional column)
|
||||||
|
|
||||||
|
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
|
||||||
|
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
|
||||||
|
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
|
||||||
|
colspans of any full-width rows accordingly.
|
||||||
|
- Line net / document total / VAT always use the discounted net regardless of
|
||||||
|
`hasDiscount`.
|
||||||
|
- `escapeHtml` every interpolation (string-built PDF templates).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
|
||||||
|
(incl. a 100% line = 0, and VAT-on-discounted-net).
|
||||||
|
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
|
||||||
|
- PDF: column present when an item has a discount; absent when none; values
|
||||||
|
escaped.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- No separate "total discount" summary line, no document-level discount.
|
||||||
|
- No changes to received invoices, issued orders, or orders.
|
||||||
|
|
||||||
|
## Migration etiquette
|
||||||
|
|
||||||
|
DB change → user stops the dev server first; create the migration via the
|
||||||
|
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
|
||||||
|
dev and `app_test`, commit schema + migration folder together.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Per-document custom-field print selection
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Approved design — ready for implementation plan
|
||||||
|
**Scope:** offers (quotations), issued orders, invoices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Company custom fields are defined in company settings (`company_settings.custom_fields`,
|
||||||
|
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
|
||||||
|
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
|
||||||
|
|
||||||
|
Today **every** custom field with a value prints on **every** document PDF (offer, issued
|
||||||
|
order, invoice). The only existing per-field control is the company-settings checkbox
|
||||||
|
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
|
||||||
|
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
|
||||||
|
|
||||||
|
The user wants per-document control: on each offer / issued order / invoice **detail page**,
|
||||||
|
tick which company custom fields actually print on that specific document's PDF.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Each offer, issued order, and invoice independently selects which company custom fields
|
||||||
|
print in its PDF sender block.
|
||||||
|
- Selection lives on the document and is editable on its detail page.
|
||||||
|
- Default is **none selected** — existing documents and new drafts print no custom fields
|
||||||
|
until fields are deliberately ticked.
|
||||||
|
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
|
||||||
|
behavior (print all custom fields).
|
||||||
|
- No change to how custom fields are _defined_ in company settings.
|
||||||
|
- No stable per-field IDs — selection is **positional** (see Decisions).
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
|
||||||
|
document prints custom fields only for the indices it explicitly stores.
|
||||||
|
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
|
||||||
|
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
|
||||||
|
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
|
||||||
|
saved document's stored indices point at. Low risk — company fields rarely change, and
|
||||||
|
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
|
||||||
|
over the more robust stable-ID approach.
|
||||||
|
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
|
||||||
|
4. **`showLabel` retained** in company settings, untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data storage
|
||||||
|
|
||||||
|
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
|
||||||
|
|
||||||
|
| Column | Type | Meaning |
|
||||||
|
| ------------------------ | --------- | ------------------------------------------------------------- |
|
||||||
|
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
|
||||||
|
|
||||||
|
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
|
||||||
|
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
|
||||||
|
|
||||||
|
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
|
||||||
|
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
|
||||||
|
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
|
||||||
|
|
||||||
|
### 2. Backend — schemas
|
||||||
|
|
||||||
|
Add to each document's **Create and Update** Zod schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/schemas/offers.schema.ts` — `CreateQuotationSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/issued-orders.schema.ts` — `CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
|
||||||
|
(invoices define the two schemas separately).
|
||||||
|
|
||||||
|
### 3. Backend — services
|
||||||
|
|
||||||
|
In each create/update service, persist the field inside the **existing transaction**:
|
||||||
|
|
||||||
|
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
|
||||||
|
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
|
||||||
|
- No other logic changes; sits alongside the existing header column assignments.
|
||||||
|
|
||||||
|
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
|
||||||
|
`src/services/invoices.service.ts`.
|
||||||
|
|
||||||
|
### 4. Backend — detail responses
|
||||||
|
|
||||||
|
The detail endpoints spread the document row. Decode the stored string back into a
|
||||||
|
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
|
||||||
|
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
|
||||||
|
`selected_custom_fields: number[]` to the corresponding detail interfaces in
|
||||||
|
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
|
||||||
|
|
||||||
|
### 5. PDF rendering
|
||||||
|
|
||||||
|
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
|
||||||
|
company-block `buildAddressLines()` with an optional parameter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
|
||||||
|
includes `i`. Behavior:
|
||||||
|
|
||||||
|
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
|
||||||
|
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
|
||||||
|
DIČ/`vat_id`) are **unaffected** — always built as today.
|
||||||
|
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
|
||||||
|
`Value`) and `field_order` ordering logic is preserved unchanged.
|
||||||
|
|
||||||
|
Only the **company/sender** block is filtered (that's where company custom fields render).
|
||||||
|
The customer block (offers/invoices) and supplier block (issued orders) keep their own
|
||||||
|
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
|
||||||
|
settings, and are not in scope.
|
||||||
|
|
||||||
|
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
|
||||||
|
and passes it into `buildAddressLines(settings, …, selected)`.
|
||||||
|
|
||||||
|
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
|
||||||
|
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
|
||||||
|
|
||||||
|
### 6. Frontend — shared picker component
|
||||||
|
|
||||||
|
New shared component under `src/admin/components/document/` (e.g.
|
||||||
|
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
|
||||||
|
don't fork three copies — per CLAUDE.md document-module conventions).
|
||||||
|
|
||||||
|
Props (shape): the parsed company custom fields (`{name, value}[]` from
|
||||||
|
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
|
||||||
|
`disabled` flag.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
|
||||||
|
so the user knows what each is.
|
||||||
|
- Bound to local state on the detail page, seeded from the document's
|
||||||
|
`selected_custom_fields`.
|
||||||
|
- Hidden/empty when the company has no custom fields defined.
|
||||||
|
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
|
||||||
|
isn't editable or is locked by another user, matching how the rest of the header fields
|
||||||
|
behave on that page.
|
||||||
|
- Included in the page's save payload as `selected_custom_fields: number[]`.
|
||||||
|
|
||||||
|
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
|
||||||
|
each already loads `companySettingsOptions()`, so the field definitions are available with
|
||||||
|
no new query.
|
||||||
|
|
||||||
|
Placement: in the editable header/meta area of each detail page, near the other
|
||||||
|
document-level settings (currency/language/etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
|
||||||
|
idx 0 idx 1 idx 2
|
||||||
|
|
||||||
|
Offer detail page → user ticks "Tel." and "Datová schránka"
|
||||||
|
→ save payload selected_custom_fields: [0, 2]
|
||||||
|
→ service stores quotations.selected_custom_fields = "[0,2]"
|
||||||
|
|
||||||
|
Offer PDF render
|
||||||
|
→ read column "[0,2]" → [0,2]
|
||||||
|
→ buildAddressLines(settings, …, [0,2])
|
||||||
|
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
|
||||||
|
→ sender block prints Tel. and Datová schránka only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
|
||||||
|
|
||||||
|
- Create/update each document with `selected_custom_fields` → column persists the JSON
|
||||||
|
string; empty/omitted → `NULL`.
|
||||||
|
- Detail response decodes to `number[]` (and `[]` for `NULL`).
|
||||||
|
- Schema: non-integer / negative entries rejected; omitted is valid.
|
||||||
|
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
|
||||||
|
selected custom field lines and still includes standard address lines; empty selection ⇒
|
||||||
|
no custom lines; standard lines unaffected.
|
||||||
|
- Regression: customer/supplier custom fields still render regardless of company selection.
|
||||||
|
|
||||||
|
## Migration & rollout
|
||||||
|
|
||||||
|
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
|
||||||
|
`app_test`.
|
||||||
|
- No data backfill (none-selected default is the intended post-ship state).
|
||||||
|
- No production PDF changes for already-archived documents.
|
||||||
|
|
||||||
|
## Affected files (reference)
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` (+ new migration folder)
|
||||||
|
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
|
||||||
|
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
|
||||||
|
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
|
||||||
|
- (detail route handlers, if decoding is done there rather than in the service)
|
||||||
|
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
|
||||||
|
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Status quick actions + project⇄order sync
|
||||||
|
|
||||||
|
**Date:** 2026-07-04 · **Status:** Approved (interactive design w/ owner) · **Scope:** offers, received orders, projects
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Status changes are inconsistent: the Invoices list has a quick chip action (click → confirm → paid),
|
||||||
|
but Offers/ReceivedOrders/Projects tables have inert chips — every change needs the detail page.
|
||||||
|
ProjectDetail edits status via a free combobox in the form (no transitions, no confirm), unlike
|
||||||
|
OfferDetail/OrderDetail's transition buttons. Projects have **no status machine at all** (free-text
|
||||||
|
column). Order→project completion sync exists one-way (`syncProjectStatus`), but finishing a
|
||||||
|
**project** leaves its linked order untouched.
|
||||||
|
|
||||||
|
## Decisions (owner)
|
||||||
|
|
||||||
|
1. **Chip opens a menu** of valid next states (not single-click-advance) → ConfirmDialog per pick,
|
||||||
|
danger styling + cascade notes on destructive/irreversible ones.
|
||||||
|
2. **Reopen allowed**: `dokonceny/zruseny → aktivni` (projects), `dokoncena/stornovana → v_realizaci`
|
||||||
|
(received orders). **Reopening never cascades** to the linked record.
|
||||||
|
3. **Sync scope: finish + cancel, both directions.** Project `dokonceny` ⇄ order `dokoncena`;
|
||||||
|
project `zruseny` ⇄ order `stornovana`.
|
||||||
|
4. Offers quick menu: `Aktivovat` (draft — same number-assignment + PDF-archive semantics as the
|
||||||
|
detail), `Zneplatnit` (danger), and **"Vytvořit objednávku…" which opens the existing
|
||||||
|
create-order modal** (never a raw label flip to `ordered` — that state is owned by the
|
||||||
|
create-order flow).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Projects gain a status machine** (`src/services/projects.service.ts`):
|
||||||
|
`VALID_TRANSITIONS = { aktivni: [dokonceny, zruseny], dokonceny: [aktivni], zruseny: [aktivni] }`.
|
||||||
|
`updateProject` validates status changes (token `invalid_transition` → Czech 400 in the route);
|
||||||
|
a legacy/unknown current status may transition to any canonical value (tolerant). `getProject`
|
||||||
|
returns `valid_transitions` (same contract as offers/orders).
|
||||||
|
|
||||||
|
**Project → order cascade** (new), inside one transaction with the project update:
|
||||||
|
|
||||||
|
- project → `dokonceny` ⇒ linked order → `dokoncena` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- project → `zruseny` ⇒ linked order → `stornovana` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- reopen ⇒ no order change; completed/cancelled orders are never resurrected by a cascade
|
||||||
|
- direct `tx` write (no service recursion); sibling projects of the same order are NOT touched
|
||||||
|
(multi-project orders are a rare schema possibility, not a flow; avoid surprise mass updates)
|
||||||
|
- the service returns what cascaded so the route writes an audit row for the order change too
|
||||||
|
|
||||||
|
**Received orders** (`src/services/orders.service.ts`):
|
||||||
|
|
||||||
|
- `VALID_TRANSITIONS` gains reopen: `dokoncena: [v_realizaci]`, `stornovana: [v_realizaci]`
|
||||||
|
- `syncProjectStatus` becomes reopen-aware: given the previous status, a transition OUT of a
|
||||||
|
terminal state (reopen) skips project sync entirely (decision 2). Forward transitions keep the
|
||||||
|
existing mapping (v_realizaci→aktivni no-op in practice, dokoncena→dokonceny,
|
||||||
|
stornovana→zruseny), and the cascaded project change now gets an audit row.
|
||||||
|
- item/section edit guards stay status-based, so a reopened order is editable again (intended).
|
||||||
|
|
||||||
|
No DB migration (status columns are strings already).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Shared `StatusChipMenu`** (`src/admin/components/StatusChipMenu.tsx`): renders a `StatusChip`;
|
||||||
|
click opens a small MUI `Menu` of actions; each action either opens a `ConfirmDialog`
|
||||||
|
(danger variant + `loading` during the request; cascade note in the message) or runs a plain
|
||||||
|
`onClick` (the modal-opening offer item). Hidden affordance fixed via `title` tooltip. Chip is
|
||||||
|
plain (non-clickable) when the user lacks the edit permission or no actions exist.
|
||||||
|
|
||||||
|
**Offers list**: draft → `Aktivovat` (confirm notes the number assignment; after success the same
|
||||||
|
fire-and-forget PDF-archive call the detail does); active → `Vytvořit objednávku…` (opens the
|
||||||
|
existing modal) + `Zneplatnit` (danger); ordered → `Zneplatnit` (danger).
|
||||||
|
|
||||||
|
**ReceivedOrders list**: prijata → `Zahájit realizaci`, `Stornovat` (danger, note: linked project
|
||||||
|
will be cancelled); v_realizaci → `Dokončit` (note: linked project will be completed),
|
||||||
|
`Stornovat` (danger); dokoncena/stornovana → `Obnovit` (reopen, no cascade note).
|
||||||
|
|
||||||
|
**Projects list**: aktivni → `Dokončit` (note when `order_id` present: linked order will be
|
||||||
|
completed), `Zrušit` (danger, order-storno note); dokonceny/zruseny → `Obnovit`.
|
||||||
|
|
||||||
|
**ProjectDetail**: status `Select` removed from the form (and status removed from the save
|
||||||
|
payload); header gains transition buttons rendered from `valid_transitions`
|
||||||
|
(`Dokončit projekt` / `Zrušit projekt` danger / `Obnovit projekt`), each via ConfirmDialog with
|
||||||
|
cascade notes, as a status-only PUT with its own mutation
|
||||||
|
(invalidate: projects, orders, offers, invoices, warehouse). OrderDetail keeps its pattern;
|
||||||
|
its transition label shows `Obnovit` when reopening from a terminal state.
|
||||||
|
|
||||||
|
Invalidation for all new status mutations: `["orders","offers","projects","invoices"]`
|
||||||
|
(+`["warehouse"]` on project mutations, matching the page's existing set).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Service-level (real `app_test` DB): project transition validation (all legal/illegal edges incl.
|
||||||
|
legacy-status tolerance), project→order cascade both mappings + the guard (no cascade onto
|
||||||
|
dokoncena/stornovana orders), reopen-no-cascade both directions, order reopen transitions, and
|
||||||
|
regression: order→project sync still fires on forward transitions.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Offer machine changes (unchanged: draft→active→ordered/invalidated), issued orders, sibling-project
|
||||||
|
propagation, ReceivedOrders detail-page button changes beyond the automatic reopen button.
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<title>Admin</title>
|
<title>BOHA Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.20",
|
"version": "2.4.47",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.20",
|
"version": "2.4.47",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.20",
|
"version": "2.4.47",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
@@ -65,7 +65,9 @@ model audit_logs {
|
|||||||
new_values String? @db.LongText
|
new_values String? @db.LongText
|
||||||
user_agent String? @db.Text
|
user_agent String? @db.Text
|
||||||
session_id String? @db.VarChar(128)
|
session_id String? @db.VarChar(128)
|
||||||
created_at DateTime? @default(now()) @db.Timestamp(0)
|
// DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
|
||||||
|
// the session timezone (2026-06-12 hardening migration).
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
|
||||||
@@index([action], map: "idx_audit_logs_action")
|
@@index([action], map: "idx_audit_logs_action")
|
||||||
@@index([created_at], map: "idx_audit_logs_created")
|
@@index([created_at], map: "idx_audit_logs_created")
|
||||||
@@ -206,6 +208,7 @@ model invoice_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
position Int? @default(0)
|
position Int? @default(0)
|
||||||
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
||||||
@@ -236,6 +239,7 @@ model invoices {
|
|||||||
billing_text String? @db.VarChar(500)
|
billing_text String? @db.VarChar(500)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
invoice_items invoice_items[]
|
invoice_items invoice_items[]
|
||||||
@@ -390,6 +394,7 @@ model issued_orders {
|
|||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
order_text String? @db.VarChar(500)
|
order_text String? @db.VarChar(500)
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
@@ -457,6 +462,7 @@ model project_notes {
|
|||||||
user_name String? @db.VarChar(100)
|
user_name String? @db.VarChar(100)
|
||||||
content String? @db.Text
|
content String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
edited_at DateTime? @db.DateTime(0)
|
||||||
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
|
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
|
||||||
|
|
||||||
@@index([project_id], map: "project_id")
|
@@index([project_id], map: "project_id")
|
||||||
@@ -502,6 +508,7 @@ model quotation_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
@@ -522,6 +529,7 @@ model quotations {
|
|||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|||||||
@@ -391,12 +391,12 @@ async function main() {
|
|||||||
console.log(` Admin role now has all ${allPerms.length} permissions`);
|
console.log(` Admin role now has all ${allPerms.length} permissions`);
|
||||||
|
|
||||||
// 4. Ensure admin user exists
|
// 4. Ensure admin user exists
|
||||||
let adminUser = await prisma.users.findFirst({
|
const adminUser = await prisma.users.findFirst({
|
||||||
where: { username: "admin" },
|
where: { username: "admin" },
|
||||||
});
|
});
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
const hash = bcrypt.hashSync("admin", 10);
|
const hash = bcrypt.hashSync("admin", 10);
|
||||||
adminUser = await prisma.users.create({
|
await prisma.users.create({
|
||||||
data: {
|
data: {
|
||||||
username: "admin",
|
username: "admin",
|
||||||
email: "admin@boha.local",
|
email: "admin@boha.local",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function main() {
|
|||||||
// fails we KEEP file_data and report the row as failed.
|
// fails we KEEP file_data and report the row as failed.
|
||||||
const expectedSize = rec.file_data.length;
|
const expectedSize = rec.file_data.length;
|
||||||
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
|
||||||
let writtenSize = -1;
|
let writtenSize: number;
|
||||||
if (readBack) {
|
if (readBack) {
|
||||||
writtenSize = readBack.data.length;
|
writtenSize = readBack.data.length;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
378
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
378
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// 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 <b></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">8,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">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row summary-alert">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">152,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>"
|
||||||
|
`;
|
||||||
137
src/__tests__/ai-budget-permission.test.ts
Normal file
137
src/__tests__/ai-budget-permission.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import aiRoutes from "../routes/admin/ai";
|
||||||
|
import { getBudgetUsd, setBudgetUsd } from "../services/ai.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning test for audit finding L8a: the company-wide AI monthly budget must
|
||||||
|
* NOT be mutable by an ordinary ai.use holder (budget_usd=0 is a company-wide
|
||||||
|
* AI DoS; a huge value removes the cost cap). It is a company setting —
|
||||||
|
* settings.company / settings.system / admin only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "ai_budget_perm_";
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let aiUserToken: string;
|
||||||
|
let aiRoleId: number;
|
||||||
|
let savedBudget: number;
|
||||||
|
|
||||||
|
function token(user: { id: number; username: string; roleName: string }) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: user.roleName },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||||
|
const roles = await prisma.roles.findMany({
|
||||||
|
where: { name: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const roleIds = roles.map((r) => r.id);
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
await prisma.role_permissions.deleteMany({
|
||||||
|
where: { role_id: { in: roleIds } },
|
||||||
|
});
|
||||||
|
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
savedBudget = await getBudgetUsd();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
app.addHook("onRequest", securityHeaders);
|
||||||
|
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = token({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordinary ai.use holder — may chat, must NOT control the budget.
|
||||||
|
const role = await prisma.roles.create({
|
||||||
|
data: { name: `${N}aiuse_${stamp}`, display_name: "AI Use Only" },
|
||||||
|
});
|
||||||
|
aiRoleId = role.id;
|
||||||
|
const perm = await prisma.permissions.findFirst({
|
||||||
|
where: { name: "ai.use" },
|
||||||
|
});
|
||||||
|
if (!perm) throw new Error("Test setup: ai.use permission missing");
|
||||||
|
await prisma.role_permissions.create({
|
||||||
|
data: { role_id: aiRoleId, permission_id: perm.id },
|
||||||
|
});
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user_${stamp}`,
|
||||||
|
email: `${N}${stamp}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
first_name: "AiUse",
|
||||||
|
last_name: "Only",
|
||||||
|
is_active: true,
|
||||||
|
role_id: aiRoleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
aiUserToken = token({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleName: role.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await setBudgetUsd(savedBudget); // restore whatever the test DB had
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /ai/budget permission gate (audit L8a)", () => {
|
||||||
|
it("rejects an ordinary ai.use holder with 403", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/admin/ai/budget",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${aiUserToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { budget_usd: 0 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
// ...and the budget must be untouched.
|
||||||
|
expect(await getBudgetUsd()).toBe(savedBudget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to set the budget", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/api/admin/ai/budget",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { budget_usd: savedBudget },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
fixUser2Id = u2.id;
|
fixUser2Id = u2.id;
|
||||||
|
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||||||
|
await prisma.leave_balances.create({
|
||||||
|
data: {
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
sick_used: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
await prisma.attendance.create({
|
await prisma.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: fixUserId,
|
user_id: fixUserId,
|
||||||
@@ -176,6 +186,107 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||||
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||||
|
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||||
|
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||||
|
let fixOfferId = 0;
|
||||||
|
let fixSupplierId = 0;
|
||||||
|
let fixRiId = 0;
|
||||||
|
let fixCustomerId = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.quotations.deleteMany({
|
||||||
|
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||||
|
});
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||||||
|
await prisma.received_invoices.deleteMany({
|
||||||
|
where: { supplier_name: FIX_RI_SUPPLIER },
|
||||||
|
});
|
||||||
|
const sup = await prisma.sklad_suppliers.create({
|
||||||
|
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||||
|
});
|
||||||
|
fixSupplierId = sup.id;
|
||||||
|
// Customer + two projects for the per-customer aggregation tool.
|
||||||
|
await prisma.projects.deleteMany({
|
||||||
|
where: { name: { startsWith: "AiTest Projekt" } },
|
||||||
|
});
|
||||||
|
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||||
|
const cust = await prisma.customers.create({
|
||||||
|
data: { name: FIX_CUSTOMER },
|
||||||
|
});
|
||||||
|
fixCustomerId = cust.id;
|
||||||
|
await prisma.projects.createMany({
|
||||||
|
data: [
|
||||||
|
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||||
|
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ri = await prisma.received_invoices.create({
|
||||||
|
data: {
|
||||||
|
month: 6,
|
||||||
|
year: 2098,
|
||||||
|
supplier_name: FIX_RI_SUPPLIER,
|
||||||
|
invoice_number: "AITEST-RI-1",
|
||||||
|
amount: 1210,
|
||||||
|
vat_rate: 21,
|
||||||
|
vat_amount: 210,
|
||||||
|
currency: "CZK",
|
||||||
|
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||||||
|
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||||||
|
status: "unpaid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixRiId = ri.id;
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: FIX_OFFER_NUMBER,
|
||||||
|
status: "active",
|
||||||
|
currency: "EUR",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Rozvaděč RVO-1",
|
||||||
|
item_description: "<p>Hlavní <b>rozvaděč</b> včetně montáže</p>",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: 2,
|
||||||
|
description: "Doprava (informativní)",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 500,
|
||||||
|
is_included_in_total: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scope_sections: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
title_cz: "Rozsah projektu",
|
||||||
|
content: "<p>Dodávka & montáž <i>na klíč</i>.</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixOfferId = q.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Items/sections cascade with the quotation.
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||||
|
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||||
|
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||||
|
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||||
|
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||||||
const fixUserIds = [fixUserId, fixUser2Id];
|
const fixUserIds = [fixUserId, fixUser2Id];
|
||||||
@@ -410,6 +521,348 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("get_offer_detail returns line items, totals and stripped sections", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" }, // partial number lookup
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
number: string;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
items_total: number;
|
||||||
|
items: {
|
||||||
|
name: string;
|
||||||
|
detail?: string;
|
||||||
|
line_total: number;
|
||||||
|
excluded_from_total?: boolean;
|
||||||
|
}[];
|
||||||
|
sections: { title: string | null; text: string }[];
|
||||||
|
};
|
||||||
|
expect(r.number).toBe(FIX_OFFER_NUMBER);
|
||||||
|
expect(r.currency).toBe("EUR");
|
||||||
|
expect(r.item_count).toBe(2);
|
||||||
|
expect(r.items[0]).toMatchObject({
|
||||||
|
name: "Rozvaděč RVO-1",
|
||||||
|
detail: "Hlavní rozvaděč včetně montáže", // HTML stripped
|
||||||
|
line_total: 2000,
|
||||||
|
});
|
||||||
|
expect(r.items[1].excluded_from_total).toBe(true);
|
||||||
|
expect(r.items_total).toBe(2000); // excluded row not counted
|
||||||
|
expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" });
|
||||||
|
expect(r.sections[0].text).toContain("Dodávka & montáž na klíč");
|
||||||
|
|
||||||
|
// Unknown number → explicit Czech error, flagged not-ok.
|
||||||
|
const miss = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "NEEXISTUJE-123" },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(miss.ok).toBe(false);
|
||||||
|
|
||||||
|
// No offers.view → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_offer_detail",
|
||||||
|
{ number: "99999" },
|
||||||
|
NOBODY,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||||||
|
const own = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ year: 2098 },
|
||||||
|
{
|
||||||
|
userId: fixUserId,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.record"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(own.ok).toBe(true);
|
||||||
|
expect(own.result).toMatchObject({
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
vacation_remaining: 20,
|
||||||
|
sick_used: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bare attendance.record asking about someone else → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// attendance.balances (the overview route's permission) unlocks others.
|
||||||
|
const hr = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
{
|
||||||
|
userId: 999999998,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.balances"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(hr.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
fund_hours: number;
|
||||||
|
worked_hours: number;
|
||||||
|
business_days: number;
|
||||||
|
};
|
||||||
|
expect(r.fund_hours).toBeGreaterThan(0);
|
||||||
|
expect(r.business_days).toBeGreaterThan(0);
|
||||||
|
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||||||
|
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||||||
|
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||||||
|
// no-project bucket with per-user hours.
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_project_hours_report",
|
||||||
|
{ year: 2098 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
year: number;
|
||||||
|
projects: {
|
||||||
|
label: string;
|
||||||
|
hours: number;
|
||||||
|
by_user: Record<string, number>;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
expect(r.year).toBe(2098);
|
||||||
|
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
expect(bucket!.hours).toBe(8);
|
||||||
|
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||||||
|
const sup = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: "AiTest Dodavatel" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(sup.ok).toBe(true);
|
||||||
|
const s = (
|
||||||
|
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||||||
|
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||||
|
expect(s).toMatchObject({ ico: "99999998" });
|
||||||
|
|
||||||
|
// IČO typed with spaces finds the space-less stored value.
|
||||||
|
const spacedIco = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: "999 99 998" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||||
|
(x) => x.name === FIX_SUPPLIER,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const veh = await executeTool(
|
||||||
|
"list_vehicles",
|
||||||
|
{ search: FIX.spz },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(veh.ok).toBe(true);
|
||||||
|
const v = (
|
||||||
|
veh.result as {
|
||||||
|
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||||||
|
}
|
||||||
|
).vehicles[0];
|
||||||
|
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||||
|
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||||
|
|
||||||
|
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||||
|
// in list_vehicles AND in the list_trips vehicle filter.
|
||||||
|
const spaced = await executeTool(
|
||||||
|
"list_vehicles",
|
||||||
|
{ search: "aitest 999" },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||||
|
).toBe(FIX.spz);
|
||||||
|
const tripsByPlate = await executeTool(
|
||||||
|
"list_trips",
|
||||||
|
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||||
|
).toBe(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||||||
|
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||||||
|
|
||||||
|
// orders.view alone cannot read offer totals (per-type re-check).
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// Offers have no period filter — month/year must error, not be ignored.
|
||||||
|
const offersMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", month: 6, year: 2026 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(offersMonth.ok).toBe(false);
|
||||||
|
|
||||||
|
// A bare month defaults the year (never an unmarked all-time sum) and
|
||||||
|
// the applied period is echoed back.
|
||||||
|
const bareMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "orders", month: 6 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(bareMonth.ok).toBe(true);
|
||||||
|
expect((bareMonth.result as { period: string }).period).toBe(
|
||||||
|
`6/${new Date().getFullYear()}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_document_totals: received_invoices sums gross + converts to CZK", async () => {
|
||||||
|
// invoices.view alone unlocks the received-invoices branch.
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "received_invoices", month: 6, year: 2098 },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
period: string;
|
||||||
|
invoice_count: number;
|
||||||
|
totals: { currency: string; amount: number }[];
|
||||||
|
total_czk: number;
|
||||||
|
};
|
||||||
|
expect(r.period).toBe("6/2098");
|
||||||
|
expect(r.invoice_count).toBe(1);
|
||||||
|
expect(r.totals).toEqual([{ currency: "CZK", amount: 1210 }]);
|
||||||
|
expect(r.total_czk).toBe(1210); // CZK rows convert 1:1, no rate fetch
|
||||||
|
|
||||||
|
// orders.view alone cannot read expense totals.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "received_invoices" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_top_customers",
|
||||||
|
{ type: "projects" },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
customers_with_records: number;
|
||||||
|
top: { customer: string; count: number }[];
|
||||||
|
};
|
||||||
|
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(mine!.count).toBe(2);
|
||||||
|
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_top_customers",
|
||||||
|
{ type: "projects" },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("find_employee and find_supplier report total_matching", async () => {
|
||||||
|
const emp = await executeTool(
|
||||||
|
"find_employee",
|
||||||
|
{ search: FIX.last },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(emp.result as { total_matching: number }).total_matching,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
const sup = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: FIX_SUPPLIER },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(sup.result as { total_matching: number }).total_matching,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "AiTest Supplier" },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.result).toMatchObject({
|
||||||
|
supplier: FIX_RI_SUPPLIER,
|
||||||
|
number: "AITEST-RI-1",
|
||||||
|
amount_with_vat: 1210,
|
||||||
|
vat_amount: 210,
|
||||||
|
status: "unpaid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "x" },
|
||||||
|
NOBODY,
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_work_plan",
|
"get_work_plan",
|
||||||
|
|||||||
46
src/__tests__/app-version-store.test.ts
Normal file
46
src/__tests__/app-version-store.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
reportServerVersion,
|
||||||
|
subscribeNewVersion,
|
||||||
|
getNewVersion,
|
||||||
|
BUILD_VERSION,
|
||||||
|
__resetVersionStateForTest,
|
||||||
|
} from "../admin/utils/appVersion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client store backing the "new version available" banner. The server stamps
|
||||||
|
* X-App-Version on every response; apiFetch feeds it here. A version that
|
||||||
|
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
|
||||||
|
* happened → latch it and notify subscribers (the banner).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("appVersion store", () => {
|
||||||
|
beforeEach(() => __resetVersionStateForTest());
|
||||||
|
|
||||||
|
it("ignores the same version, empty, or null", () => {
|
||||||
|
reportServerVersion(BUILD_VERSION);
|
||||||
|
reportServerVersion("");
|
||||||
|
reportServerVersion(null);
|
||||||
|
reportServerVersion(undefined);
|
||||||
|
expect(getNewVersion()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("latches + notifies once when the server reports a different version", () => {
|
||||||
|
let notified = 0;
|
||||||
|
const unsub = subscribeNewVersion(() => {
|
||||||
|
notified++;
|
||||||
|
});
|
||||||
|
|
||||||
|
reportServerVersion(`${BUILD_VERSION}-next`);
|
||||||
|
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
|
||||||
|
expect(notified).toBe(1);
|
||||||
|
|
||||||
|
// Idempotent: once a new version is known, further reports don't re-notify
|
||||||
|
// or overwrite (the banner is already showing).
|
||||||
|
reportServerVersion("99.0.0");
|
||||||
|
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
|
||||||
|
expect(notified).toBe(1);
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
});
|
||||||
139
src/__tests__/attendance-delete-balance.test.ts
Normal file
139
src/__tests__/attendance-delete-balance.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createLeave, deleteAttendance } from "../services/attendance.service";
|
||||||
|
import { localDateStr } from "../utils/date";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding C1: deleting a vacation/sick attendance
|
||||||
|
* record must restore the hours it charged to leave_balances. Without the
|
||||||
|
* restore, cancelling booked leave by deleting its rows permanently inflates
|
||||||
|
* vacation_used/sick_used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "att_delbal_";
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
/** First Monday of March in the given year — always before the earliest
|
||||||
|
* possible Easter holiday and clear of all fixed Czech holidays, so a
|
||||||
|
* Mon-Fri range books exactly 5 days. (Far-future year per fixture hygiene.) */
|
||||||
|
function firstMondayOfMarch(year: number): Date {
|
||||||
|
const d = new Date(year, 2, 1, 12, 0, 0);
|
||||||
|
while (d.getDay() !== 1) d.setDate(d.getDate() + 1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
where: { username: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ids = users.map((u) => u.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
|
||||||
|
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
|
||||||
|
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||||
|
if (!adminRole)
|
||||||
|
throw new Error("Admin role not found — seed the database first");
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user`,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "Delete",
|
||||||
|
last_name: "Balance",
|
||||||
|
is_active: true,
|
||||||
|
role_id: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function balance(year: number) {
|
||||||
|
return prisma.leave_balances.findFirst({ where: { user_id: userId, year } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deleteAttendance leave-balance restore (audit C1)", () => {
|
||||||
|
it("restores vacation_used when booked vacation rows are deleted", async () => {
|
||||||
|
const monday = firstMondayOfMarch(2098);
|
||||||
|
const friday = new Date(monday);
|
||||||
|
friday.setDate(friday.getDate() + 4);
|
||||||
|
|
||||||
|
const created = await createLeave(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
date_from: localDateStr(monday),
|
||||||
|
date_to: localDateStr(friday),
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: 8,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect("created" in created && created.created).toBe(5);
|
||||||
|
expect(Number((await balance(2098))?.vacation_used)).toBe(40);
|
||||||
|
|
||||||
|
const rows = await prisma.attendance.findMany({
|
||||||
|
where: { user_id: userId, leave_type: "vacation" },
|
||||||
|
});
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
for (const row of rows) {
|
||||||
|
const res = await deleteAttendance(row.id);
|
||||||
|
expect("success" in res && res.success).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Number((await balance(2098))?.vacation_used)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores sick_used when a booked sick day is deleted", async () => {
|
||||||
|
const monday = firstMondayOfMarch(2099);
|
||||||
|
await createLeave(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
date_from: localDateStr(monday),
|
||||||
|
leave_type: "sick",
|
||||||
|
leave_hours: 8,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(Number((await balance(2099))?.sick_used)).toBe(8);
|
||||||
|
|
||||||
|
const row = await prisma.attendance.findFirst({
|
||||||
|
where: { user_id: userId, leave_type: "sick" },
|
||||||
|
});
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
await deleteAttendance(row!.id);
|
||||||
|
|
||||||
|
expect(Number((await balance(2099))?.sick_used)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch balances when deleting a plain work shift", async () => {
|
||||||
|
const wednesday = firstMondayOfMarch(2098);
|
||||||
|
wednesday.setDate(wednesday.getDate() + 9); // a weekday a week later
|
||||||
|
const shift = await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
shift_date: wednesday,
|
||||||
|
leave_type: "work",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = await balance(2098);
|
||||||
|
await deleteAttendance(shift.id);
|
||||||
|
const after = await balance(2098);
|
||||||
|
|
||||||
|
expect(Number(after?.vacation_used)).toBe(Number(before?.vacation_used));
|
||||||
|
expect(Number(after?.sick_used)).toBe(Number(before?.sick_used));
|
||||||
|
});
|
||||||
|
});
|
||||||
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
284
src/__tests__/attendance-mzda-summary.test.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
computeMzdaSummary,
|
||||||
|
formatHoursDecimal1,
|
||||||
|
} from "../admin/utils/attendanceHelpers";
|
||||||
|
import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BINDING payroll-recap formulas (owner spec 2026-07, back-computed against
|
||||||
|
* the real payslip of Dominik Vesecký 5/2026 — the dataset below is the
|
||||||
|
* built-in acceptance test and MUST match exactly, not approximately):
|
||||||
|
*
|
||||||
|
* fond = (Po–Pá dny, které NEJSOU svátek) × 8
|
||||||
|
* odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně
|
||||||
|
* svátek = čisté odpracované hodiny ve dnech svátku
|
||||||
|
* dovolená = Σ leave_hours (evidence)
|
||||||
|
* přesčas = max(0, (worked_total + dovolená) − fond) [měsíční]
|
||||||
|
* vč. svátků = odpracováno + přesčas
|
||||||
|
* so/ne = čisté hodiny směn začínajících v So/Ne (celá směna)
|
||||||
|
* noc = průnik s 22:00–06:00 minus pauzy v pásmu, ke dni začátku
|
||||||
|
*
|
||||||
|
* Expected 5/2026 (holidays 1.5. + 8.5.): fond 152 · odpracováno 144 ·
|
||||||
|
* svátek 8 · dovolená 24 · přesčas 17,25 (=17,3 na desetiny) · vč. 161,25
|
||||||
|
* (=161,3) · So/Ne 57,25 · noc 36,75 (payslip said 31 — accountant's own
|
||||||
|
* error, she missed the 31.5.→1.6. overnight shift; 36,75 is CORRECT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const W = (d: string, from: string, to: string, brk?: [string, string]) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`,
|
||||||
|
departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`,
|
||||||
|
break_start: brk
|
||||||
|
? brk[0].includes(" ")
|
||||||
|
? brk[0].replace(" ", "T")
|
||||||
|
: `${d}T${brk[0]}:00`
|
||||||
|
: null,
|
||||||
|
break_end: brk
|
||||||
|
? brk[1].includes(" ")
|
||||||
|
? brk[1].replace(" ", "T")
|
||||||
|
: `${d}T${brk[1]}:00`
|
||||||
|
: null,
|
||||||
|
leave_type: "work",
|
||||||
|
});
|
||||||
|
const V = (d: string, h: number) => ({
|
||||||
|
shift_date: `${d}T02:00:00`,
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: h,
|
||||||
|
arrival_time: null,
|
||||||
|
departure_time: null,
|
||||||
|
break_start: null,
|
||||||
|
break_end: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The exact dataset from the spec (times with a space carry a full date). */
|
||||||
|
const DOMINIK_MAY_2026 = [
|
||||||
|
W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
|
||||||
|
W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
|
||||||
|
"2026-05-04 01:52:00",
|
||||||
|
"2026-05-04 02:22:00",
|
||||||
|
]),
|
||||||
|
V("2026-05-05", 8),
|
||||||
|
V("2026-05-06", 8),
|
||||||
|
V("2026-05-07", 8),
|
||||||
|
W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
|
||||||
|
"2026-05-11 01:52:00",
|
||||||
|
"2026-05-11 02:22:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
|
||||||
|
W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
|
||||||
|
W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
|
||||||
|
W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
|
||||||
|
"2026-05-18 02:00:00",
|
||||||
|
"2026-05-18 02:30:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-19", "09:30", "17:00", ["13:15", "13:45"]),
|
||||||
|
W("2026-05-20", "08:30", "16:30", ["12:30", "13:00"]),
|
||||||
|
W("2026-05-21", "09:45", "15:15"),
|
||||||
|
W("2026-05-22", "10:45", "15:30"),
|
||||||
|
W("2026-05-24", "17:45", "2026-05-25 06:00:00", [
|
||||||
|
"23:45",
|
||||||
|
"2026-05-25 00:45:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-26", "09:00", "14:30"),
|
||||||
|
W("2026-05-27", "10:00", "15:30"),
|
||||||
|
W("2026-05-28", "08:00", "15:45", ["11:45", "12:15"]),
|
||||||
|
W("2026-05-29", "08:15", "15:00", ["11:30", "12:00"]),
|
||||||
|
W("2026-05-31", "19:45", "2026-06-01 04:15:00", [
|
||||||
|
"2026-06-01 00:00:00",
|
||||||
|
"2026-06-01 00:30:00",
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => {
|
||||||
|
const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
|
||||||
|
|
||||||
|
it("Fond měsíce = 152 (19 Po–Pá dnů mimo svátky 1.5. a 8.5.)", () => {
|
||||||
|
expect(s.businessDays).toBe(19);
|
||||||
|
expect(s.fund).toBe(152);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno = 144 (18 pracovních dnů mimo svátek × 8, paušálně)", () => {
|
||||||
|
expect(s.odpracovano).toBe(144);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Svátek = 8 (skutečné hodiny odpracované 1. 5.)", () => {
|
||||||
|
expect(s.svatekHours).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dovolená = 24", () => {
|
||||||
|
expect(s.vacationHours).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("worked_total = 145,25 (145:15 čistého času)", () => {
|
||||||
|
expect(s.workedHoursRaw).toBe(145.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Přesčas = 17,25 → zobrazeno 17,3 ((145,25 + 24) − 152)", () => {
|
||||||
|
expect(s.prescas).toBe(17.25);
|
||||||
|
expect(formatHoursDecimal1(s.prescas * 60)).toBe("17,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Odpracováno včetně svátků a přesčasů = 161,25 → zobrazeno 161,3 (144 + 17,25)", () => {
|
||||||
|
expect(s.vcetneSvatku).toBe(161.25);
|
||||||
|
expect(formatHoursDecimal1(s.vcetneSvatku * 60)).toBe("161,3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("So/Ne = 57,25 (6 směn začínajících v So/Ne, celé směny)", () => {
|
||||||
|
expect(s.weekendHours).toBe(57.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => {
|
||||||
|
expect(s.nightMinutes / 60).toBe(36.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => {
|
||||||
|
expect(s.manko).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => {
|
||||||
|
// Real production month exposing the gap the paušál hides: 21 attended days
|
||||||
|
// × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally
|
||||||
|
// compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is
|
||||||
|
// only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are
|
||||||
|
// binding), manko must surface the 31,5.
|
||||||
|
const JUNE = [
|
||||||
|
W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday
|
||||||
|
W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon
|
||||||
|
W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-17", "10:45", "13:15"),
|
||||||
|
W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]),
|
||||||
|
W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]),
|
||||||
|
W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]),
|
||||||
|
W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]),
|
||||||
|
V("2026-06-25", 8),
|
||||||
|
W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]),
|
||||||
|
W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday
|
||||||
|
W("2026-06-29", "11:45", "14:30"),
|
||||||
|
W("2026-06-30", "06:30", "09:15"),
|
||||||
|
W("2026-06-30", "10:45", "13:30"),
|
||||||
|
];
|
||||||
|
const s = computeMzdaSummary(JUNE as never, "2026-06");
|
||||||
|
|
||||||
|
it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => {
|
||||||
|
expect(s.fund).toBe(176);
|
||||||
|
expect(s.businessDays).toBe(22);
|
||||||
|
expect(s.odpracovano).toBe(168); // 21 days × 8 flat
|
||||||
|
expect(s.vacationHours).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(136.5);
|
||||||
|
expect(s.prescas).toBe(0); // (136,5 + 8) − 176 < 0 — unchanged formula
|
||||||
|
expect(s.vcetneSvatku).toBe(168);
|
||||||
|
expect(s.svatekHours).toBe(0);
|
||||||
|
expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6.
|
||||||
|
// 14.6. shift (22:00–04:15 minus break = 5:45) + early starts before
|
||||||
|
// 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15).
|
||||||
|
expect(s.nightMinutes / 60).toBe(6.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => {
|
||||||
|
expect(s.manko).toBe(31.5); // 176 − (136,5 + 8)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => {
|
||||||
|
const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }];
|
||||||
|
const s2 = computeMzdaSummary(withSick as never, "2026-06");
|
||||||
|
expect(s2.sickHours).toBe(8);
|
||||||
|
expect(s2.manko).toBe(23.5); // 31,5 − 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeMzdaSummary — formula edge cases", () => {
|
||||||
|
it("Odpracováno is a flat 8h per day: a lone 4:45 day counts as 8", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-06-02", "10:45", "15:30")] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(4.75);
|
||||||
|
expect(s.prescas).toBe(0); // (4,75 + 0) − 176 < 0
|
||||||
|
expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dovolená covers the fond in Přesčas", () => {
|
||||||
|
// 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
|
||||||
|
const recs: unknown[] = [];
|
||||||
|
const days = [
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"08",
|
||||||
|
"09",
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"12",
|
||||||
|
"15",
|
||||||
|
"16",
|
||||||
|
"17",
|
||||||
|
"18",
|
||||||
|
"19",
|
||||||
|
"22",
|
||||||
|
"23",
|
||||||
|
"24",
|
||||||
|
"25",
|
||||||
|
].map((d) => `2026-06-${d}`);
|
||||||
|
for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152
|
||||||
|
recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14));
|
||||||
|
const s = computeMzdaSummary(recs as never, "2026-06");
|
||||||
|
expect(s.vacationHours).toBe(30);
|
||||||
|
expect(s.prescas).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
|
||||||
|
"2026-07",
|
||||||
|
);
|
||||||
|
expect(s.svatekHours).toBe(10);
|
||||||
|
expect(s.odpracovano).toBe(0);
|
||||||
|
expect(s.fund).toBe(176); // 23 Mon–Fri − 6.7. holiday = 22 × 8
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("print day table — multiple records on one day", () => {
|
||||||
|
it("renders one row per record (two shifts are both visible)", () => {
|
||||||
|
const records = [
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
];
|
||||||
|
const html = buildUserSectionHtml(
|
||||||
|
"1",
|
||||||
|
{ name: "Test", minutes: 480, vacation_hours: 0, records } as never,
|
||||||
|
{ month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never,
|
||||||
|
);
|
||||||
|
expect(html).toContain("06:00");
|
||||||
|
expect(html).toContain("14:00");
|
||||||
|
const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || [];
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/__tests__/attendance-print-html.test.ts
Normal file
155
src/__tests__/attendance-print-html.test.ts
Normal 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 & <Co>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deterministic fixture: a fixed far-future month, one work day (with a numeric
|
||||||
|
// project log so the snapshot also guards the parseInt path in context) and one
|
||||||
|
// vacation day.
|
||||||
|
const workRecord = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 5,
|
||||||
|
shift_date: "2098-03-04",
|
||||||
|
leave_type: "work",
|
||||||
|
arrival_time: "2098-03-04T08:00:00",
|
||||||
|
departure_time: "2098-03-04T16:30:00",
|
||||||
|
break_start: "2098-03-04T12:00:00",
|
||||||
|
break_end: "2098-03-04T12:30:00",
|
||||||
|
notes: "Poznámka <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);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/__tests__/attendance-print-tz.test.ts
Normal file
77
src/__tests__/attendance-print-tz.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
getCzechWeekday,
|
||||||
|
isWeekendDate,
|
||||||
|
formatTimeOrDatetimePrint,
|
||||||
|
} from "../admin/utils/attendanceHelpers";
|
||||||
|
import { formatDate } from "../admin/utils/formatters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: the attendance admin print builds its day rows from date-ONLY
|
||||||
|
* strings ("2026-06-01"). `new Date("YYYY-MM-DD")` is UTC midnight per the
|
||||||
|
* ECMAScript spec, so formatting it with local getters rendered the PREVIOUS
|
||||||
|
* day for viewers west of UTC — a June print opened with "31. 5. 2026 Neděle"
|
||||||
|
* (a May row) and every weekday name was shifted by one. The helpers now
|
||||||
|
* parse the calendar day from the string parts, which is timezone-proof by
|
||||||
|
* construction: these assertions hold in EVERY zone (on a machine west of
|
||||||
|
* UTC the old implementation fails them).
|
||||||
|
*/
|
||||||
|
describe("attendance print — timezone-proof date rendering", () => {
|
||||||
|
it("formatDate renders a date-only string as that exact calendar day", () => {
|
||||||
|
expect(formatDate("2026-06-01")).toBe("1. 6. 2026");
|
||||||
|
expect(formatDate("2026-06-30")).toBe("30. 6. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatDate keeps naive-datetime (wire format) behavior", () => {
|
||||||
|
expect(formatDate("2026-06-01T02:00:00")).toBe("1. 6. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCzechWeekday names the string's own calendar day", () => {
|
||||||
|
expect(getCzechWeekday("2026-06-01")).toBe("Pondělí");
|
||||||
|
expect(getCzechWeekday("2026-06-06")).toBe("Sobota");
|
||||||
|
expect(getCzechWeekday("2026-06-07")).toBe("Neděle");
|
||||||
|
// naive-datetime wire format resolves to the same day
|
||||||
|
expect(getCzechWeekday("2026-06-01T02:00:00")).toBe("Pondělí");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isWeekendDate flags the string's own calendar day", () => {
|
||||||
|
expect(isWeekendDate("2026-06-06")).toBe(true); // Saturday
|
||||||
|
expect(isWeekendDate("2026-06-07")).toBe(true); // Sunday
|
||||||
|
expect(isWeekendDate("2026-06-01")).toBe(false); // Monday
|
||||||
|
expect(isWeekendDate("2026-06-06T02:00:00")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees with a locally-constructed date in the current zone (guards a regression to UTC parsing)", () => {
|
||||||
|
// Local construction is the ground truth for "calendar day" in any zone.
|
||||||
|
const local = new Date(2026, 5, 1, 12).toLocaleDateString("cs-CZ", {
|
||||||
|
weekday: "long",
|
||||||
|
});
|
||||||
|
const expected = local.charAt(0).toUpperCase() + local.slice(1);
|
||||||
|
expect(getCzechWeekday("2026-06-01")).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attendance print — overnight date prefix on time cells", () => {
|
||||||
|
// shift_date arrives as a naive datetime on the wire ("2026-06-01T02:00:00");
|
||||||
|
// the guard used to compare it raw against the extracted date part, so the
|
||||||
|
// overnight-only "d.m." prefix fired on EVERY cell ("1.6. 08:00").
|
||||||
|
const wireShiftDate = "2026-06-01T02:00:00";
|
||||||
|
|
||||||
|
it("same-day punch renders time only", () => {
|
||||||
|
expect(
|
||||||
|
formatTimeOrDatetimePrint("2026-06-01T08:00:00", wireShiftDate),
|
||||||
|
).toBe("08:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overnight punch keeps the date prefix", () => {
|
||||||
|
expect(
|
||||||
|
formatTimeOrDatetimePrint("2026-06-02T01:30:00", wireShiftDate),
|
||||||
|
).toBe("2.6. 01:30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("date-only shiftDate also matches", () => {
|
||||||
|
expect(formatTimeOrDatetimePrint("2026-06-01T08:00:00", "2026-06-01")).toBe(
|
||||||
|
"08:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/__tests__/attendance-workfund.test.ts
Normal file
124
src/__tests__/attendance-workfund.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { getWorkfund } from "../services/attendance.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print-recap parity for the Bilance page's monthly fund overview
|
||||||
|
* (getWorkfund), per the binding formulas (computeMzdaSummary):
|
||||||
|
* overtime (Přesčas) = max(0, worked + dovolená − fond)
|
||||||
|
* missing (Chybějící hodiny) = max(0, fond − (worked + dovolená + nemoc
|
||||||
|
* + neplacené volno))
|
||||||
|
* The two coverage bases differ deliberately: only dovolená earns toward
|
||||||
|
* overtime, every approved absence covers the fond.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const USERNAME = "wf_parity_test_user";
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
const june = (day: string) => new Date(`2026-06-${day}T00:00:00Z`);
|
||||||
|
const dt = (day: string, hm: string) => new Date(`2026-06-${day}T${hm}:00`);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.attendance.deleteMany({
|
||||||
|
where: { users: { username: USERNAME } },
|
||||||
|
});
|
||||||
|
await prisma.users.deleteMany({ where: { username: USERNAME } });
|
||||||
|
|
||||||
|
// getAttendanceUsers includes only users whose role carries
|
||||||
|
// attendance.record — the seeded admin role has every permission.
|
||||||
|
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||||
|
if (!adminRole) throw new Error("Admin role not found — seed the DB first");
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: USERNAME,
|
||||||
|
email: `${USERNAME}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "Workfund",
|
||||||
|
last_name: "Parity",
|
||||||
|
is_active: true,
|
||||||
|
role_id: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
// June 2026 (fond 176 = 22 non-holiday weekdays × 8):
|
||||||
|
// 10 work days of 8h net (08:00–16:30, 30min break) → worked 80
|
||||||
|
const workDays = ["01", "02", "03", "04", "05", "08", "09", "10", "11", "12"];
|
||||||
|
for (const d of workDays) {
|
||||||
|
await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
shift_date: june(d),
|
||||||
|
arrival_time: dt(d, "08:00"),
|
||||||
|
departure_time: dt(d, "16:30"),
|
||||||
|
break_start: dt(d, "12:00"),
|
||||||
|
break_end: dt(d, "12:30"),
|
||||||
|
leave_type: "work",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// dovolená 24, nemoc 16, neplacené volno 8
|
||||||
|
const leaves: Array<[string, string, number]> = [
|
||||||
|
["15", "vacation", 8],
|
||||||
|
["16", "vacation", 8],
|
||||||
|
["17", "vacation", 8],
|
||||||
|
["18", "sick", 8],
|
||||||
|
["19", "sick", 8],
|
||||||
|
["22", "unpaid", 8],
|
||||||
|
];
|
||||||
|
for (const [d, type, hours] of leaves) {
|
||||||
|
await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
shift_date: june(d),
|
||||||
|
leave_type: type as never,
|
||||||
|
leave_hours: hours,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.attendance.deleteMany({ where: { user_id: userId } });
|
||||||
|
await prisma.users.deleteMany({ where: { id: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getWorkfund — print-recap parity", () => {
|
||||||
|
it("computes overtime from worked+dovolená and missing from all approved absences", async () => {
|
||||||
|
const data = await getWorkfund(2026);
|
||||||
|
// getWorkfund's future-year early return types months as a bare {} —
|
||||||
|
// narrow to the populated shape (2026 is never a future year here).
|
||||||
|
const months = data.months as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
fund: number;
|
||||||
|
users: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
worked: number;
|
||||||
|
covered: number;
|
||||||
|
covered_prescas: number;
|
||||||
|
overtime: number;
|
||||||
|
missing: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
const juneData = months["6"];
|
||||||
|
expect(juneData).toBeTruthy();
|
||||||
|
expect(juneData.fund).toBe(176);
|
||||||
|
|
||||||
|
const us = juneData.users[String(userId)];
|
||||||
|
expect(us).toBeTruthy();
|
||||||
|
expect(us.worked).toBe(80);
|
||||||
|
// covered (manko base): 80 + 24 + 16 + 8
|
||||||
|
expect(us.covered).toBe(128);
|
||||||
|
// covered_prescas (Přesčas base): 80 + 24 — sick/unpaid excluded
|
||||||
|
expect(us.covered_prescas).toBe(104);
|
||||||
|
// Chybějící hodiny: 176 − 128
|
||||||
|
expect(us.missing).toBe(48);
|
||||||
|
// Přesčas: max(0, 104 − 176)
|
||||||
|
expect(us.overtime).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
src/__tests__/audit-log-date-filter.test.ts
Normal file
96
src/__tests__/audit-log-date-filter.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import auditLogRoutes from "../routes/admin/audit-log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning test for audit finding L8b: the audit-log date_from boundary must
|
||||||
|
* be the LOCAL midnight of the requested day, symmetric with the date_to
|
||||||
|
* side (which already parses "T23:59:59" as local). The old
|
||||||
|
* `new Date(date_from)` was UTC midnight = 01:00/02:00 Prague, silently
|
||||||
|
* excluding events logged in the first morning hours of the from-day.
|
||||||
|
*
|
||||||
|
* NOTE: audit_logs.created_at is @db.Timestamp(0) — capped at 2038, so the
|
||||||
|
* far-future fixture year is 2037, not 2098.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "auditlog_datefilter_";
|
||||||
|
const DAY = "2037-04-09";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let earlyId: number; // 00:30 local — inside the old excluded window
|
||||||
|
let middayId: number; // 12:00 local
|
||||||
|
let prevDayId: number; // 23:30 local the day BEFORE — must stay excluded
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.audit_logs.deleteMany({
|
||||||
|
where: { description: { contains: N } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(auditLogRoutes, { prefix: "/api/admin/audit-log" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const early = await prisma.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
action: "test",
|
||||||
|
description: `${N}early`,
|
||||||
|
created_at: new Date(2037, 3, 9, 0, 30, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
earlyId = early.id;
|
||||||
|
const midday = await prisma.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
action: "test",
|
||||||
|
description: `${N}midday`,
|
||||||
|
created_at: new Date(2037, 3, 9, 12, 0, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
middayId = midday.id;
|
||||||
|
const prevDay = await prisma.audit_logs.create({
|
||||||
|
data: {
|
||||||
|
action: "test",
|
||||||
|
description: `${N}prevday`,
|
||||||
|
created_at: new Date(2037, 3, 8, 23, 30, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
prevDayId = prevDay.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audit-log date_from boundary (audit L8b)", () => {
|
||||||
|
it("includes events from the first morning hours of the from-day", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/audit-log?date_from=${DAY}&date_to=${DAY}&limit=100`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||||
|
|
||||||
|
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
|
||||||
|
expect(ids).toContain(middayId);
|
||||||
|
expect(ids).not.toContain(prevDayId); // previous evening stays out
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/__tests__/db-constraints.test.ts
Normal file
136
src/__tests__/db-constraints.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
|
|
||||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||||
const expected = (await previewIssuedOrderNumber()).number;
|
const expected = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
@@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|||||||
73
src/__tests__/html-to-pdf-retry.test.ts
Normal file
73
src/__tests__/html-to-pdf-retry.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M12: a shared Puppeteer browser that
|
||||||
|
* disconnects between getBrowser()'s point-in-time `connected` check and the
|
||||||
|
* actual render must be re-acquired once — instead of failing the request
|
||||||
|
* with a 500 that an immediate retry would have served fine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("puppeteer", () => ({ default: { launch: launchMock } }));
|
||||||
|
|
||||||
|
function healthyBrowser(pdfBytes = new Uint8Array([1, 2, 3])) {
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
newPage: vi.fn(async () => ({
|
||||||
|
setContent: vi.fn(async () => undefined),
|
||||||
|
pdf: vi.fn(async () => pdfBytes),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
})),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser whose connection dies the moment a page is requested — the
|
||||||
|
* post-guard crash window from the finding. */
|
||||||
|
function dyingBrowser() {
|
||||||
|
const b = {
|
||||||
|
connected: true,
|
||||||
|
newPage: vi.fn(async () => {
|
||||||
|
b.connected = false;
|
||||||
|
throw new Error("Protocol error (Target.createTarget): Session closed.");
|
||||||
|
}),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
launchMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("htmlToPdf browser re-acquire (audit M12)", () => {
|
||||||
|
it("relaunches once and resolves when the cached browser died mid-acquire", async () => {
|
||||||
|
launchMock
|
||||||
|
.mockResolvedValueOnce(dyingBrowser())
|
||||||
|
.mockResolvedValueOnce(healthyBrowser());
|
||||||
|
|
||||||
|
const { htmlToPdf } = await import("../utils/html-to-pdf");
|
||||||
|
const pdf = await htmlToPdf("<html><body>retry</body></html>");
|
||||||
|
|
||||||
|
expect(Buffer.isBuffer(pdf)).toBe(true);
|
||||||
|
expect(launchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT retry a genuine render error on a healthy browser", async () => {
|
||||||
|
const browser = healthyBrowser();
|
||||||
|
browser.newPage = vi.fn(async () => ({
|
||||||
|
setContent: vi.fn(async () => {
|
||||||
|
throw new Error("Render timeout");
|
||||||
|
}),
|
||||||
|
pdf: vi.fn(async () => new Uint8Array()),
|
||||||
|
close: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
launchMock.mockResolvedValue(browser);
|
||||||
|
|
||||||
|
const { htmlToPdf } = await import("../utils/html-to-pdf");
|
||||||
|
await expect(htmlToPdf("<html></html>")).rejects.toThrow("Render timeout");
|
||||||
|
expect(launchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
src/__tests__/invoice-date-coercion.test.ts
Normal file
91
src/__tests__/invoice-date-coercion.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import invoicesRoutes from "../routes/admin/invoices";
|
||||||
|
import {
|
||||||
|
CreateInvoiceSchema,
|
||||||
|
UpdateInvoiceSchema,
|
||||||
|
} from "../schemas/invoices.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M1: invoice date fields must go through the
|
||||||
|
* lenient ISO-date coercion. A round-tripped datetime string like
|
||||||
|
* "2026-03-02T00:00:00" (the toJSON override emits LOCAL time, no Z) parses
|
||||||
|
* as local Prague midnight = 23:00Z of the PREVIOUS day, and Prisma truncates
|
||||||
|
* @db.Date columns to the UTC date part — the invoice lands a day early and
|
||||||
|
* in the wrong month bucket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
const createdInvoiceIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of createdInvoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice @db.Date coercion (audit M1)", () => {
|
||||||
|
it("stores the submitted calendar day when issue_date carries a time part", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/invoices",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
status: "draft",
|
||||||
|
issue_date: "2026-03-02T00:00:00",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const id = res.json().data.id as number;
|
||||||
|
createdInvoiceIds.push(id);
|
||||||
|
|
||||||
|
const row = await prisma.invoices.findUnique({ where: { id } });
|
||||||
|
// @db.Date reads back as UTC midnight of the stored calendar day.
|
||||||
|
expect(row?.issue_date?.toISOString().slice(0, 10)).toBe("2026-03-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schema rejects a Czech-format date", () => {
|
||||||
|
const result = CreateInvoiceSchema.safeParse({
|
||||||
|
issue_date: "15.03.2026",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schema normalizes an empty string to null (edit forms clear with '')", () => {
|
||||||
|
const result = CreateInvoiceSchema.safeParse({ issue_date: "" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.issue_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update schema strips the time part from tax_date", () => {
|
||||||
|
const result = UpdateInvoiceSchema.safeParse({
|
||||||
|
tax_date: "2026-03-02T00:00:00",
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.tax_date).toBe("2026-03-02");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
src/__tests__/invoice-pdf-cnb-degrade.test.ts
Normal file
108
src/__tests__/invoice-pdf-cnb-degrade.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||||
|
import { createInvoice } from "../services/invoices.service";
|
||||||
|
import { getRate } from "../services/exchange-rates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M5: a foreign-currency invoice PDF must
|
||||||
|
* degrade gracefully when the CNB rate lookup fails — render WITHOUT the CZK
|
||||||
|
* VAT recap/conversion footnote instead of 500ing the whole preview AND the
|
||||||
|
* NAS archival. The rate only feeds the recap conversion; the document itself
|
||||||
|
* is renderable without it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("../services/exchange-rates", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("../services/exchange-rates")>();
|
||||||
|
return { ...actual, getRate: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
const createdInvoiceIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of createdInvoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeEurInvoice() {
|
||||||
|
// Draft → no number consumed; EUR → triggers the CNB rate lookup.
|
||||||
|
const invoice = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "EUR",
|
||||||
|
apply_vat: true,
|
||||||
|
vat_rate: 21,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "CNB-DEGRADE-MARKER",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 100,
|
||||||
|
vat_rate: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
createdInvoiceIds.push(invoice.id);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("invoice PDF vs unreachable CNB (audit M5)", () => {
|
||||||
|
it("renders the invoice without the CZK recap when the rate lookup throws", async () => {
|
||||||
|
vi.mocked(getRate).mockRejectedValue(
|
||||||
|
new Error("Nepodařilo se získat aktuální kurzy z ČNB"),
|
||||||
|
);
|
||||||
|
const invoice = await makeEurInvoice();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const html = res.body;
|
||||||
|
expect(html).toContain("CNB-DEGRADE-MARKER");
|
||||||
|
// The CZK recap and the conversion footnote are omitted — their numbers
|
||||||
|
// cannot be computed without the rate.
|
||||||
|
expect(html).not.toContain("Rekapitulace DPH v Kč");
|
||||||
|
expect(html).not.toContain("Přepočet kurzem ČNB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders the recap + conversion footnote when the rate is available", async () => {
|
||||||
|
vi.mocked(getRate).mockResolvedValue(25.0);
|
||||||
|
const invoice = await makeEurInvoice();
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const html = res.body;
|
||||||
|
expect(html).toContain("Rekapitulace DPH v Kč");
|
||||||
|
expect(html).toContain("Přepočet kurzem ČNB");
|
||||||
|
expect(html).toContain("25,000");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const order = await mkIssued({ status: "sent" });
|
const order = await mkIssued({
|
||||||
|
status: "sent",
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
expect(order.po_number).toBe(before);
|
expect(order.po_number).toBe(before);
|
||||||
expect(order.status).toBe("sent");
|
expect(order.status).toBe("sent");
|
||||||
});
|
});
|
||||||
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
expect("error" in ok).toBe(false);
|
expect("error" in ok).toBe(false);
|
||||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("status-only transition payloads still work when not editable", async () => {
|
it("status-only transition payloads still work when not editable", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finalizes a sections-only order (no items) successfully", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, {
|
||||||
|
status: "sent",
|
||||||
|
items: [],
|
||||||
|
sections: [
|
||||||
|
{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.status).toBe("sent");
|
||||||
|
expect(row!.po_number).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects finalizing a completely blank order (no items, no sections)", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, {
|
||||||
|
status: "sent",
|
||||||
|
items: [],
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
expect("error" in res && res.error).toBe("empty_document");
|
||||||
|
// Stays a draft — the finalize was rejected before any write.
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.status).toBe("draft");
|
||||||
|
expect(row!.po_number).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects creating a non-draft order with no content", async () => {
|
||||||
|
const res = await createIssuedOrder({ status: "sent" });
|
||||||
|
expect("error" in res && res.error).toBe("empty_document");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrder", () => {
|
describe("getIssuedOrder", () => {
|
||||||
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
|
|||||||
|
|
||||||
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||||
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
|
|
||||||
@@ -539,8 +586,46 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
|||||||
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => {
|
||||||
|
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||||
|
// full payload + status in ONE PUT while the order is still editable
|
||||||
|
// (sent). The server must apply the items AND the transition together.
|
||||||
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/issued-orders/${order.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: {
|
||||||
|
status: "confirmed",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Flushnutá položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 50,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
include: { issued_order_items: true },
|
||||||
|
});
|
||||||
|
expect(row!.status).toBe("confirmed");
|
||||||
|
expect(row!.issued_order_items.map((i) => i.description)).toContain(
|
||||||
|
"Flushnutá položka",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
|
|
||||||
@@ -699,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Odběratel");
|
expect(html).toContain("Odběratel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits the items table and total when there are no items (sections-only order)", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
[],
|
||||||
|
supplier,
|
||||||
|
{ company_name: "Naše firma" },
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
[{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" }],
|
||||||
|
);
|
||||||
|
// The section renders…
|
||||||
|
expect(html).toContain("Rozsah prací");
|
||||||
|
// …but the items table, the billing heading and the total row do not.
|
||||||
|
expect(html).not.toContain('class="items"');
|
||||||
|
expect(html).not.toContain("Celkem bez DPH");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the structured supplier address plus IČO and DIČ", () => {
|
it("renders the structured supplier address plus IČO and DIČ", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
|
|||||||
130
src/__tests__/item-discount-pdf.test.ts
Normal file
130
src/__tests__/item-discount-pdf.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||||
|
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||||
|
import { createInvoice } from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
|
||||||
|
* least one item carries a discount > 0; otherwise the table looks exactly as
|
||||||
|
* before. The discounted net always flows into the line/grand totals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
const quotationIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||||
|
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of quotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const offerHtml = async (discount: number): Promise<string> => {
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 100,
|
||||||
|
discount,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quotationIds.push(q.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${q.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("offer PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column + discounted total when an item has a discount", async () => {
|
||||||
|
const html = await offerHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
|
||||||
|
expect(html).toContain("180,00");
|
||||||
|
expect(html).not.toContain("200,00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await offerHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
expect(html).toContain("200,00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceHtml = async (discount: number): Promise<string> => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
apply_vat: true,
|
||||||
|
vat_rate: 21,
|
||||||
|
language: "cs",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1000,
|
||||||
|
discount,
|
||||||
|
vat_rate: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/invoices-pdf/${inv.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("invoice PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column when an item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
expect(html).toContain("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/__tests__/item-discount-schema.test.ts
Normal file
55
src/__tests__/item-discount-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||||
|
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
|
||||||
|
* accept 0–100, default to 0 when absent, and reject out-of-range values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("offer item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults discount to 0 when absent", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a discount above 100", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount and defaults to 0", () => {
|
||||||
|
const ok = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
|
||||||
|
});
|
||||||
|
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
|
||||||
|
|
||||||
|
const def = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(def.success && def.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative discount", () => {
|
||||||
|
const r = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
src/__tests__/item-discount-totals.test.ts
Normal file
94
src/__tests__/item-discount-totals.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||||
|
import {
|
||||||
|
createInvoice,
|
||||||
|
getInvoiceListTotals,
|
||||||
|
} from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
|
||||||
|
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
|
||||||
|
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARKER = "SLEVA-TOTALS-2096";
|
||||||
|
const YEAR = 2096;
|
||||||
|
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
|
||||||
|
const dateStr = `${YEAR}-0${MONTH}-15`;
|
||||||
|
|
||||||
|
const offerIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of offerIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
offerIds.length = 0;
|
||||||
|
invoiceIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["offer", "invoice"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountFor = (
|
||||||
|
totals: { currency: string; amount: number }[],
|
||||||
|
currency: string,
|
||||||
|
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
|
||||||
|
|
||||||
|
describe("offer per-item discount", () => {
|
||||||
|
it("reduces the offer NET total by the per-item percentage", async () => {
|
||||||
|
// 2 × 1000 with 10% off => 1800 net.
|
||||||
|
const res = await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
project_code: MARKER,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||||
|
offerIds.push(res.id);
|
||||||
|
|
||||||
|
const { totals } = await getOfferTotals({ search: MARKER });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice per-item discount", () => {
|
||||||
|
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
|
||||||
|
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(2178);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount line as zero", async () => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
|
||||||
|
});
|
||||||
|
});
|
||||||
202
src/__tests__/leave-requests-holidays.test.ts
Normal file
202
src/__tests__/leave-requests-holidays.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import leaveRequestsRoutes from "../routes/admin/leave-requests";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding H6 + the year-spanning fast-follow:
|
||||||
|
* leave-request creation and approval must mirror attendance.service
|
||||||
|
* createLeave — skip Czech public holidays (a weekday holiday is already
|
||||||
|
* paid/free; charging it double-deducts) and book each day's hours against
|
||||||
|
* ITS OWN calendar year's balance (a Dec→Jan request used to charge
|
||||||
|
* everything to the start year).
|
||||||
|
*
|
||||||
|
* Fixture math (verified): 2098-05-01 (Thu) and 2098-05-08 (Thu) are both
|
||||||
|
* weekday holidays → the 05-01..05-08 range has 4 chargeable days (32h),
|
||||||
|
* not 6 (48h). 2098-12-28 (Sun)..2099-01-05 (Mon) has 3 chargeable days in
|
||||||
|
* 2098 (29/30/31 = 24h) and 2 in 2099 (Jan 2 + Jan 5 = 16h; Jan 1 is a
|
||||||
|
* holiday).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "leave_holiday_";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let userToken: string;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
where: { username: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ids = users.map((u) => u.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
|
||||||
|
await prisma.leave_requests.deleteMany({
|
||||||
|
where: { user_id: { in: ids } },
|
||||||
|
});
|
||||||
|
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
|
||||||
|
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(leaveRequestsRoutes, {
|
||||||
|
prefix: "/api/admin/leave-requests",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = await prisma.roles.findFirst();
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user`,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
first_name: "Leave",
|
||||||
|
last_name: "Holiday",
|
||||||
|
is_active: true,
|
||||||
|
role_id: role!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
userToken = jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: role!.name },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const year of [2098, 2099]) {
|
||||||
|
await prisma.leave_balances.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
year,
|
||||||
|
vacation_total: 200,
|
||||||
|
vacation_used: 0,
|
||||||
|
sick_used: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function balance(year: number) {
|
||||||
|
return prisma.leave_balances.findFirst({
|
||||||
|
where: { user_id: userId, year },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("leave requests vs Czech holidays (audit H6)", () => {
|
||||||
|
it("creation excludes weekday public holidays from the charged total", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/leave-requests",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
leave_type: "vacation",
|
||||||
|
date_from: "2098-05-01",
|
||||||
|
date_to: "2098-05-08",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const row = await prisma.leave_requests.findFirst({
|
||||||
|
where: { id: res.json().data.id },
|
||||||
|
});
|
||||||
|
expect(row?.total_days).toBe(4);
|
||||||
|
expect(Number(row?.total_hours)).toBe(32);
|
||||||
|
// Leave it cancelled so the approval test's range stays free.
|
||||||
|
await prisma.leave_requests.update({
|
||||||
|
where: { id: row!.id },
|
||||||
|
data: { status: "cancelled" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("approval charges only non-holiday business days and skips holiday attendance rows", async () => {
|
||||||
|
// Stored totals mimic a PRE-FIX request (weekend-only counting) — the
|
||||||
|
// approval must recompute, not trust them.
|
||||||
|
const req = await prisma.leave_requests.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
leave_type: "vacation",
|
||||||
|
date_from: new Date(Date.UTC(2098, 4, 1)),
|
||||||
|
date_to: new Date(Date.UTC(2098, 4, 8)),
|
||||||
|
total_hours: 48,
|
||||||
|
total_days: 6,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/leave-requests/${req.id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { status: "approved" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
expect(Number((await balance(2098))?.vacation_used)).toBe(32);
|
||||||
|
|
||||||
|
const rows = await prisma.attendance.findMany({
|
||||||
|
where: { user_id: userId, leave_type: "vacation" },
|
||||||
|
});
|
||||||
|
const days = rows.map((r) => r.shift_date.toISOString().slice(0, 10));
|
||||||
|
expect(rows).toHaveLength(4);
|
||||||
|
expect(days).not.toContain("2098-05-01");
|
||||||
|
expect(days).not.toContain("2098-05-08");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a year-spanning approval books each day against its own year's balance", async () => {
|
||||||
|
const req = await prisma.leave_requests.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
leave_type: "sick",
|
||||||
|
date_from: new Date(Date.UTC(2098, 11, 28)),
|
||||||
|
date_to: new Date(Date.UTC(2099, 0, 5)),
|
||||||
|
total_hours: 48,
|
||||||
|
total_days: 6,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/leave-requests/${req.id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { status: "approved" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// 29/30/31 Dec 2098 = 24h; Jan 2 + Jan 5 2099 = 16h (Jan 1 is a holiday).
|
||||||
|
expect(Number((await balance(2098))?.sick_used)).toBe(24);
|
||||||
|
expect(Number((await balance(2099))?.sick_used)).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
117
src/__tests__/modal-duplicate-close.test.ts
Normal file
117
src/__tests__/modal-duplicate-close.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/__tests__/money.test.ts
Normal file
22
src/__tests__/money.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
|
|
||||||
|
describe("lineNet (per-item discounted net)", () => {
|
||||||
|
it("returns qty × unit_price when there is no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, 0)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a percentage discount to the line net", () => {
|
||||||
|
expect(lineNet(2, 1000, 10)).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount as zero", () => {
|
||||||
|
expect(lineNet(3, 500, 100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null/undefined/NaN discount as no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, null)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, undefined)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, NaN)).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
src/__tests__/navdata-icons.test.ts
Normal file
51
src/__tests__/navdata-icons.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/__tests__/offer-number-release.test.ts
Normal file
100
src/__tests__/offer-number-release.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { deleteOffer } from "../services/offers.service";
|
||||||
|
import { applyPattern } from "../services/numbering.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning test for audit finding L2: an offer created in year Y but
|
||||||
|
* finalized (numbered) in year Y+1 must release the Y+1 sequence on delete.
|
||||||
|
* deleteOffer used to derive the release year from created_at, so the
|
||||||
|
* reconstructed highest number ("2025/NA/...") never matched the actual
|
||||||
|
* "2026/NA/..." and the counter kept a permanent gap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
|
||||||
|
let offerId: number;
|
||||||
|
let originalLastNumber: number | null = null; // null = row absent before test
|
||||||
|
let offerNumber: string;
|
||||||
|
|
||||||
|
async function getSeq(): Promise<number | null> {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ last_number: number }>>`
|
||||||
|
SELECT last_number FROM number_sequences
|
||||||
|
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||||
|
`;
|
||||||
|
return rows.length > 0 ? Number(rows[0].last_number) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const settings = await prisma.company_settings.findFirst();
|
||||||
|
const pattern = settings?.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}";
|
||||||
|
const prefix = settings?.quotation_prefix || "NA";
|
||||||
|
|
||||||
|
originalLastNumber = await getSeq();
|
||||||
|
const base = originalLastNumber ?? 0;
|
||||||
|
const seq = base + 1;
|
||||||
|
offerNumber = applyPattern(pattern, {
|
||||||
|
year: CURRENT_YEAR,
|
||||||
|
prefix,
|
||||||
|
code: "",
|
||||||
|
seq,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume the number in the sequence (what finalize would have done).
|
||||||
|
if (originalLastNumber === null) {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
|
||||||
|
VALUES ('offer', ${CURRENT_YEAR}, ${seq})
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE number_sequences SET \`last_number\` = ${seq}
|
||||||
|
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The offer itself was CREATED last year (draft), finalized this year —
|
||||||
|
// its number carries the finalize year, created_at the draft year.
|
||||||
|
const offer = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: offerNumber,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
created_at: new Date(CURRENT_YEAR - 1, 11, 30, 12, 0, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
offerId = offer.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.quotations
|
||||||
|
.deleteMany({ where: { id: offerId } })
|
||||||
|
.catch(() => {});
|
||||||
|
// Restore the sequence to its pre-test value regardless of outcome.
|
||||||
|
if (originalLastNumber === null) {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
DELETE FROM number_sequences
|
||||||
|
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE number_sequences SET \`last_number\` = ${originalLastNumber}
|
||||||
|
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offer sequence release across years (audit L2)", () => {
|
||||||
|
it("releases the finalize-year sequence when created_at is in a previous year", async () => {
|
||||||
|
const before = await getSeq();
|
||||||
|
expect(before).toBe((originalLastNumber ?? 0) + 1);
|
||||||
|
|
||||||
|
const result = await deleteOffer(offerId);
|
||||||
|
expect(result && "error" in result ? result.error : null).toBeNull();
|
||||||
|
|
||||||
|
// The highest number was just deleted → the counter must step back.
|
||||||
|
expect(await getSeq()).toBe(originalLastNumber ?? 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => {
|
|||||||
expect(withoutDetail!.attachment_name).toBeNull();
|
expect(withoutDetail!.attachment_name).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PUT /:id accepts the canonical storno token through the Zod layer", async () => {
|
||||||
|
// Regression: UpdateOrderSchema carried a phantom "zrusena" enum member
|
||||||
|
// until 2026-07, so { status: "stornovana" } 400ed at parseBody with a
|
||||||
|
// raw English Zod message. Service-level tests bypass the schema — this
|
||||||
|
// must stay a route-level test.
|
||||||
|
const customer = await makeCustomer();
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
customer_id: customer.id,
|
||||||
|
create_project: false,
|
||||||
|
});
|
||||||
|
if (!("id" in res)) failResult(res);
|
||||||
|
createdOrderIds.push(res.id!);
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/orders/${res.id}`,
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { status: "stornovana" },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
const row = await prisma.orders.findUnique({ where: { id: res.id! } });
|
||||||
|
expect(row!.status).toBe("stornovana");
|
||||||
|
});
|
||||||
|
|
||||||
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||||
const { withId, withoutId } = await makeOrderPair();
|
const { withId, withoutId } = await makeOrderPair();
|
||||||
const headers = { authorization: `Bearer ${adminToken}` };
|
const headers = { authorization: `Bearer ${adminToken}` };
|
||||||
|
|||||||
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import customersRoutes from "../routes/admin/customers";
|
||||||
|
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding L3: offset pagination over a non-unique
|
||||||
|
* sort column needs an `{ id }` tiebreak, or rows sharing the sort value can
|
||||||
|
* reorder between page queries — duplicating one row and skipping another.
|
||||||
|
* (Documented determinism convention; siblings issued-orders/trips already
|
||||||
|
* use compound orderings.)
|
||||||
|
*
|
||||||
|
* NOTE: without the tiebreak the misbehavior is probabilistic (MySQL may
|
||||||
|
* return a stable order by luck), so the paging assertion is primarily a
|
||||||
|
* REGRESSION GUARD pinning the exactly-once contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "tiebreak_test_";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let customerIds: number[] = [];
|
||||||
|
let invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.customers.deleteMany({ where: { name: { startsWith: N } } });
|
||||||
|
await prisma.received_invoices.deleteMany({
|
||||||
|
where: { supplier_name: { startsWith: N } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(customersRoutes, { prefix: "/api/admin/customers" });
|
||||||
|
await app.register(receivedInvoicesRoutes, {
|
||||||
|
prefix: "/api/admin/received-invoices",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5 customers sharing one city (the sort key) so every page boundary
|
||||||
|
// falls inside a duplicate-key run.
|
||||||
|
customerIds = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const c = await prisma.customers.create({
|
||||||
|
data: { name: `${N}c${i}`, city: "Tiebreakov" },
|
||||||
|
});
|
||||||
|
customerIds.push(c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 received invoices sharing supplier_name in one far-future month.
|
||||||
|
invoiceIds = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const inv = await prisma.received_invoices.create({
|
||||||
|
data: {
|
||||||
|
supplier_name: `${N}supplier`,
|
||||||
|
month: 7,
|
||||||
|
year: 2098,
|
||||||
|
amount: 100 + i,
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
vat_amount: 17.36,
|
||||||
|
status: "unpaid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function collectPages(urlBase: string, pages: number) {
|
||||||
|
const seen: number[] = [];
|
||||||
|
for (let page = 1; page <= pages; page++) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `${urlBase}&page=${page}&limit=2`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
for (const row of res.json().data as Array<{ id: number }>) {
|
||||||
|
seen.push(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("offset pagination exactly-once (audit L3)", () => {
|
||||||
|
it("customers sorted by a duplicate city appear exactly once across pages", async () => {
|
||||||
|
const seen = (
|
||||||
|
await collectPages("/api/admin/customers?sort=city&search=" + N, 3)
|
||||||
|
).filter((id) => customerIds.includes(id));
|
||||||
|
|
||||||
|
expect([...new Set(seen)].sort()).toEqual([...customerIds].sort());
|
||||||
|
expect(seen.length).toBe(customerIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("received invoices sorted by a duplicate supplier appear exactly once across pages", async () => {
|
||||||
|
const seen = (
|
||||||
|
await collectPages(
|
||||||
|
"/api/admin/received-invoices?sort=supplier_name&month=7&year=2098",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
).filter((id) => invoiceIds.includes(id));
|
||||||
|
|
||||||
|
expect([...new Set(seen)].sort()).toEqual([...invoiceIds].sort());
|
||||||
|
expect(seen.length).toBe(invoiceIds.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/__tests__/pdf-shared.test.ts
Normal file
55
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||||
|
|
||||||
|
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||||
|
it("keeps Quill text and background colors", () => {
|
||||||
|
const html =
|
||||||
|
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||||
|
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||||
|
'<span style="color: #06c;">modře</span></p>';
|
||||||
|
const out = cleanQuillHtml(html);
|
||||||
|
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||||
|
expect(out).toContain('style="background-color: #ffff00"');
|
||||||
|
expect(out).toContain('style="color: #06c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops every non-color style declaration", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||||
|
);
|
||||||
|
expect(out).toBe('<span style="color: red">x</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(
|
||||||
|
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||||
|
).toBe("<span>x</span>");
|
||||||
|
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||||
|
"<span>x</span>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
|
||||||
|
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
|
||||||
|
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
|
||||||
|
"<p>A</p><p><br></p><p>B</p>",
|
||||||
|
);
|
||||||
|
// Attribute-carrying and whitespace-only empties too.
|
||||||
|
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
|
||||||
|
'<p class="ql-align-center"><br></p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||||
|
const out = cleanQuillHtml(
|
||||||
|
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||||
|
'<a href="javascript:alert(1)">x</a></p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain("script");
|
||||||
|
expect(out).not.toContain("onclick");
|
||||||
|
expect(out).not.toContain("javascript:");
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/__tests__/plan-update-cap.test.ts
Normal file
106
src/__tests__/plan-update-cap.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { updateEntry } from "../services/plan.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M9: updateEntry must re-check the per-cell
|
||||||
|
* cap (MAX_RECORDS_PER_CELL = 3) when the date range changes — expanding an
|
||||||
|
* entry onto a day that already holds 3 active entries used to succeed,
|
||||||
|
* producing a 4th that the grid (capped at 3) silently hides.
|
||||||
|
*
|
||||||
|
* The re-check must EXCLUDE the updated entry itself, so editing an at-cap
|
||||||
|
* entry without moving it stays allowed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOTE = "plan_updcap_test";
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
let cappedEntryIds: number[] = [];
|
||||||
|
let fourthId: number;
|
||||||
|
|
||||||
|
const D = (day: number) => new Date(Date.UTC(2099, 6, day)); // July 2099, UTC
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.work_plan_entries.deleteMany({ where: { note: NOTE } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||||
|
if (!user) throw new Error("Test setup: no users — seed the database");
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
cappedEntryIds = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const entry = await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
date_from: D(1),
|
||||||
|
date_to: D(1),
|
||||||
|
category: "work",
|
||||||
|
note: NOTE,
|
||||||
|
created_by: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cappedEntryIds.push(entry.id);
|
||||||
|
}
|
||||||
|
const fourth = await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
date_from: D(2),
|
||||||
|
date_to: D(2),
|
||||||
|
category: "work",
|
||||||
|
note: NOTE,
|
||||||
|
created_by: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fourthId = fourth.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEntry per-cell cap (audit M9)", () => {
|
||||||
|
it("rejects expanding an entry onto a day already at the cap", async () => {
|
||||||
|
const result = await updateEntry(
|
||||||
|
fourthId,
|
||||||
|
{ date_from: "2099-07-01", date_to: "2099-07-01" },
|
||||||
|
userId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) expect(result.status).toBe(400);
|
||||||
|
|
||||||
|
// The entry must not have moved.
|
||||||
|
const fresh = await prisma.work_plan_entries.findUnique({
|
||||||
|
where: { id: fourthId },
|
||||||
|
});
|
||||||
|
expect(fresh?.date_from.toISOString().slice(0, 10)).toBe("2099-07-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows editing an at-cap entry without moving it (self-exclusion)", async () => {
|
||||||
|
const result = await updateEntry(
|
||||||
|
cappedEntryIds[0],
|
||||||
|
{ date_from: "2099-07-01", date_to: "2099-07-01", note: NOTE },
|
||||||
|
userId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows a note-only edit and a move to a free day", async () => {
|
||||||
|
const noteOnly = await updateEntry(fourthId, { note: NOTE }, userId, false);
|
||||||
|
expect("error" in noteOnly).toBe(false);
|
||||||
|
|
||||||
|
const move = await updateEntry(
|
||||||
|
fourthId,
|
||||||
|
{ date_from: "2099-07-03", date_to: "2099-07-03" },
|
||||||
|
userId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("error" in move).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1090,26 +1090,6 @@ async function authPost(path: string, token: string, body: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authPatch(path: string, token: string, body: unknown) {
|
|
||||||
return app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: path,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
payload: body as object,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authDelete(path: string, token: string) {
|
|
||||||
return app.inject({
|
|
||||||
method: "DELETE",
|
|
||||||
url: path,
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await buildApp();
|
app = await buildApp();
|
||||||
|
|
||||||
|
|||||||
227
src/__tests__/project-notes.test.ts
Normal file
227
src/__tests__/project-notes.test.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import projectsRoutes from "../routes/admin/projects";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project notes: author-only editing with a visible edited_at stamp, and
|
||||||
|
// admin-only deletion (a projects.edit holder may add + edit their own notes
|
||||||
|
// but never delete — user decision 2026-06-12).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const N = "projnotes_test_";
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||||
|
let adminToken: string;
|
||||||
|
let userAId: number;
|
||||||
|
let userAToken: string;
|
||||||
|
let userBToken: string;
|
||||||
|
let roleId: number;
|
||||||
|
let projectId: number;
|
||||||
|
let noteId: number;
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const a = Fastify({ logger: false });
|
||||||
|
await a.register(cookie);
|
||||||
|
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
a.addHook("onRequest", securityHeaders);
|
||||||
|
await a.register(projectsRoutes, { prefix: "/api/admin/projects" });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
roleName: string | null;
|
||||||
|
}): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: user.roleName },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inject(
|
||||||
|
method: "POST" | "PUT" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
token: string,
|
||||||
|
body?: unknown,
|
||||||
|
) {
|
||||||
|
return app.inject({
|
||||||
|
method,
|
||||||
|
url: path,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
// Content-Type only with a body — Fastify 400s an empty JSON body.
|
||||||
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
...(body !== undefined ? { payload: body as object } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
// Defensive pre-clean of a previously failed run.
|
||||||
|
await prisma.project_notes.deleteMany({
|
||||||
|
where: { content: { contains: N } },
|
||||||
|
});
|
||||||
|
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||||
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||||
|
await prisma.roles.deleteMany({ where: { name: `${N}role` } });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
include: { roles: true },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = generateToken({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: admin.roles?.name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-admin role with projects.view + projects.edit.
|
||||||
|
const role = await prisma.roles.create({
|
||||||
|
data: { name: `${N}role`, display_name: "ProjNotes test role" },
|
||||||
|
});
|
||||||
|
roleId = role.id;
|
||||||
|
const perms = await prisma.permissions.findMany({
|
||||||
|
where: { name: { in: ["projects.view", "projects.edit"] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (perms.length !== 2) {
|
||||||
|
throw new Error("Test setup: projects.view/projects.edit missing");
|
||||||
|
}
|
||||||
|
await prisma.role_permissions.createMany({
|
||||||
|
data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mkUser = async (suffix: string) =>
|
||||||
|
prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}${suffix}`,
|
||||||
|
email: `${N}${suffix}@test.local`,
|
||||||
|
password_hash: "x",
|
||||||
|
first_name: "Projnotes",
|
||||||
|
last_name: suffix.toUpperCase(),
|
||||||
|
is_active: true,
|
||||||
|
role_id: roleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const userA = await mkUser("a");
|
||||||
|
const userB = await mkUser("b");
|
||||||
|
userAId = userA.id;
|
||||||
|
userAToken = generateToken({
|
||||||
|
id: userA.id,
|
||||||
|
username: userA.username,
|
||||||
|
roleName: `${N}role`,
|
||||||
|
});
|
||||||
|
userBToken = generateToken({
|
||||||
|
id: userB.id,
|
||||||
|
username: userB.username,
|
||||||
|
roleName: `${N}role`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project`, status: "aktivni" },
|
||||||
|
});
|
||||||
|
projectId = project.id;
|
||||||
|
|
||||||
|
// Note authored by user A (through the route, like the UI does).
|
||||||
|
const res = await inject(
|
||||||
|
"POST",
|
||||||
|
`/api/admin/projects/${projectId}/notes`,
|
||||||
|
userAToken,
|
||||||
|
{ content: `${N}original` },
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
noteId = res.json().data.note.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.project_notes.deleteMany({ where: { project_id: projectId } });
|
||||||
|
await prisma.projects.deleteMany({ where: { id: projectId } });
|
||||||
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||||
|
await prisma.roles.deleteMany({ where: { id: roleId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project notes — author-only edit with edited_at stamp", () => {
|
||||||
|
it("the author edits their own note and the edit is stamped", async () => {
|
||||||
|
const res = await inject(
|
||||||
|
"PUT",
|
||||||
|
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||||
|
userAToken,
|
||||||
|
{ content: `${N}edited` },
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const row = await prisma.project_notes.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
});
|
||||||
|
expect(row?.content).toBe(`${N}edited`);
|
||||||
|
expect(row?.user_id).toBe(userAId);
|
||||||
|
expect(row?.edited_at).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("another user cannot edit someone else's note", async () => {
|
||||||
|
const res = await inject(
|
||||||
|
"PUT",
|
||||||
|
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||||
|
userBToken,
|
||||||
|
{ content: `${N}hijack` },
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
const row = await prisma.project_notes.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
});
|
||||||
|
expect(row?.content).toBe(`${N}edited`); // unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty content and unknown note ids", async () => {
|
||||||
|
const empty = await inject(
|
||||||
|
"PUT",
|
||||||
|
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||||
|
userAToken,
|
||||||
|
{ content: " " },
|
||||||
|
);
|
||||||
|
expect(empty.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const missing = await inject(
|
||||||
|
"PUT",
|
||||||
|
`/api/admin/projects/${projectId}/notes/999999999`,
|
||||||
|
userAToken,
|
||||||
|
{ content: "x" },
|
||||||
|
);
|
||||||
|
expect(missing.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project notes — admin-only deletion", () => {
|
||||||
|
it("a projects.edit holder cannot delete (even their own note)", async () => {
|
||||||
|
const res = await inject(
|
||||||
|
"DELETE",
|
||||||
|
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||||
|
userAToken,
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin deletes anybody's note", async () => {
|
||||||
|
const res = await inject(
|
||||||
|
"DELETE",
|
||||||
|
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const row = await prisma.project_notes.findUnique({
|
||||||
|
where: { id: noteId },
|
||||||
|
});
|
||||||
|
expect(row).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
301
src/__tests__/project-order-status-sync.test.ts
Normal file
301
src/__tests__/project-order-status-sync.test.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { describe, it, expect, afterEach, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import {
|
||||||
|
updateProject,
|
||||||
|
getProject,
|
||||||
|
VALID_TRANSITIONS as PROJECT_VALID_TRANSITIONS,
|
||||||
|
} from "../services/projects.service";
|
||||||
|
import { updateOrder } from "../services/orders.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirectional project⇄order status sync (spec 2026-07-04):
|
||||||
|
* - projects gain a status machine (aktivni ⇄ dokonceny/zruseny) with
|
||||||
|
* legacy-status tolerance,
|
||||||
|
* - finishing/cancelling a project cascades onto its linked OPEN order
|
||||||
|
* (prijata/v_realizaci) — terminal orders are never touched,
|
||||||
|
* - reopen (project → aktivni, order → v_realizaci) is now a legal
|
||||||
|
* transition and NEVER cascades,
|
||||||
|
* - regression: the existing order→project forward sync stays intact.
|
||||||
|
*
|
||||||
|
* Real app_test DB via the service layer (suite convention). Fixtures use a
|
||||||
|
* unique prefix; projects are created without project_number so getProject's
|
||||||
|
* NAS folder probe is skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "posync_";
|
||||||
|
|
||||||
|
const createdProjectIds: number[] = [];
|
||||||
|
const createdOrderIds: number[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
async function mkOrder(status: string) {
|
||||||
|
const order = await prisma.orders.create({
|
||||||
|
data: { order_number: `${N}${Date.now()}_${seq++}`, status },
|
||||||
|
});
|
||||||
|
createdOrderIds.push(order.id);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkProject(status: string, orderId?: number) {
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project_${seq++}`, status, order_id: orderId ?? null },
|
||||||
|
});
|
||||||
|
createdProjectIds.push(project.id);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK-safe order: projects reference orders (Restrict), so projects go first.
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.projects.deleteMany({
|
||||||
|
where: { id: { in: createdProjectIds } },
|
||||||
|
});
|
||||||
|
await prisma.orders.deleteMany({ where: { id: { in: createdOrderIds } } });
|
||||||
|
createdProjectIds.length = 0;
|
||||||
|
createdOrderIds.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertOk<T>(
|
||||||
|
res: T,
|
||||||
|
): asserts res is Exclude<T, null | { error: unknown }> {
|
||||||
|
if (!res || (typeof res === "object" && "error" in res)) {
|
||||||
|
throw new Error(`expected success, got ${JSON.stringify(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project → order cascade (updateProject)", () => {
|
||||||
|
it("aktivni→dokonceny completes a linked v_realizaci order and returns synced_order", async () => {
|
||||||
|
const order = await mkOrder("v_realizaci");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toEqual({
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
from: "v_realizaci",
|
||||||
|
to: "dokoncena",
|
||||||
|
});
|
||||||
|
expect(res.old_status).toBe("aktivni");
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("dokoncena");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aktivni→zruseny cancels a linked prijata order", async () => {
|
||||||
|
const order = await mkOrder("prijata");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "zruseny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toEqual({
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
from: "prijata",
|
||||||
|
to: "stornovana",
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("stornovana");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guard: never resurrects a terminal order (stornovana stays stornovana)", async () => {
|
||||||
|
const order = await mkOrder("stornovana");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("stornovana");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen (dokonceny→aktivni) never touches the linked order", async () => {
|
||||||
|
const order = await mkOrder("dokoncena");
|
||||||
|
const project = await mkProject("dokonceny", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "aktivni" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("dokoncena");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("aktivni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no linked order → no cascade, synced_order null", async () => {
|
||||||
|
const project = await mkProject("aktivni");
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project status machine (updateProject validation)", () => {
|
||||||
|
it("rejects dokonceny→zruseny with the invalid_transition token", async () => {
|
||||||
|
const project = await mkProject("dokonceny");
|
||||||
|
const res = await updateProject(project.id, { status: "zruseny" });
|
||||||
|
expect(res && "error" in res && res.error).toBe("invalid_transition");
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
currentStatus: "dokonceny",
|
||||||
|
newStatus: "zruseny",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing was written.
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(fresh?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy tolerance: an unknown current status may move to any canonical target", async () => {
|
||||||
|
const project = await mkProject("stary");
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(fresh?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy tolerance still rejects a non-canonical target", async () => {
|
||||||
|
const project = await mkProject("stary");
|
||||||
|
const res = await updateProject(project.id, { status: "nesmysl" });
|
||||||
|
expect(res && "error" in res && res.error).toBe("invalid_transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status-unchanged payloads pass without transition validation", async () => {
|
||||||
|
const project = await mkProject("dokonceny");
|
||||||
|
const res = await updateProject(project.id, {
|
||||||
|
status: "dokonceny",
|
||||||
|
notes: `${N}note`,
|
||||||
|
});
|
||||||
|
assertOk(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("order reopen + order → project sync (updateOrder)", () => {
|
||||||
|
it("dokoncena→v_realizaci is now valid and skips project sync (reopen)", async () => {
|
||||||
|
const order = await mkOrder("dokoncena");
|
||||||
|
const project = await mkProject("dokonceny", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "v_realizaci" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toEqual([]);
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("v_realizaci");
|
||||||
|
// Reopen never cascades — the project keeps its terminal status.
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stornovana→v_realizaci is valid too; other exits from terminal stay rejected", async () => {
|
||||||
|
const order = await mkOrder("stornovana");
|
||||||
|
const res = await updateOrder(order.id, { status: "v_realizaci" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
|
||||||
|
const back = await mkOrder("dokoncena");
|
||||||
|
const rejected = await updateOrder(back.id, { status: "prijata" });
|
||||||
|
expect("error" in rejected).toBe(true);
|
||||||
|
if ("error" in rejected) {
|
||||||
|
expect(rejected.status).toBe(400);
|
||||||
|
expect(rejected.error).toContain("Neplatný přechod stavu");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regression: v_realizaci→dokoncena still completes the linked project and reports it", async () => {
|
||||||
|
const order = await mkOrder("v_realizaci");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "dokoncena" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toEqual([
|
||||||
|
{
|
||||||
|
id: project.id,
|
||||||
|
project_number: null,
|
||||||
|
from: "aktivni",
|
||||||
|
to: "dokonceny",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regression: prijata→stornovana still cancels the linked project", async () => {
|
||||||
|
const order = await mkOrder("prijata");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "stornovana" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toHaveLength(1);
|
||||||
|
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("zruseny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getProject valid_transitions", () => {
|
||||||
|
it("matches the machine for canonical statuses", async () => {
|
||||||
|
const active = await mkProject("aktivni");
|
||||||
|
expect((await getProject(active.id))?.valid_transitions).toEqual(
|
||||||
|
PROJECT_VALID_TRANSITIONS["aktivni"],
|
||||||
|
);
|
||||||
|
expect((await getProject(active.id))?.valid_transitions).toEqual([
|
||||||
|
"dokonceny",
|
||||||
|
"zruseny",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const done = await mkProject("dokonceny");
|
||||||
|
expect((await getProject(done.id))?.valid_transitions).toEqual(["aktivni"]);
|
||||||
|
|
||||||
|
const cancelled = await mkProject("zruseny");
|
||||||
|
expect((await getProject(cancelled.id))?.valid_transitions).toEqual([
|
||||||
|
"aktivni",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all canonical targets for a legacy/unknown status", async () => {
|
||||||
|
const legacy = await mkProject("stary");
|
||||||
|
expect((await getProject(legacy.id))?.valid_transitions).toEqual([
|
||||||
|
"aktivni",
|
||||||
|
"dokonceny",
|
||||||
|
"zruseny",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/__tests__/projects-update-fk.test.ts
Normal file
76
src/__tests__/projects-update-fk.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { updateProject } from "../services/projects.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M6: updateProject must pre-validate FK ids
|
||||||
|
* (customer_id, responsible_user_id, quotation_id, order_id) and return clean
|
||||||
|
* Czech 400s like createProject does — a dangling id otherwise raises P2003
|
||||||
|
* at Prisma and surfaces as a generic 500.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "prj_fk_";
|
||||||
|
|
||||||
|
let projectId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project`, status: "active" },
|
||||||
|
});
|
||||||
|
projectId = project.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isError(r: unknown): r is { error: string; status: number } {
|
||||||
|
return typeof r === "object" && r !== null && "error" in r;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("updateProject FK existence checks (audit M6)", () => {
|
||||||
|
it.each([
|
||||||
|
["customer_id", "Zákazník nenalezen"],
|
||||||
|
["responsible_user_id", "Zodpovědná osoba nenalezena"],
|
||||||
|
["quotation_id", "Nabídka nenalezena"],
|
||||||
|
["order_id", "Zakázka nenalezena"],
|
||||||
|
])("returns a Czech 400 for a dangling %s", async (field, message) => {
|
||||||
|
const result = await updateProject(projectId, { [field]: 99999999 });
|
||||||
|
|
||||||
|
expect(isError(result)).toBe(true);
|
||||||
|
if (isError(result)) {
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.error).toBe(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was written.
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
});
|
||||||
|
expect(fresh?.[field as "customer_id"]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows clearing an FK with null", async () => {
|
||||||
|
const result = await updateProject(projectId, { customer_id: null });
|
||||||
|
expect(isError(result)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows setting a valid FK", async () => {
|
||||||
|
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||||
|
expect(user).not.toBeNull();
|
||||||
|
const result = await updateProject(projectId, {
|
||||||
|
responsible_user_id: user!.id,
|
||||||
|
});
|
||||||
|
expect(isError(result)).toBe(false);
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
});
|
||||||
|
expect(fresh?.responsible_user_id).toBe(user!.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/__tests__/received-invoice-dates.test.ts
Normal file
115
src/__tests__/received-invoice-dates.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
|
||||||
|
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding H5: Czech-format dates in the received-
|
||||||
|
* invoice upload metadata must be rejected with a clean Czech 400 BEFORE any
|
||||||
|
* NAS side effect — "15.03.2026" used to parse to Invalid Date (NaN
|
||||||
|
* month/year written after the NAS save → 500 + orphaned file) and
|
||||||
|
* "12.6.2026" silently filed under December (US month-first parsing).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(receivedInvoicesRoutes, {
|
||||||
|
prefix: "/api/admin/received-invoices",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function multipartUpload(meta: Record<string, unknown>) {
|
||||||
|
const boundary = "----vitestboundary";
|
||||||
|
const payload = [
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="invoices"',
|
||||||
|
"",
|
||||||
|
JSON.stringify([meta]),
|
||||||
|
`--${boundary}`,
|
||||||
|
'Content-Disposition: form-data; name="files"; filename="test.pdf"',
|
||||||
|
"Content-Type: application/pdf",
|
||||||
|
"",
|
||||||
|
"%PDF-1.4 test",
|
||||||
|
`--${boundary}--`,
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/received-invoices",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
},
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("received-invoice date handling (audit H5)", () => {
|
||||||
|
const partialSchema = z.array(CreateReceivedInvoiceSchema.partial());
|
||||||
|
|
||||||
|
it("schema rejects Czech-format dates", () => {
|
||||||
|
expect(
|
||||||
|
partialSchema.safeParse([{ issue_date: "15.03.2026" }]).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(partialSchema.safeParse([{ issue_date: "12.6.2026" }]).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schema strips a trailing time part and normalizes '' to null", () => {
|
||||||
|
const withTime = partialSchema.safeParse([
|
||||||
|
{ issue_date: "2098-05-03T00:00:00" },
|
||||||
|
]);
|
||||||
|
expect(withTime.success).toBe(true);
|
||||||
|
if (withTime.success)
|
||||||
|
expect(withTime.data[0].issue_date).toBe("2098-05-03");
|
||||||
|
|
||||||
|
const empty = partialSchema.safeParse([{ issue_date: "" }]);
|
||||||
|
expect(empty.success).toBe(true);
|
||||||
|
if (empty.success) expect(empty.data[0].issue_date).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload with a Czech-format issue_date returns 400 before any NAS write", async () => {
|
||||||
|
const res = await multipartUpload({
|
||||||
|
supplier_name: "Test s.r.o.",
|
||||||
|
amount: 121,
|
||||||
|
vat_rate: 21,
|
||||||
|
issue_date: "15.03.2026",
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("Datum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload with an impossible regex-passing date returns 400, not NaN month", async () => {
|
||||||
|
const res = await multipartUpload({
|
||||||
|
supplier_name: "Test s.r.o.",
|
||||||
|
amount: 121,
|
||||||
|
vat_rate: 21,
|
||||||
|
issue_date: "2098-99-99",
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("datum");
|
||||||
|
});
|
||||||
|
});
|
||||||
394
src/__tests__/schema-caps.test.ts
Normal file
394
src/__tests__/schema-caps.test.ts
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
CreateOrderSchema,
|
||||||
|
UpdateOrderSchema,
|
||||||
|
CreateOrderFromQuotationSchema,
|
||||||
|
} from "../schemas/orders.schema";
|
||||||
|
import {
|
||||||
|
CreateInvoiceSchema,
|
||||||
|
UpdateInvoiceSchema,
|
||||||
|
} from "../schemas/invoices.schema";
|
||||||
|
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||||
|
import { TotpEnableSchema } from "../schemas/auth.schema";
|
||||||
|
import {
|
||||||
|
CreateReceivedInvoiceSchema,
|
||||||
|
UpdateReceivedInvoiceSchema,
|
||||||
|
} from "../schemas/received-invoices.schema";
|
||||||
|
import {
|
||||||
|
CreateSupplierSchema,
|
||||||
|
UpdateSupplierSchema,
|
||||||
|
} from "../schemas/warehouse.schema";
|
||||||
|
import {
|
||||||
|
CreateCustomerSchema,
|
||||||
|
UpdateCustomerSchema,
|
||||||
|
} from "../schemas/customers.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for the Zod-cap-vs-DB-column class (audit H2, M2, M4, M11,
|
||||||
|
* L1, L8d, L8e + adjacent invoice item/bank caps): every form-facing string
|
||||||
|
* cap must fit its DB column width, or an over-width value passes Zod and
|
||||||
|
* dies at Prisma as a 500 (P2000) instead of a clean Czech 400.
|
||||||
|
*
|
||||||
|
* Each case asserts BOTH directions: width+1 chars must FAIL the schema, and
|
||||||
|
* exactly width chars must PASS (guards against over-tightening — stored
|
||||||
|
* values can never exceed the column width, so re-submitted edit forms stay
|
||||||
|
* valid).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CapCase {
|
||||||
|
name: string;
|
||||||
|
schema: z.ZodTypeAny;
|
||||||
|
/** DB column width from prisma/schema.prisma */
|
||||||
|
width: number;
|
||||||
|
build: (value: string) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripBase = {
|
||||||
|
vehicle_id: 1,
|
||||||
|
trip_date: "2098-03-02",
|
||||||
|
start_km: 0,
|
||||||
|
end_km: 1,
|
||||||
|
route_from: "A",
|
||||||
|
route_to: "B",
|
||||||
|
};
|
||||||
|
const receivedBase = { supplier_name: "X", month: 6, year: 2026 };
|
||||||
|
|
||||||
|
const CASES: CapCase[] = [
|
||||||
|
// ── orders.schema.ts vs order_items / orders ──
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema items.description",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ items: [{ description: v }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema items.unit",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ items: [{ unit: v }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema order_number",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ order_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema customer_order_number",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ customer_order_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema currency",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 10,
|
||||||
|
build: (v) => ({ currency: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderSchema language",
|
||||||
|
schema: CreateOrderSchema,
|
||||||
|
width: 5,
|
||||||
|
build: (v) => ({ language: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateOrderSchema customer_order_number",
|
||||||
|
schema: UpdateOrderSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ customer_order_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateOrderSchema currency",
|
||||||
|
schema: UpdateOrderSchema,
|
||||||
|
width: 10,
|
||||||
|
build: (v) => ({ currency: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateOrderSchema language",
|
||||||
|
schema: UpdateOrderSchema,
|
||||||
|
width: 5,
|
||||||
|
build: (v) => ({ language: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateOrderFromQuotationSchema customerOrderNumber",
|
||||||
|
schema: CreateOrderFromQuotationSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ quotationId: 1, customerOrderNumber: v }),
|
||||||
|
},
|
||||||
|
// ── invoices.schema.ts vs invoice_items / invoices ──
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema items.description",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ items: [{ description: v }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema items.unit",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ items: [{ unit: v }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema invoice_number",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ invoice_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema currency",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 10,
|
||||||
|
build: (v) => ({ currency: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema language",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 5,
|
||||||
|
build: (v) => ({ language: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema payment_method",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ payment_method: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema constant_symbol",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ constant_symbol: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema bank_swift",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ bank_swift: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema bank_iban",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ bank_iban: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema bank_account",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ bank_account: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateInvoiceSchema billing_text",
|
||||||
|
schema: CreateInvoiceSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ billing_text: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema currency",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 10,
|
||||||
|
build: (v) => ({ currency: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema language",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 5,
|
||||||
|
build: (v) => ({ language: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema payment_method",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ payment_method: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema constant_symbol",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ constant_symbol: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema bank_swift",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ bank_swift: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema bank_iban",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ bank_iban: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema bank_account",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ bank_account: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateInvoiceSchema billing_text",
|
||||||
|
schema: UpdateInvoiceSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ billing_text: v }),
|
||||||
|
},
|
||||||
|
// ── trips.schema.ts vs trips ──
|
||||||
|
{
|
||||||
|
name: "CreateTripSchema route_from",
|
||||||
|
schema: CreateTripSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ ...tripBase, route_from: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateTripSchema route_to",
|
||||||
|
schema: CreateTripSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ ...tripBase, route_to: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateTripSchema route_from",
|
||||||
|
schema: UpdateTripSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ route_from: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateTripSchema route_to",
|
||||||
|
schema: UpdateTripSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ route_to: v }),
|
||||||
|
},
|
||||||
|
// ── auth.schema.ts vs users.totp_secret ──
|
||||||
|
// The stored value is AES-256-GCM ciphertext (base64(IV+ct+tag)) of the
|
||||||
|
// plaintext secret in a VarChar(255) column: a plaintext over ~164 chars
|
||||||
|
// overflows after encryption. Real TOTP secrets are 16-32 chars; cap 64.
|
||||||
|
{
|
||||||
|
name: "TotpEnableSchema secret",
|
||||||
|
schema: TotpEnableSchema,
|
||||||
|
width: 64,
|
||||||
|
build: (v) => ({ secret: v, code: "123456" }),
|
||||||
|
},
|
||||||
|
// ── received-invoices.schema.ts vs received_invoices ──
|
||||||
|
{
|
||||||
|
name: "CreateReceivedInvoiceSchema description",
|
||||||
|
schema: CreateReceivedInvoiceSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ ...receivedBase, description: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateReceivedInvoiceSchema invoice_number",
|
||||||
|
schema: CreateReceivedInvoiceSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ ...receivedBase, invoice_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateReceivedInvoiceSchema currency",
|
||||||
|
schema: CreateReceivedInvoiceSchema,
|
||||||
|
width: 3,
|
||||||
|
build: (v) => ({ ...receivedBase, currency: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateReceivedInvoiceSchema description",
|
||||||
|
schema: UpdateReceivedInvoiceSchema,
|
||||||
|
width: 500,
|
||||||
|
build: (v) => ({ description: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateReceivedInvoiceSchema invoice_number",
|
||||||
|
schema: UpdateReceivedInvoiceSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ invoice_number: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateReceivedInvoiceSchema currency",
|
||||||
|
schema: UpdateReceivedInvoiceSchema,
|
||||||
|
width: 3,
|
||||||
|
build: (v) => ({ currency: v }),
|
||||||
|
},
|
||||||
|
// ── warehouse.schema.ts (suppliers) vs sklad_suppliers ──
|
||||||
|
{
|
||||||
|
name: "CreateSupplierSchema ico",
|
||||||
|
schema: CreateSupplierSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ name: "X", ico: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateSupplierSchema dic",
|
||||||
|
schema: CreateSupplierSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ name: "X", dic: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateSupplierSchema ico",
|
||||||
|
schema: UpdateSupplierSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ ico: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateSupplierSchema dic",
|
||||||
|
schema: UpdateSupplierSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ dic: v }),
|
||||||
|
},
|
||||||
|
// ── customers.schema.ts vs customers ──
|
||||||
|
{
|
||||||
|
name: "CreateCustomerSchema company_id",
|
||||||
|
schema: CreateCustomerSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ name: "X", company_id: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateCustomerSchema vat_id",
|
||||||
|
schema: CreateCustomerSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ name: "X", vat_id: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateCustomerSchema postal_code",
|
||||||
|
schema: CreateCustomerSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ name: "X", postal_code: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CreateCustomerSchema country",
|
||||||
|
schema: CreateCustomerSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ name: "X", country: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateCustomerSchema company_id",
|
||||||
|
schema: UpdateCustomerSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ company_id: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateCustomerSchema vat_id",
|
||||||
|
schema: UpdateCustomerSchema,
|
||||||
|
width: 50,
|
||||||
|
build: (v) => ({ vat_id: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateCustomerSchema postal_code",
|
||||||
|
schema: UpdateCustomerSchema,
|
||||||
|
width: 20,
|
||||||
|
build: (v) => ({ postal_code: v }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "UpdateCustomerSchema country",
|
||||||
|
schema: UpdateCustomerSchema,
|
||||||
|
width: 100,
|
||||||
|
build: (v) => ({ country: v }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Zod caps fit DB column widths (audit H2/M2/M4/M11/L1/L8d/L8e + adjacent)", () => {
|
||||||
|
for (const c of CASES) {
|
||||||
|
it(`${c.name} rejects ${c.width + 1} chars`, () => {
|
||||||
|
const result = c.schema.safeParse(c.build("x".repeat(c.width + 1)));
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${c.name} accepts ${c.width} chars`, () => {
|
||||||
|
const result = c.schema.safeParse(c.build("x".repeat(c.width)));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -85,11 +85,13 @@ describe("Zod form coercion + NaN rejection", () => {
|
|||||||
|
|
||||||
describe("CreateTripSchema", () => {
|
describe("CreateTripSchema", () => {
|
||||||
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
|
||||||
|
// km fields are Int columns — integer strings coerce, decimals are
|
||||||
|
// rejected (see trips-vehicles-km-int.test.ts for the rejection pins).
|
||||||
const result = CreateTripSchema.safeParse({
|
const result = CreateTripSchema.safeParse({
|
||||||
vehicle_id: "5",
|
vehicle_id: "5",
|
||||||
trip_date: "2026-01-15",
|
trip_date: "2026-01-15",
|
||||||
start_km: "100",
|
start_km: "100",
|
||||||
end_km: "150.5",
|
end_km: "150",
|
||||||
route_from: "Praha",
|
route_from: "Praha",
|
||||||
route_to: "Brno",
|
route_to: "Brno",
|
||||||
});
|
});
|
||||||
@@ -97,7 +99,7 @@ describe("Zod form coercion + NaN rejection", () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data.vehicle_id).toBe(5);
|
expect(result.data.vehicle_id).toBe(5);
|
||||||
expect(result.data.start_km).toBe(100);
|
expect(result.data.start_km).toBe(100);
|
||||||
expect(result.data.end_km).toBe(150.5);
|
expect(result.data.end_km).toBe(150);
|
||||||
expect(typeof result.data.start_km).toBe("number");
|
expect(typeof result.data.start_km).toBe("number");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
129
src/__tests__/selected-custom-fields.test.ts
Normal file
129
src/__tests__/selected-custom-fields.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
import { afterAll } from "vitest";
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf";
|
||||||
|
import type { OfferForPdf } from "../routes/admin/offers-pdf";
|
||||||
|
import type { company_settings } from "@prisma/client";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offer PDF prints only selected company custom fields", () => {
|
||||||
|
// Two company custom fields; the document selects only index 0.
|
||||||
|
const settings = {
|
||||||
|
company_name: "Test s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Email", value: "selected@example.com", showLabel: false },
|
||||||
|
{ name: "Web", value: "notselected.example.com", showLabel: false },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
logo_data: null,
|
||||||
|
} as unknown as company_settings;
|
||||||
|
|
||||||
|
const baseQuotation = {
|
||||||
|
quotation_number: "TEST-2098-001",
|
||||||
|
language: "EN",
|
||||||
|
currency: "EUR",
|
||||||
|
valid_until: new Date("2098-01-01"),
|
||||||
|
project_code: null,
|
||||||
|
created_at: new Date("2098-01-01"),
|
||||||
|
customers: { name: "Cust" },
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the selected custom field and omits the non-selected one", () => {
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints NO company custom fields when the document selects none", () => {
|
||||||
|
// The company call site always passes a parsed array; a null column
|
||||||
|
// decodes to [] → empty selection → no custom fields on the document.
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: null,
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/__tests__/session-terminate-refresh.test.ts
Normal file
138
src/__tests__/session-terminate-refresh.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { refreshAccessToken, hashToken } from "../services/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding H1: a session terminated via the sessions
|
||||||
|
* routes (replaced_at set, NO replaced_by_hash) presented on a later refresh
|
||||||
|
* is NOT token theft — it must be rejected with a plain 401 WITHOUT revoking
|
||||||
|
* the user's other sessions. Real theft (replay of a ROTATED token, i.e.
|
||||||
|
* replaced_by_hash set) must keep revoking the whole family.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "sess_term_";
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
log: { warn: () => {} },
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
headers: {},
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
const rawA = `${N}rawA_${stamp}`;
|
||||||
|
const rawB = `${N}rawB_${stamp}`;
|
||||||
|
const rawC = `${N}rawC_${stamp}`;
|
||||||
|
const rawD = `${N}rawD_${stamp}`;
|
||||||
|
|
||||||
|
let tokenAId: number;
|
||||||
|
let tokenDId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await prisma.users.findMany({
|
||||||
|
where: { username: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ids = users.map((u) => u.id);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
|
||||||
|
await prisma.users.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const role = await prisma.roles.findFirst();
|
||||||
|
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user_${stamp}`,
|
||||||
|
email: `${N}${stamp}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
first_name: "Session",
|
||||||
|
last_name: "Terminate",
|
||||||
|
is_active: true,
|
||||||
|
role_id: role.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
const future = new Date(Date.now() + 7 * 24 * 3600 * 1000);
|
||||||
|
const tokenA = await prisma.refresh_tokens.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
token_hash: hashToken(rawA),
|
||||||
|
expires_at: future,
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tokenAId = tokenA.id;
|
||||||
|
// Token B: terminated the way DELETE /sessions/:id does it — replaced_at
|
||||||
|
// ONLY, no replaced_by_hash, row kept.
|
||||||
|
await prisma.refresh_tokens.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
token_hash: hashToken(rawB),
|
||||||
|
expires_at: future,
|
||||||
|
remember_me: false,
|
||||||
|
replaced_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Token C: legitimately ROTATED (both replaced_at and replaced_by_hash set)
|
||||||
|
// — replaying it is the theft signature.
|
||||||
|
await prisma.refresh_tokens.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
token_hash: hashToken(rawC),
|
||||||
|
expires_at: future,
|
||||||
|
remember_me: false,
|
||||||
|
replaced_at: new Date(),
|
||||||
|
replaced_by_hash: hashToken(`${N}descendant_${stamp}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tokenD = await prisma.refresh_tokens.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
token_hash: hashToken(rawD),
|
||||||
|
expires_at: future,
|
||||||
|
remember_me: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tokenDId = tokenD.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshAccessToken vs terminated sessions (audit H1)", () => {
|
||||||
|
it("rejects a manually terminated token with 401 WITHOUT revoking other sessions", async () => {
|
||||||
|
const result = await refreshAccessToken(rawB, fakeReq);
|
||||||
|
|
||||||
|
expect(result.type).toBe("error");
|
||||||
|
if (result.type === "error") expect(result.status).toBe(401);
|
||||||
|
|
||||||
|
// The user's OTHER session must survive — termination is not theft.
|
||||||
|
const tokenA = await prisma.refresh_tokens.findUnique({
|
||||||
|
where: { id: tokenAId },
|
||||||
|
});
|
||||||
|
expect(tokenA).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still revokes the whole family on replay of a ROTATED token (theft)", async () => {
|
||||||
|
const result = await refreshAccessToken(rawC, fakeReq);
|
||||||
|
|
||||||
|
expect(result.type).toBe("error");
|
||||||
|
|
||||||
|
// Breach containment must keep working: every session of the user is
|
||||||
|
// gone, including the otherwise-valid token D.
|
||||||
|
const tokenD = await prisma.refresh_tokens.findUnique({
|
||||||
|
where: { id: tokenDId },
|
||||||
|
});
|
||||||
|
expect(tokenD).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/__tests__/trips-vehicles-km-int.test.ts
Normal file
63
src/__tests__/trips-vehicles-km-int.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
|
||||||
|
import {
|
||||||
|
CreateVehicleSchema,
|
||||||
|
UpdateVehicleSchema,
|
||||||
|
} from "../schemas/vehicles.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding L7: odometer fields are Int columns —
|
||||||
|
* a decimal reading must 400 at Zod instead of 500ing at Prisma (or
|
||||||
|
* silently truncating, corrupting the derived distance and
|
||||||
|
* vehicles.actual_km).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tripBase = {
|
||||||
|
vehicle_id: 1,
|
||||||
|
trip_date: "2098-01-15",
|
||||||
|
start_km: 100,
|
||||||
|
end_km: 200,
|
||||||
|
route_from: "A",
|
||||||
|
route_to: "B",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("km fields are integers (audit L7)", () => {
|
||||||
|
it.each([["start_km"], ["end_km"]])(
|
||||||
|
"CreateTripSchema rejects a decimal %s",
|
||||||
|
(field) => {
|
||||||
|
expect(
|
||||||
|
CreateTripSchema.safeParse({ ...tripBase, [field]: 100.5 }).success,
|
||||||
|
).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("CreateTripSchema still accepts integer readings", () => {
|
||||||
|
expect(CreateTripSchema.safeParse(tripBase).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UpdateTripSchema rejects a decimal start_km", () => {
|
||||||
|
expect(UpdateTripSchema.safeParse({ start_km: 100.5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([["initial_km"], ["actual_km"]])(
|
||||||
|
"CreateVehicleSchema rejects a decimal %s",
|
||||||
|
(field) => {
|
||||||
|
expect(
|
||||||
|
CreateVehicleSchema.safeParse({
|
||||||
|
spz: "1AB 1234",
|
||||||
|
name: "Test",
|
||||||
|
[field]: 12345.5,
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("UpdateVehicleSchema rejects a decimal actual_km, accepts an integer", () => {
|
||||||
|
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345.5 }).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345 }).success).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips";
|
|||||||
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
||||||
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
||||||
// non-managers to their own trips exactly like the list does.
|
// non-managers to their own trips exactly like the list does.
|
||||||
|
// 3. POST /trips rejects a non-manager filing a trip under someone else's
|
||||||
|
// user_id with an explicit Czech 403 (impersonation gap), while
|
||||||
|
// self-creates and manager on-behalf creates keep working.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
||||||
@@ -66,6 +69,18 @@ async function authGet(path: string, token: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function authPost(path: string, token: string, body: unknown) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: path,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: body as object,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function authPut(path: string, token: string, body: unknown) {
|
async function authPut(path: string, token: string, body: unknown) {
|
||||||
return app.inject({
|
return app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => {
|
|||||||
expect(filtered.json().data.count).toBe(2);
|
expect(filtered.json().data.count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/trips — on-behalf authorization", () => {
|
||||||
|
function postBody(params: { userId?: number; note: string }) {
|
||||||
|
return {
|
||||||
|
vehicle_id: vehicleAId,
|
||||||
|
...(params.userId !== undefined ? { user_id: params.userId } : {}),
|
||||||
|
trip_date: "2098-11-10",
|
||||||
|
start_km: 100,
|
||||||
|
end_km: 150,
|
||||||
|
route_from: "Praha",
|
||||||
|
route_to: "Brno",
|
||||||
|
is_business: true,
|
||||||
|
notes: `${N}${params.note}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects a non-manager filing a trip under another user's id with 403", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ userId: adminUserId, note: "authz_spoof" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: "Nemáte oprávnění zadat jízdu za jiného uživatele",
|
||||||
|
});
|
||||||
|
|
||||||
|
// …and the trip must NOT have been created.
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_spoof` },
|
||||||
|
});
|
||||||
|
expect(created).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a non-manager create a trip without user_id (defaults to self)", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ note: "authz_self_implicit" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().success).toBe(true);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_self_implicit` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a non-manager create a trip with their OWN user_id", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ userId: scopeUserId, note: "authz_self_explicit" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_self_explicit` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a manager/admin create a trip on behalf of another user", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
adminToken,
|
||||||
|
postBody({ userId: scopeUserId, note: "authz_on_behalf" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_on_behalf` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
165
src/__tests__/users-create-role-guard.test.ts
Normal file
165
src/__tests__/users-create-role-guard.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import usersRoutes from "../routes/admin/users";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M10: POST /users must strip role_id for
|
||||||
|
* non-admin callers (mirroring the PUT privilege-escalation guard) — a bare
|
||||||
|
* users.create holder must not be able to mint an account on a privileged
|
||||||
|
* role the caller itself lacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "usr_roleguard_";
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let creatorToken: string;
|
||||||
|
let creatorRoleId: number;
|
||||||
|
let privilegedRoleId: number;
|
||||||
|
|
||||||
|
function token(user: { id: number; username: string; roleName: string }) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: user.roleName },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||||
|
const roles = await prisma.roles.findMany({
|
||||||
|
where: { name: { startsWith: N } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const roleIds = roles.map((r) => r.id);
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
await prisma.role_permissions.deleteMany({
|
||||||
|
where: { role_id: { in: roleIds } },
|
||||||
|
});
|
||||||
|
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
app.addHook("onRequest", securityHeaders);
|
||||||
|
await app.register(usersRoutes, { prefix: "/api/admin/users" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
include: { roles: true },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = token({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Caller role: ONLY users.create.
|
||||||
|
const creatorRole = await prisma.roles.create({
|
||||||
|
data: { name: `${N}creator_${stamp}`, display_name: "Creator Only" },
|
||||||
|
});
|
||||||
|
creatorRoleId = creatorRole.id;
|
||||||
|
const perm = await prisma.permissions.findFirst({
|
||||||
|
where: { name: "users.create" },
|
||||||
|
});
|
||||||
|
if (!perm) throw new Error("Test setup: users.create permission missing");
|
||||||
|
await prisma.role_permissions.create({
|
||||||
|
data: { role_id: creatorRoleId, permission_id: perm.id },
|
||||||
|
});
|
||||||
|
const creator = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}creator_${stamp}`,
|
||||||
|
email: `${N}creator_${stamp}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
first_name: "Creator",
|
||||||
|
last_name: "Only",
|
||||||
|
is_active: true,
|
||||||
|
role_id: creatorRoleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
creatorToken = token({
|
||||||
|
id: creator.id,
|
||||||
|
username: creator.username,
|
||||||
|
roleName: creatorRole.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Target role the caller does NOT hold — its content is irrelevant, the
|
||||||
|
// guard must strip ANY role assignment from a non-admin create.
|
||||||
|
const privilegedRole = await prisma.roles.create({
|
||||||
|
data: { name: `${N}privileged_${stamp}`, display_name: "Privileged" },
|
||||||
|
});
|
||||||
|
privilegedRoleId = privilegedRole.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /users role escalation guard (audit M10)", () => {
|
||||||
|
it("strips role_id when the caller is not admin", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/users",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${creatorToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
username: `${N}victim_${stamp}`,
|
||||||
|
email: `${N}victim_${stamp}@test.local`,
|
||||||
|
password: "Heslo12345",
|
||||||
|
first_name: "Victim",
|
||||||
|
last_name: "User",
|
||||||
|
role_id: privilegedRoleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.users.findFirst({
|
||||||
|
where: { username: `${N}victim_${stamp}` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created?.role_id).not.toBe(privilegedRoleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps role assignment for an admin caller", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/users",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
username: `${N}byadmin_${stamp}`,
|
||||||
|
email: `${N}byadmin_${stamp}@test.local`,
|
||||||
|
password: "Heslo12345",
|
||||||
|
first_name: "ByAdmin",
|
||||||
|
last_name: "User",
|
||||||
|
role_id: privilegedRoleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.users.findFirst({
|
||||||
|
where: { username: `${N}byadmin_${stamp}` },
|
||||||
|
});
|
||||||
|
expect(created?.role_id).toBe(privilegedRoleId);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/__tests__/version-header.test.ts
Normal file
24
src/__tests__/version-header.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { registerVersionHeader, readAppVersion } from "../middleware/version";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server stamps every response with X-App-Version so the SPA can detect a
|
||||||
|
* deploy (the client compares it to the version baked into its bundle).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("registerVersionHeader", () => {
|
||||||
|
it("adds X-App-Version to responses", async () => {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
registerVersionHeader(app, "9.9.9");
|
||||||
|
app.get("/ping", async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const res = await app.inject({ method: "GET", url: "/ping" });
|
||||||
|
expect(res.headers["x-app-version"]).toBe("9.9.9");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the package.json version (semver string)", () => {
|
||||||
|
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
src/__tests__/warehouse-date-filter.test.ts
Normal file
92
src/__tests__/warehouse-date-filter.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import warehouseRoutes from "../routes/admin/warehouse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M7: date_from/date_to filters on warehouse
|
||||||
|
* lists must cover the FULL local calendar days. created_at holds local-time
|
||||||
|
* instants; the old bounds (gte/lte `new Date("YYYY-MM-DD")` = UTC midnight)
|
||||||
|
* excluded the whole inclusive end day and the first 1-2 morning hours of
|
||||||
|
* the start day.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "wh_datefilter_";
|
||||||
|
const DAY = "2098-04-08";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let earlyId: number; // 00:30 local on DAY — start-day morning window
|
||||||
|
let middayId: number; // 09:00 local on DAY
|
||||||
|
let lateId: number; // 23:30 local on DAY — end-day coverage
|
||||||
|
let nextDayId: number; // 00:30 local the day AFTER — must stay excluded
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
app.addHook("onRequest", securityHeaders);
|
||||||
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local-time instants on the filtered day (year 2098 keeps real data out).
|
||||||
|
const early = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}early`, created_at: new Date(2098, 3, 8, 0, 30, 0) },
|
||||||
|
});
|
||||||
|
earlyId = early.id;
|
||||||
|
const midday = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}midday`, created_at: new Date(2098, 3, 8, 9, 0, 0) },
|
||||||
|
});
|
||||||
|
middayId = midday.id;
|
||||||
|
const late = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}late`, created_at: new Date(2098, 3, 8, 23, 30, 0) },
|
||||||
|
});
|
||||||
|
lateId = late.id;
|
||||||
|
const nextDay = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}nextday`, created_at: new Date(2098, 3, 9, 0, 30, 0) },
|
||||||
|
});
|
||||||
|
nextDayId = nextDay.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warehouse receipts date filter (audit M7)", () => {
|
||||||
|
it("a single-day range returns every receipt of that local day and nothing else", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/warehouse/receipts?date_from=${DAY}&date_to=${DAY}&limit=100`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||||
|
|
||||||
|
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
|
||||||
|
expect(ids).toContain(middayId); // 09:00 — dropped by the old lte to-bound
|
||||||
|
expect(ids).toContain(lateId); // 23:30 — dropped by the old lte to-bound
|
||||||
|
expect(ids).not.toContain(nextDayId); // next day stays out
|
||||||
|
});
|
||||||
|
});
|
||||||
206
src/__tests__/warehouse-fk-400.test.ts
Normal file
206
src/__tests__/warehouse-fk-400.test.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import warehouseRoutes from "../routes/admin/warehouse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding M8: warehouse receipts/issues create+update
|
||||||
|
* must pre-validate FK existence (supplier, item, location, project, explicit
|
||||||
|
* batch) and return Czech 400s — a dangling id otherwise raises P2003 at
|
||||||
|
* Prisma and surfaces as a generic 500. The document modules and trips.ts
|
||||||
|
* pre-validate this way; warehouse omitted the guard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "wh_fk400_";
|
||||||
|
const MISSING_ID = 99999999;
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
let projectId: number;
|
||||||
|
let itemId: number;
|
||||||
|
let draftReceiptId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.sklad_issue_lines.deleteMany({
|
||||||
|
where: { issue: { notes: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
|
||||||
|
await prisma.sklad_batches.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.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
app.addHook("onRequest", securityHeaders);
|
||||||
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project`, status: "active" },
|
||||||
|
});
|
||||||
|
projectId = project.id;
|
||||||
|
|
||||||
|
const item = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}item`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemId = item.id;
|
||||||
|
|
||||||
|
// In-stock batch so issue tests get past availability checks.
|
||||||
|
const receipt = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}stock`, status: "CONFIRMED" },
|
||||||
|
});
|
||||||
|
const line = await prisma.sklad_receipt_lines.create({
|
||||||
|
data: {
|
||||||
|
receipt_id: receipt.id,
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 10,
|
||||||
|
unit_price: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.sklad_batches.create({
|
||||||
|
data: {
|
||||||
|
item_id: itemId,
|
||||||
|
receipt_line_id: line.id,
|
||||||
|
quantity: 10,
|
||||||
|
original_qty: 10,
|
||||||
|
unit_price: 5,
|
||||||
|
received_at: new Date(2026, 0, 1, 12, 0, 0),
|
||||||
|
is_consumed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// DRAFT receipt for the PUT test.
|
||||||
|
const draft = await prisma.sklad_receipts.create({
|
||||||
|
data: {
|
||||||
|
notes: `${N}draft`,
|
||||||
|
status: "DRAFT",
|
||||||
|
items: { create: [{ item_id: itemId, quantity: 1, unit_price: 5 }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
draftReceiptId = draft.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function post(url: string, payload: unknown) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: payload as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("warehouse FK pre-validation (audit M8)", () => {
|
||||||
|
it("POST /receipts with a nonexistent item_id returns 400, not 500", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/receipts", {
|
||||||
|
notes: `${N}r1`,
|
||||||
|
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /receipts with a nonexistent supplier_id returns 400, not 500", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/receipts", {
|
||||||
|
notes: `${N}r2`,
|
||||||
|
supplier_id: MISSING_ID,
|
||||||
|
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /receipts with a nonexistent location_id returns 400, not 500", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/receipts", {
|
||||||
|
notes: `${N}r3`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 10,
|
||||||
|
location_id: MISSING_ID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /receipts/:id with a nonexistent item_id returns 400, not 500", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/warehouse/receipts/${draftReceiptId}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
notes: `${N}draft`,
|
||||||
|
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /issues with a nonexistent project_id returns 400, not 500", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/issues", {
|
||||||
|
notes: `${N}i1`,
|
||||||
|
project_id: MISSING_ID,
|
||||||
|
items: [{ item_id: itemId, quantity: 1 }],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /issues with a nonexistent explicit batch_id returns 400, not 500", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/issues", {
|
||||||
|
notes: `${N}i2`,
|
||||||
|
project_id: projectId,
|
||||||
|
items: [{ item_id: itemId, quantity: 1, batch_id: MISSING_ID }],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toContain("nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("valid receipt create still succeeds", async () => {
|
||||||
|
const res = await post("/api/admin/warehouse/receipts", {
|
||||||
|
notes: `${N}ok`,
|
||||||
|
items: [{ item_id: itemId, quantity: 2, unit_price: 10 }],
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
src/__tests__/warehouse-inventory-confirm.test.ts
Normal file
124
src/__tests__/warehouse-inventory-confirm.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { confirmInventorySession } from "../services/warehouse.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding C3: confirmInventorySession must be atomic.
|
||||||
|
* A session whose item list contains a valid surplus line BEFORE a deficit
|
||||||
|
* line that cannot be satisfied (no batches) must fail WITHOUT committing the
|
||||||
|
* surplus item's corrective receipt/batch — and retries must not accumulate
|
||||||
|
* phantom stock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "wh_invconf_";
|
||||||
|
|
||||||
|
let itemAId: number; // surplus line (processed first — lower line id)
|
||||||
|
let itemBId: number; // deficit line with no batches -> selectFifoBatches throws
|
||||||
|
let sessionId: number;
|
||||||
|
|
||||||
|
/** Corrective receipts carry generated notes without our prefix — collect
|
||||||
|
* them via their lines' items before deleting. */
|
||||||
|
async function cleanup() {
|
||||||
|
const lines = await prisma.sklad_receipt_lines.findMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
select: { receipt_id: true },
|
||||||
|
});
|
||||||
|
const receiptIds = [...new Set(lines.map((l) => l.receipt_id))];
|
||||||
|
await prisma.sklad_batches.deleteMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_receipt_lines.deleteMany({
|
||||||
|
where: { item: { name: { contains: N } } },
|
||||||
|
});
|
||||||
|
if (receiptIds.length > 0) {
|
||||||
|
await prisma.sklad_receipts.deleteMany({
|
||||||
|
where: { id: { in: receiptIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.sklad_inventory_lines.deleteMany({
|
||||||
|
where: { session: { notes: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_inventory_sessions.deleteMany({
|
||||||
|
where: { notes: { contains: N } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
const itemA = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}surplus_item`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemAId = itemA.id;
|
||||||
|
const itemB = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}deficit_item`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemBId = itemB.id;
|
||||||
|
|
||||||
|
// Surplus line created first => lower id => processed first by the confirm
|
||||||
|
// loop. Deficit line for an item with NO batches forces the FIFO throw.
|
||||||
|
const session = await prisma.sklad_inventory_sessions.create({
|
||||||
|
data: {
|
||||||
|
notes: `${N}session`,
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{ item_id: itemAId, system_qty: 0, actual_qty: 5, difference: 5 },
|
||||||
|
{ item_id: itemBId, system_qty: 3, actual_qty: 0, difference: -3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
sessionId = session.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirmInventorySession atomicity (audit C3)", () => {
|
||||||
|
it("rolls back the surplus corrective receipt when a later deficit line fails", async () => {
|
||||||
|
const result = await confirmInventorySession(sessionId);
|
||||||
|
|
||||||
|
// The confirm must fail — item B has nothing to consume.
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) {
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.error).toContain(`ID ${itemBId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and item A's surplus writes must NOT have been committed.
|
||||||
|
const receiptLines = await prisma.sklad_receipt_lines.findMany({
|
||||||
|
where: { item_id: itemAId },
|
||||||
|
});
|
||||||
|
expect(receiptLines).toHaveLength(0);
|
||||||
|
|
||||||
|
const batches = await prisma.sklad_batches.findMany({
|
||||||
|
where: { item_id: itemAId },
|
||||||
|
});
|
||||||
|
expect(batches).toHaveLength(0);
|
||||||
|
|
||||||
|
// The session itself stays DRAFT and unnumbered either way.
|
||||||
|
const session = await prisma.sklad_inventory_sessions.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
});
|
||||||
|
expect(session?.status).toBe("DRAFT");
|
||||||
|
expect(session?.session_number).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accumulate phantom stock when the failed confirm is retried", async () => {
|
||||||
|
await confirmInventorySession(sessionId);
|
||||||
|
await confirmInventorySession(sessionId);
|
||||||
|
|
||||||
|
const batches = await prisma.sklad_batches.findMany({
|
||||||
|
where: { item_id: itemAId },
|
||||||
|
});
|
||||||
|
expect(batches).toHaveLength(0);
|
||||||
|
|
||||||
|
const receipts = await prisma.sklad_receipts.findMany({
|
||||||
|
where: { notes: { contains: `inventarizace #${sessionId}` } },
|
||||||
|
});
|
||||||
|
expect(receipts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
196
src/__tests__/warehouse-issue-confirm.test.ts
Normal file
196
src/__tests__/warehouse-issue-confirm.test.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { confirmIssue } from "../services/warehouse.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for audit finding C2: confirmIssue must validate duplicate
|
||||||
|
* lines pointing at the SAME batch against their combined quantity. Two lines
|
||||||
|
* that each pass the per-line check individually must not decrement the batch
|
||||||
|
* cumulatively below zero (silent over-issue hidden from stock totals).
|
||||||
|
*
|
||||||
|
* The duplicate-lines draft is exactly what POST /issues produces when two
|
||||||
|
* same-item lines without batch_id FIFO-resolve to the same oldest batch
|
||||||
|
* (per-line resolution persists nothing between lines), and is equally
|
||||||
|
* reachable with explicit batch_ids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "wh_issueconf_";
|
||||||
|
|
||||||
|
let projectId: number;
|
||||||
|
let userId: number;
|
||||||
|
let itemId: number;
|
||||||
|
let batch1Id: number;
|
||||||
|
let overIssueId: number;
|
||||||
|
let exactIssueId: number;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.sklad_issue_lines.deleteMany({
|
||||||
|
where: { issue: { notes: { contains: N } } },
|
||||||
|
});
|
||||||
|
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
|
||||||
|
await prisma.sklad_batches.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.users
|
||||||
|
.deleteMany({ where: { username: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
await prisma.projects
|
||||||
|
.deleteMany({ where: { name: { startsWith: N } } })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
|
||||||
|
if (!adminRole)
|
||||||
|
throw new Error("Admin role not found — seed the database first");
|
||||||
|
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `${N}user`,
|
||||||
|
email: `${N}user@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "Issue",
|
||||||
|
last_name: "Confirm",
|
||||||
|
is_active: true,
|
||||||
|
role_id: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project`, status: "active" },
|
||||||
|
});
|
||||||
|
projectId = project.id;
|
||||||
|
|
||||||
|
const item = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}item`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemId = item.id;
|
||||||
|
|
||||||
|
// Two batches of 8 — total stock 16, so the per-item aggregate check
|
||||||
|
// passes a combined quantity of 10, but FIFO resolves both lines to the
|
||||||
|
// OLDER batch B1 which only holds 8.
|
||||||
|
const receipt = await prisma.sklad_receipts.create({
|
||||||
|
data: { notes: `${N}receipt`, status: "CONFIRMED" },
|
||||||
|
});
|
||||||
|
const line1 = await prisma.sklad_receipt_lines.create({
|
||||||
|
data: {
|
||||||
|
receipt_id: receipt.id,
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 8,
|
||||||
|
unit_price: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const line2 = await prisma.sklad_receipt_lines.create({
|
||||||
|
data: {
|
||||||
|
receipt_id: receipt.id,
|
||||||
|
item_id: itemId,
|
||||||
|
quantity: 8,
|
||||||
|
unit_price: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const batch1 = await prisma.sklad_batches.create({
|
||||||
|
data: {
|
||||||
|
item_id: itemId,
|
||||||
|
receipt_line_id: line1.id,
|
||||||
|
quantity: 8,
|
||||||
|
original_qty: 8,
|
||||||
|
unit_price: 10,
|
||||||
|
received_at: new Date(2026, 0, 1, 12, 0, 0),
|
||||||
|
is_consumed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
batch1Id = batch1.id;
|
||||||
|
await prisma.sklad_batches.create({
|
||||||
|
data: {
|
||||||
|
item_id: itemId,
|
||||||
|
receipt_line_id: line2.id,
|
||||||
|
quantity: 8,
|
||||||
|
original_qty: 8,
|
||||||
|
unit_price: 10,
|
||||||
|
received_at: new Date(2026, 0, 2, 12, 0, 0),
|
||||||
|
is_consumed: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draft mirroring the FIFO resolution: two lines, both on B1, 5 + 5 = 10 > 8.
|
||||||
|
const overIssue = await prisma.sklad_issues.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
notes: `${N}over_issue`,
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||||
|
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
overIssueId = overIssue.id;
|
||||||
|
|
||||||
|
// Control draft: duplicate lines whose combined quantity EXACTLY fits B1.
|
||||||
|
const exactIssue = await prisma.sklad_issues.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
notes: `${N}exact_issue`,
|
||||||
|
items: {
|
||||||
|
create: [
|
||||||
|
{ item_id: itemId, batch_id: batch1Id, quantity: 3 },
|
||||||
|
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
exactIssueId = exactIssue.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirmIssue cross-line batch totals (audit C2)", () => {
|
||||||
|
it("rejects duplicate same-batch lines whose combined quantity exceeds the batch", async () => {
|
||||||
|
const result = await confirmIssue(overIssueId, userId);
|
||||||
|
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) expect(result.status).toBe(400);
|
||||||
|
|
||||||
|
// The batch must be untouched — not driven negative and hidden.
|
||||||
|
const b1 = await prisma.sklad_batches.findUnique({
|
||||||
|
where: { id: batch1Id },
|
||||||
|
});
|
||||||
|
expect(Number(b1?.quantity)).toBe(8);
|
||||||
|
expect(b1?.is_consumed).toBe(false);
|
||||||
|
|
||||||
|
const negatives = await prisma.sklad_batches.findMany({
|
||||||
|
where: { item_id: itemId, quantity: { lt: 0 } },
|
||||||
|
});
|
||||||
|
expect(negatives).toHaveLength(0);
|
||||||
|
|
||||||
|
const issue = await prisma.sklad_issues.findUnique({
|
||||||
|
where: { id: overIssueId },
|
||||||
|
});
|
||||||
|
expect(issue?.status).toBe("DRAFT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still confirms duplicate same-batch lines whose combined quantity fits", async () => {
|
||||||
|
const result = await confirmIssue(exactIssueId, userId);
|
||||||
|
|
||||||
|
expect("error" in result).toBe(false);
|
||||||
|
|
||||||
|
const b1 = await prisma.sklad_batches.findUnique({
|
||||||
|
where: { id: batch1Id },
|
||||||
|
});
|
||||||
|
expect(Number(b1?.quantity)).toBe(0);
|
||||||
|
expect(b1?.is_consumed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/__tests__/warehouse-items-sort.test.ts
Normal file
97
src/__tests__/warehouse-items-sort.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import warehouseRoutes from "../routes/admin/warehouse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pinning tests for the verified-adjacent audit finding: GET /items ignores
|
||||||
|
* the client `sort` param (the WarehouseItems page sends one — default
|
||||||
|
* item_number) and hardcodes `orderBy: { name: "asc" }` with no `{ id }`
|
||||||
|
* tiebreak.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "wh_itemsort_";
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify>;
|
||||||
|
let adminToken: string;
|
||||||
|
// Names sort A→B, item numbers sort the OPPOSITE way, so honoring the sort
|
||||||
|
// param produces a different first row than the hardcoded name ordering.
|
||||||
|
let itemAId: number; // name "...a_name", item_number "...Z2"
|
||||||
|
let itemBId: number; // name "...b_name", item_number "...A1"
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
app.addHook("onRequest", securityHeaders);
|
||||||
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemA = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}a_name`, item_number: `${N}Z2`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemAId = itemA.id;
|
||||||
|
const itemB = await prisma.sklad_items.create({
|
||||||
|
data: { name: `${N}b_name`, item_number: `${N}A1`, unit: "ks" },
|
||||||
|
});
|
||||||
|
itemBId = itemB.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /warehouse/items sort param", () => {
|
||||||
|
it("honors sort=item_number (asc puts the A1 item first)", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/warehouse/items?search=${N}&sort=item_number&order=asc`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||||
|
expect(ids).toEqual([itemBId, itemAId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to name ordering when no sort is given", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/warehouse/items?search=${N}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
|
||||||
|
expect(ids).toEqual([itemAId, itemBId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a non-allow-listed sort field (no 500)", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/warehouse/items?search=${N}&sort=total_quantity`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
|
|||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import AppShell from "./ui/AppShell";
|
import AppShell from "./ui/AppShell";
|
||||||
import AlertContainer from "./components/AlertContainer";
|
import AlertContainer from "./components/AlertContainer";
|
||||||
|
import UpdateBanner from "./components/UpdateBanner";
|
||||||
|
import { lazyWithReload } from "./utils/lazyWithReload";
|
||||||
import MuiProvider from "./ui/MuiProvider";
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
import { LoadingState } from "./ui";
|
import { LoadingState } from "./ui";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
|
||||||
|
|
||||||
const Odin = lazy(() => import("./pages/Odin"));
|
const Dashboard = lazyWithReload(() => import("./pages/Dashboard"));
|
||||||
const Users = lazy(() => import("./pages/Users"));
|
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Users = lazyWithReload(() => import("./pages/Users"));
|
||||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
||||||
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
|
const AttendanceHistory = lazyWithReload(
|
||||||
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
|
() => import("./pages/AttendanceHistory"),
|
||||||
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
);
|
||||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
|
||||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
const AttendanceBalances = lazyWithReload(
|
||||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
() => import("./pages/AttendanceBalances"),
|
||||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
);
|
||||||
const Trips = lazy(() => import("./pages/Trips"));
|
const AttendanceCreate = lazyWithReload(
|
||||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
() => import("./pages/AttendanceCreate"),
|
||||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
);
|
||||||
const Vehicles = lazy(() => import("./pages/Vehicles"));
|
const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
|
||||||
const Offers = lazy(() => import("./pages/Offers"));
|
const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
|
||||||
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
|
const AttendanceLocation = lazyWithReload(
|
||||||
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
() => import("./pages/AttendanceLocation"),
|
||||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
);
|
||||||
const Orders = lazy(() => import("./pages/Orders"));
|
const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
|
||||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
const Trips = lazyWithReload(() => import("./pages/Trips"));
|
||||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
|
||||||
const Projects = lazy(() => import("./pages/Projects"));
|
const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
|
||||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
|
||||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
const Offers = lazyWithReload(() => import("./pages/Offers"));
|
||||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
|
||||||
const Settings = lazy(() => import("./pages/Settings"));
|
const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
|
||||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
const Orders = lazyWithReload(() => import("./pages/Orders"));
|
||||||
const Warehouse = lazy(() => import("./pages/Warehouse"));
|
const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
|
||||||
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
|
const IssuedOrderDetail = lazyWithReload(
|
||||||
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
|
() => import("./pages/IssuedOrderDetail"),
|
||||||
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
|
);
|
||||||
const WarehouseReceiptDetail = lazy(
|
const Projects = lazyWithReload(() => import("./pages/Projects"));
|
||||||
|
const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
|
||||||
|
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
|
||||||
|
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
|
||||||
|
const Settings = lazyWithReload(() => import("./pages/Settings"));
|
||||||
|
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
|
||||||
|
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
|
||||||
|
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
|
||||||
|
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
|
||||||
|
const WarehouseItemDetail = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseItemDetail"),
|
||||||
|
);
|
||||||
|
const WarehouseReceipts = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseReceipts"),
|
||||||
|
);
|
||||||
|
const WarehouseReceiptDetail = lazyWithReload(
|
||||||
() => import("./pages/WarehouseReceiptDetail"),
|
() => import("./pages/WarehouseReceiptDetail"),
|
||||||
);
|
);
|
||||||
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
|
const WarehouseReceiptForm = lazyWithReload(
|
||||||
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
|
() => import("./pages/WarehouseReceiptForm"),
|
||||||
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
|
);
|
||||||
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
|
const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
|
||||||
const WarehouseReservations = lazy(
|
const WarehouseIssueDetail = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseIssueDetail"),
|
||||||
|
);
|
||||||
|
const WarehouseIssueForm = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseIssueForm"),
|
||||||
|
);
|
||||||
|
const WarehouseReservations = lazyWithReload(
|
||||||
() => import("./pages/WarehouseReservations"),
|
() => import("./pages/WarehouseReservations"),
|
||||||
);
|
);
|
||||||
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
|
const WarehouseInventory = lazyWithReload(
|
||||||
const WarehouseInventoryDetail = lazy(
|
() => import("./pages/WarehouseInventory"),
|
||||||
|
);
|
||||||
|
const WarehouseInventoryDetail = lazyWithReload(
|
||||||
() => import("./pages/WarehouseInventoryDetail"),
|
() => import("./pages/WarehouseInventoryDetail"),
|
||||||
);
|
);
|
||||||
const WarehouseInventoryForm = lazy(
|
const WarehouseInventoryForm = lazyWithReload(
|
||||||
() => import("./pages/WarehouseInventoryForm"),
|
() => import("./pages/WarehouseInventoryForm"),
|
||||||
);
|
);
|
||||||
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
const WarehouseReports = lazyWithReload(
|
||||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
() => import("./pages/WarehouseReports"),
|
||||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
);
|
||||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
const WarehouseSuppliers = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseSuppliers"),
|
||||||
|
);
|
||||||
|
const WarehouseLocations = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseLocations"),
|
||||||
|
);
|
||||||
|
const WarehouseCategories = lazyWithReload(
|
||||||
|
() => import("./pages/WarehouseCategories"),
|
||||||
|
);
|
||||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
@@ -75,6 +107,7 @@ export default function AdminApp() {
|
|||||||
<AlertProvider>
|
<AlertProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AlertContainer />
|
<AlertContainer />
|
||||||
|
<UpdateBanner />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<LoadingState />}>
|
<Suspense fallback={<LoadingState />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function BulkPlanModal({
|
|||||||
value={form.project_id}
|
value={form.project_id}
|
||||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "— bez projektu —" },
|
{ value: "", label: "Vyberte projekt" },
|
||||||
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
249
src/admin/components/NoteCard.tsx
Normal file
249
src/admin/components/NoteCard.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat-bubble style note row (author avatar + bubble), shared by the
|
||||||
|
* project notes list. Mobile-first: the header wraps (name / timestamp),
|
||||||
|
* the action icons live in the bubble's top-right corner so they never
|
||||||
|
* squeeze the content column, and editing happens inline in the bubble.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const initialsOf = (name: string): string =>
|
||||||
|
name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Same edit/delete glyphs as the data tables (AttendanceShiftTable & co.).
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatNoteDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const hours = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteCardProps {
|
||||||
|
authorName: string;
|
||||||
|
createdAt: string;
|
||||||
|
editedAt?: string | null;
|
||||||
|
content: string;
|
||||||
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
|
deleting?: boolean;
|
||||||
|
/** Reject (throw) to keep the editor open — the caller shows the alert. */
|
||||||
|
onSave?: (content: string) => Promise<void>;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoteCard({
|
||||||
|
authorName,
|
||||||
|
createdAt,
|
||||||
|
editedAt,
|
||||||
|
content,
|
||||||
|
canEdit = false,
|
||||||
|
canDelete = false,
|
||||||
|
deleting = false,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
}: NoteCardProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
setDraft(content);
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!onSave || !draft.trim() || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(draft.trim());
|
||||||
|
setEditing(false);
|
||||||
|
} catch {
|
||||||
|
// Caller already alerted; keep the editor open with the draft.
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={(t) => ({
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.12)`,
|
||||||
|
color: "primary.main",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{initialsOf(authorName)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
borderTopLeftRadius: 4,
|
||||||
|
p: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 0.5 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
columnGap: 1,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{authorName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatNoteDate(createdAt)}
|
||||||
|
{editedAt && (
|
||||||
|
<Box component="span" sx={{ fontStyle: "italic" }}>
|
||||||
|
{" "}
|
||||||
|
· upraveno {formatNoteDate(editedAt)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{!editing && (canEdit || canDelete) && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 0.25,
|
||||||
|
flexShrink: 0,
|
||||||
|
mt: -0.5,
|
||||||
|
mr: -0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{canEdit && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={startEdit}
|
||||||
|
title="Upravit poznámku"
|
||||||
|
aria-label="Upravit poznámku"
|
||||||
|
>
|
||||||
|
{EditIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Smazat poznámku"
|
||||||
|
aria-label="Smazat poznámku"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && e.ctrlKey && draft.trim()) save();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 1, display: "flex", gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || !draft.trim()}
|
||||||
|
>
|
||||||
|
{saving ? "Ukládání…" : "Uložit"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Zrušit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -148,7 +148,7 @@ const TrashIcon = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function PlanCellModal(props: Props) {
|
export default function PlanCellModal(props: Props) {
|
||||||
const { open, mode, onClose } = props;
|
const { open, mode } = props;
|
||||||
if (!open || mode.kind === "closed") return null;
|
if (!open || mode.kind === "closed") return null;
|
||||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||||
if (mode.kind === "day-in-range")
|
if (mode.kind === "day-in-range")
|
||||||
@@ -392,7 +392,7 @@ function EditForm(props: Props) {
|
|||||||
value={projectId === null ? "" : String(projectId)}
|
value={projectId === null ? "" : String(projectId)}
|
||||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "— bez projektu —" },
|
{ value: "", label: "Vyberte projekt" },
|
||||||
...projects.map((p) => ({
|
...projects.map((p) => ({
|
||||||
value: String(p.id),
|
value: String(p.id),
|
||||||
label: p.name,
|
label: p.name,
|
||||||
@@ -488,6 +488,7 @@ function DayInRangeModal(
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onClose}
|
onSubmit={onClose}
|
||||||
submitText="Zavřít — ponechat rozsah beze změny"
|
submitText="Zavřít — ponechat rozsah beze změny"
|
||||||
|
hideCancel
|
||||||
maxWidth="md"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
|||||||
@@ -491,9 +491,11 @@ export default function PlanGrid({
|
|||||||
}, [projects]);
|
}, [projects]);
|
||||||
// Memoize the day list so it isn't rebuilt on every render (only when the
|
// Memoize the day list so it isn't rebuilt on every render (only when the
|
||||||
// visible range changes). Stable identity also keeps the row map cheap.
|
// visible range changes). Stable identity also keeps the row map cheap.
|
||||||
|
const dateFrom = data?.date_from;
|
||||||
|
const dateTo = data?.date_to;
|
||||||
const days = useMemo(
|
const days = useMemo(
|
||||||
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
() => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
|
||||||
[data?.date_from, data?.date_to],
|
[dateFrom, dateTo],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data) return <LoadingState />;
|
if (!data) return <LoadingState />;
|
||||||
|
|||||||
@@ -19,46 +19,22 @@ import {
|
|||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
|
||||||
// ---------- Shared types ----------
|
// ---------- Shared types ----------
|
||||||
|
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
|
||||||
export interface ShiftFormData {
|
// here so existing importers of ShiftFormModal keep working.
|
||||||
user_id: string;
|
export type {
|
||||||
shift_date: string;
|
ShiftFormData,
|
||||||
leave_type: string;
|
ProjectLog,
|
||||||
leave_hours: number;
|
Project,
|
||||||
arrival_date: string;
|
User,
|
||||||
arrival_time: string;
|
EditingRecord,
|
||||||
break_start_date: string;
|
} from "./shiftFormTypes";
|
||||||
break_start_time: string;
|
import type {
|
||||||
break_end_date: string;
|
ShiftFormData,
|
||||||
break_end_time: string;
|
ProjectLog,
|
||||||
departure_date: string;
|
Project,
|
||||||
departure_time: string;
|
User,
|
||||||
notes: string;
|
EditingRecord,
|
||||||
}
|
} from "./shiftFormTypes";
|
||||||
|
|
||||||
export interface ProjectLog {
|
|
||||||
_key?: string;
|
|
||||||
id?: number;
|
|
||||||
project_id: string | number;
|
|
||||||
hours: string | number;
|
|
||||||
minutes: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: number | string;
|
|
||||||
project_number: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditingRecord {
|
|
||||||
user_name: string;
|
|
||||||
shift_date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Sub-component props ----------
|
// ---------- Sub-component props ----------
|
||||||
|
|
||||||
@@ -157,7 +133,7 @@ function ProjectLogRow({
|
|||||||
value={String(log.project_id)}
|
value={String(log.project_id)}
|
||||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "— Projekt —" },
|
{ value: "", label: "Vyberte projekt" },
|
||||||
...projectList.map((p) => ({
|
...projectList.map((p) => ({
|
||||||
value: String(p.id),
|
value: String(p.id),
|
||||||
label: `${p.project_number} – ${p.name}`,
|
label: `${p.project_number} – ${p.name}`,
|
||||||
|
|||||||
148
src/admin/components/StatusChipMenu.tsx
Normal file
148
src/admin/components/StatusChipMenu.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState, type ComponentProps } from "react";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { StatusChip, ConfirmDialog } from "../ui";
|
||||||
|
|
||||||
|
/** One entry in a {@link StatusChipMenu}. */
|
||||||
|
export interface StatusChipAction {
|
||||||
|
/** Stable identifier, e.g. the target status. */
|
||||||
|
key: string;
|
||||||
|
/** Menu item text, e.g. "Dokončit". */
|
||||||
|
label: string;
|
||||||
|
/** Red menu-item text + danger ConfirmDialog variant. */
|
||||||
|
danger?: boolean;
|
||||||
|
/**
|
||||||
|
* Confirmation dialog content. When omitted the action fires directly from
|
||||||
|
* the menu without confirmation (used by "Vytvořit objednávku…", which
|
||||||
|
* opens its own modal).
|
||||||
|
*/
|
||||||
|
confirm?: { title: string; message: string; confirmText: string };
|
||||||
|
/**
|
||||||
|
* Executed on pick (after confirmation when `confirm` is set). Awaited:
|
||||||
|
* while pending the ConfirmDialog shows its loading state and cannot be
|
||||||
|
* closed; on resolve the dialog closes; on rejection it stays open (the
|
||||||
|
* caller is responsible for toasting the error).
|
||||||
|
*/
|
||||||
|
onAction: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusChipMenuProps {
|
||||||
|
/** Chip text (current status label). */
|
||||||
|
label: string;
|
||||||
|
/** Chip color, same palette as {@link StatusChip}. */
|
||||||
|
color: ComponentProps<typeof StatusChip>["color"];
|
||||||
|
/** Quick actions. Empty/undefined renders a plain non-clickable chip. */
|
||||||
|
actions?: StatusChipAction[];
|
||||||
|
/** Force a plain chip (e.g. the user lacks the edit permission). */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Tooltip on the clickable chip. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status chip with a quick-action menu, shared by the list pages
|
||||||
|
* (offers / received orders / projects).
|
||||||
|
*
|
||||||
|
* Clicking the chip (stopPropagation, so row navigation never fires) opens a
|
||||||
|
* dense MUI Menu of the valid next actions. Picking one closes the menu and
|
||||||
|
* either fires `onAction` directly (no `confirm` given) or opens the single
|
||||||
|
* shared ConfirmDialog instance — danger variant for destructive actions,
|
||||||
|
* `loading` while the awaited `onAction` is pending, staying open when it
|
||||||
|
* rejects per app convention (the caller toasts errors).
|
||||||
|
*
|
||||||
|
* With no actions — or with `disabled` — it renders a plain non-clickable
|
||||||
|
* StatusChip without a tooltip.
|
||||||
|
*/
|
||||||
|
export default function StatusChipMenu({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
actions,
|
||||||
|
disabled = false,
|
||||||
|
title = "Změnit stav",
|
||||||
|
}: StatusChipMenuProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [pending, setPending] = useState<StatusChipAction | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (disabled || !actions || actions.length === 0) {
|
||||||
|
return <StatusChip label={label} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = (action: StatusChipAction) => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
if (action.confirm) {
|
||||||
|
setPending(action);
|
||||||
|
} else {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await action.onAction();
|
||||||
|
} catch {
|
||||||
|
// Expected: the caller toasts its own errors; nothing to close here.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!pending) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await pending.onAction();
|
||||||
|
// Success → close; ConfirmDialog freezes its content through the fade.
|
||||||
|
setPending(null);
|
||||||
|
} catch {
|
||||||
|
// Expected: rejection keeps the dialog open; the caller toasts the error.
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusChip
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
title={title}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={Boolean(anchorEl)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||||
|
slotProps={{ list: { dense: true } }}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<MenuItem
|
||||||
|
key={action.key}
|
||||||
|
// stopPropagation: MUI portals re-bubble synthetic events to
|
||||||
|
// React-tree ancestors — a future onRowClick row must not fire.
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePick(action);
|
||||||
|
}}
|
||||||
|
sx={action.danger ? { color: "error.main" } : undefined}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pending !== null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onConfirm={() => void handleConfirm()}
|
||||||
|
title={pending?.confirm?.title ?? ""}
|
||||||
|
message={pending?.confirm?.message ?? ""}
|
||||||
|
confirmText={pending?.confirm?.confirmText}
|
||||||
|
confirmVariant={pending?.danger ? "danger" : "primary"}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/admin/components/TitleSync.tsx
Normal file
42
src/admin/components/TitleSync.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { menuSections, isItemActive } from "../ui/navData";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null-rendering helper that keeps the browser tab title in sync with the
|
||||||
|
* active navigation item. Scans menuSections in order (first match wins) via
|
||||||
|
* the same isItemActive logic the sidebar uses, so detail routes covered by
|
||||||
|
* matchPrefix (e.g. /offers/123) inherit their section item's label.
|
||||||
|
* No cleanup on unmount — the last title simply persists.
|
||||||
|
*/
|
||||||
|
// Labels reused across sections ("Záznam", "Moje historie", "Správa",
|
||||||
|
// "Přehled") would produce identical tab titles — qualify those with their
|
||||||
|
// section label so /attendance and /trips tabs stay distinguishable.
|
||||||
|
const labelCounts = new Map<string, number>();
|
||||||
|
for (const section of menuSections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
labelCounts.set(item.label, (labelCounts.get(item.label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TitleSync() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const section of menuSections) {
|
||||||
|
const item = section.items.find((candidate) =>
|
||||||
|
isItemActive(candidate, pathname),
|
||||||
|
);
|
||||||
|
if (item) {
|
||||||
|
const ambiguous = (labelCounts.get(item.label) ?? 0) > 1;
|
||||||
|
document.title = ambiguous
|
||||||
|
? `${item.label} – ${section.label} · BOHA`
|
||||||
|
: `${item.label} · BOHA`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.title = "BOHA Admin";
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
35
src/admin/components/UpdateBanner.tsx
Normal file
35
src/admin/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a persistent banner when the server reports a newer build than the one
|
||||||
|
* this tab is running (detected via the X-App-Version header in apiFetch). The
|
||||||
|
* reload is user-initiated — we never force it, so an in-progress form is safe.
|
||||||
|
*/
|
||||||
|
export default function UpdateBanner() {
|
||||||
|
const newVersion = useSyncExternalStore(
|
||||||
|
subscribeNewVersion,
|
||||||
|
getNewVersion,
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Snackbar
|
||||||
|
open={Boolean(newVersion)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
message="Je k dispozici nová verze aplikace."
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Obnovit
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import DialogActions from "@mui/material/DialogActions";
|
|||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
|
import { USER_INVALIDATE } from "../../lib/queries/users";
|
||||||
import { iconBadgeSx } from "../../theme";
|
import { iconBadgeSx } from "../../theme";
|
||||||
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
||||||
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
||||||
@@ -142,6 +143,9 @@ export default function DashProfile({
|
|||||||
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
||||||
queryKey: ["totp", "qr", totpQrUri],
|
queryKey: ["totp", "qr", totpQrUri],
|
||||||
enabled: !!totpQrUri,
|
enabled: !!totpQrUri,
|
||||||
|
// Purely local QR generation — the global "server data" error toast would
|
||||||
|
// misattribute a failure here; the totpQrFailed inline message covers it.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
||||||
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
||||||
@@ -221,9 +225,13 @@ export default function DashProfile({
|
|||||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||||
});
|
});
|
||||||
// Refresh anything keyed on the current user's data so stale views
|
// Refresh anything keyed on the current user's data so stale views
|
||||||
// (dashboard widgets, user lists) pick up the edited profile.
|
// pick up the edited profile — a self-rename must also refresh the
|
||||||
|
// domains embedding the display name (USER_INVALIDATE), not just the
|
||||||
|
// dashboard and user list.
|
||||||
|
for (const key of USER_INVALIDATE) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
||||||
// before the success toast appears, so the toast doesn't flash over the
|
// before the success toast appears, so the toast doesn't flash over the
|
||||||
@@ -262,8 +270,9 @@ export default function DashProfile({
|
|||||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
|
style={{ height: "100%" }}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card sx={{ height: "100%" }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -179,10 +179,12 @@ export default function DashQuickActions({
|
|||||||
});
|
});
|
||||||
const result: ApiResult<unknown> = await response.json();
|
const result: ApiResult<unknown> = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// A new trip changes the trip list and the dashboard vehicle widgets —
|
// A new trip changes the trip list, the dashboard vehicle widgets AND
|
||||||
// invalidate the broad domains (prefix-matching covers sub-queries).
|
// the vehicles domain (actual_km moves) — invalidate all three broad
|
||||||
|
// domains (prefix-matching covers sub-queries).
|
||||||
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||||
setShowTripModal(false);
|
setShowTripModal(false);
|
||||||
alert.success(result.message ?? "Jízda uložena");
|
alert.success(result.message ?? "Jízda uložena");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ export default function DashSessions() {
|
|||||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
|
style={{ height: "100%" }}
|
||||||
>
|
>
|
||||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
<Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no printable custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,10 +49,31 @@ export interface DocumentItem {
|
|||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
|
/** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */
|
||||||
|
discount: string | number;
|
||||||
/** Offers only — undefined (issued orders) counts as included. */
|
/** Offers only — undefined (issued orders) counts as included. */
|
||||||
is_included_in_total?: boolean;
|
is_included_in_total?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discount input styling: centered, and with the native number spinners hidden
|
||||||
|
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
|
||||||
|
*/
|
||||||
|
const discountInputSx = {
|
||||||
|
"& input": { textAlign: "center" },
|
||||||
|
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||||
|
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NET of one line after its per-item percentage discount. */
|
||||||
|
const discountedLineNet = (item: DocumentItem): number =>
|
||||||
|
(Number(item.quantity) || 0) *
|
||||||
|
(Number(item.unit_price) || 0) *
|
||||||
|
(1 - (Number(item.discount) || 0) / 100);
|
||||||
|
|
||||||
let documentItemKeyCounter = 0;
|
let documentItemKeyCounter = 0;
|
||||||
|
|
||||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||||
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(sum, it) =>
|
(sum, it) =>
|
||||||
it.is_included_in_total === false
|
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||||
? sum
|
|
||||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,6 +131,7 @@ function SortableItemRow({
|
|||||||
readOnly,
|
readOnly,
|
||||||
canDelete,
|
canDelete,
|
||||||
showIncludedInTotal,
|
showIncludedInTotal,
|
||||||
|
showDiscount,
|
||||||
itemDescriptionMaxLength,
|
itemDescriptionMaxLength,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -121,6 +142,7 @@ function SortableItemRow({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
showIncludedInTotal: boolean;
|
showIncludedInTotal: boolean;
|
||||||
|
showDiscount: boolean;
|
||||||
itemDescriptionMaxLength: number;
|
itemDescriptionMaxLength: number;
|
||||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -143,8 +165,7 @@ function SortableItemRow({
|
|||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
const lineTotal =
|
const lineTotal = discountedLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -214,7 +235,8 @@ function SortableItemRow({
|
|||||||
placeholder="Volitelný"
|
placeholder="Volitelný"
|
||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
sx={{ "& textarea": { resize: "vertical" } }}
|
sx={{ "& textarea": { resize: "vertical" } }}
|
||||||
/>
|
/>
|
||||||
@@ -252,6 +274,17 @@ function SortableItemRow({
|
|||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
|
{showDiscount && (
|
||||||
|
<TextField
|
||||||
|
label="Sleva %"
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -352,7 +385,8 @@ function SortableItemRow({
|
|||||||
placeholder="Podrobný popis (volitelný)"
|
placeholder="Podrobný popis (volitelný)"
|
||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
multiline
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
sx={{
|
sx={{
|
||||||
"& textarea": {
|
"& textarea": {
|
||||||
@@ -394,6 +428,18 @@ function SortableItemRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -440,10 +486,18 @@ interface DocumentItemsEditorProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||||
showIncludedInTotal?: boolean;
|
showIncludedInTotal?: boolean;
|
||||||
|
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||||
|
showDiscount?: boolean;
|
||||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||||
itemDescriptionMaxLength?: number;
|
itemDescriptionMaxLength?: number;
|
||||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||||
templatesSlot?: ReactNode;
|
templatesSlot?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Allow removing every row so the list can be empty (issued orders may be
|
||||||
|
* issued by sections alone). Default false keeps the offers/invoices rule of
|
||||||
|
* at least one item.
|
||||||
|
*/
|
||||||
|
allowEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -458,8 +512,10 @@ export default function DocumentItemsEditor({
|
|||||||
readOnly,
|
readOnly,
|
||||||
error,
|
error,
|
||||||
showIncludedInTotal = false,
|
showIncludedInTotal = false,
|
||||||
|
showDiscount = false,
|
||||||
itemDescriptionMaxLength = 5000,
|
itemDescriptionMaxLength = 5000,
|
||||||
templatesSlot,
|
templatesSlot,
|
||||||
|
allowEmpty = false,
|
||||||
}: DocumentItemsEditorProps) {
|
}: DocumentItemsEditorProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -489,10 +545,13 @@ export default function DocumentItemsEditor({
|
|||||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||||
|
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
if (items.length <= 1) return;
|
if (!allowEmpty && items.length <= 1) return;
|
||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When allowEmpty, even the last row is deletable (list may go to zero).
|
||||||
|
const canDeleteRow = allowEmpty || items.length > 1;
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -559,8 +618,9 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
@@ -592,6 +652,11 @@ export default function DocumentItemsEditor({
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell sx={{ width: "4rem" }} align="center">
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
V ceně
|
V ceně
|
||||||
@@ -611,8 +676,9 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) =>
|
onUpdate={(field, value) =>
|
||||||
updateItem(index, field, value)
|
updateItem(index, field, value)
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export default function OdinChat() {
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const threadRef = useRef<HTMLDivElement>(null);
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
const seededId = useRef<number | null>(null);
|
const seededId = useRef<number | null>(null);
|
||||||
|
// Drag & drop over the chat column. The depth counter pairs enter/leave
|
||||||
|
// events bubbling from children so the overlay doesn't flicker mid-drag.
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const dragDepth = useRef(0);
|
||||||
|
|
||||||
// Below md the conversation list collapses into a slide-in drawer.
|
// Below md the conversation list collapses into a slide-in drawer.
|
||||||
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
|
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
|
||||||
@@ -177,17 +181,63 @@ export default function OdinChat() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Attaching only stages files; nothing is sent until Odeslat.
|
// Attaching only stages files; nothing is sent until Odeslat.
|
||||||
const onFiles = (files: FileList | null) => {
|
const stageFiles = (files: File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setAttachments((a) => [
|
setAttachments((a) => [
|
||||||
...a,
|
...a,
|
||||||
...Array.from(files).map((file) => ({ id: nextUid(), file })),
|
...files.map((file) => ({ id: nextUid(), file })),
|
||||||
]);
|
]);
|
||||||
|
};
|
||||||
|
const onFiles = (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
stageFiles(Array.from(files));
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
};
|
};
|
||||||
const removeAttachment = (id: string) =>
|
const removeAttachment = (id: string) =>
|
||||||
setAttachments((a) => a.filter((s) => s.id !== id));
|
setAttachments((a) => a.filter((s) => s.id !== id));
|
||||||
|
|
||||||
|
// Drag & drop anywhere over the chat column stages files exactly like the
|
||||||
|
// paperclip (same accept filter as the file input). Only file drags react —
|
||||||
|
// text/link drags pass through untouched — and nothing stages while busy.
|
||||||
|
const isAccepted = (f: File) =>
|
||||||
|
f.type === "application/pdf" || f.type.startsWith("image/");
|
||||||
|
const dragHasFiles = (e: React.DragEvent) =>
|
||||||
|
Array.from(e.dataTransfer.types).includes("Files");
|
||||||
|
|
||||||
|
const onDragEnter = (e: React.DragEvent) => {
|
||||||
|
if (busy || !dragHasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current += 1;
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
const onDragOver = (e: React.DragEvent) => {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
// preventDefault permits the drop (and keeps the browser from replacing
|
||||||
|
// the app with the dropped file); while busy the drop itself is refused.
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) e.dataTransfer.dropEffect = "none";
|
||||||
|
};
|
||||||
|
const onDragLeave = () => {
|
||||||
|
if (dragDepth.current === 0) return;
|
||||||
|
dragDepth.current -= 1;
|
||||||
|
if (dragDepth.current === 0) setDragOver(false);
|
||||||
|
};
|
||||||
|
const onDrop = (e: React.DragEvent) => {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current = 0;
|
||||||
|
setDragOver(false);
|
||||||
|
if (busy) return;
|
||||||
|
const dropped = Array.from(e.dataTransfer.files);
|
||||||
|
const accepted = dropped.filter(isAccepted);
|
||||||
|
if (accepted.length === 0) {
|
||||||
|
// Nothing usable — hint instead of silently doing nothing.
|
||||||
|
if (dropped.length > 0) alert.info("Podporované soubory: PDF a obrázky");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stageFiles(accepted);
|
||||||
|
};
|
||||||
|
|
||||||
const onSelect = (id: number) => {
|
const onSelect = (id: number) => {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
if (id === activeId || busy) return;
|
if (id === activeId || busy) return;
|
||||||
@@ -467,9 +517,14 @@ export default function OdinChat() {
|
|||||||
sidebar
|
sidebar
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat column */}
|
{/* Chat column — also the drop target for invoice files (overlay below). */}
|
||||||
<Box
|
<Box
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
sx={{
|
sx={{
|
||||||
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -557,11 +612,14 @@ export default function OdinChat() {
|
|||||||
{review.length > 0 && (
|
{review.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
maxHeight: "35vh",
|
maxHeight: "40vh",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
// Flex children shrink by default — with 2+ cards they'd get
|
||||||
|
// compressed to fit instead of overflowing into the scrollbar.
|
||||||
|
"& > *": { flexShrink: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{review.map((inv) => (
|
{review.map((inv) => (
|
||||||
@@ -591,6 +649,63 @@ export default function OdinChat() {
|
|||||||
onRemoveAttachment={removeAttachment}
|
onRemoveAttachment={removeAttachment}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Drop-target overlay — pointer-events pass through so the drag
|
||||||
|
events keep firing on the column beneath it. */}
|
||||||
|
{dragOver && (
|
||||||
|
<Box
|
||||||
|
sx={(t) => ({
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
// Longhand on purpose — see the container border note above.
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "primary.main",
|
||||||
|
borderRadius: { xs: 0, md: 3 },
|
||||||
|
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.08)`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
boxShadow: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 32, height: 32, color: "primary.main", mb: 0.5 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Přetáhněte soubory sem
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
PDF nebo obrázky faktur
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
44
src/admin/components/shiftFormTypes.ts
Normal file
44
src/admin/components/shiftFormTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import { Select } from "../../ui";
|
import { Select } from "../../ui";
|
||||||
@@ -26,7 +26,7 @@ export default function ReservationPicker({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reservations = result?.data ?? [];
|
const reservations = useMemo(() => result?.data ?? [], [result?.data]);
|
||||||
|
|
||||||
// When item/project change, the loaded reservation list changes. If the
|
// When item/project change, the loaded reservation list changes. If the
|
||||||
// currently-selected reservation is no longer available, clear it and notify
|
// currently-selected reservation is no longer available, clear it and notify
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { registerQueryErrorNotifier } from "../lib/queryClient";
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,9 +44,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const timeouts = timeoutsRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
timeoutsRef.current.forEach(clearTimeout);
|
timeouts.forEach(clearTimeout);
|
||||||
timeoutsRef.current.clear();
|
timeouts.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -80,6 +82,13 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
[addAlert, removeAlert],
|
[addAlert, removeAlert],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Global React Query error toasts (queryClient.ts). `addAlert` is a stable
|
||||||
|
// useCallback, so this registers once on mount; re-registering the same
|
||||||
|
// handler is idempotent either way.
|
||||||
|
useEffect(() => {
|
||||||
|
registerQueryErrorNotifier((msg) => addAlert(msg, "error"));
|
||||||
|
}, [addAlert]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertContext.Provider value={methods}>
|
<AlertContext.Provider value={methods}>
|
||||||
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
|
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
|
||||||
|
|||||||
@@ -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
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -248,7 +253,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return { success: false, error: data.error };
|
return { success: false, error: data.error };
|
||||||
} catch {
|
} catch {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
"Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.";
|
"Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
return { success: false, error: errorMsg };
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
@@ -306,7 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(data.error);
|
setError(data.error);
|
||||||
return { success: false, error: data.error };
|
return { success: false, error: data.error };
|
||||||
} catch {
|
} catch {
|
||||||
const errorMsg = "Chyba pripojeni.";
|
const errorMsg = "Chyba připojení.";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
return { success: false, error: errorMsg };
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import {
|
|||||||
getDaysInMonth,
|
getDaysInMonth,
|
||||||
shiftDateForMonth,
|
shiftDateForMonth,
|
||||||
formatHoursDecimal,
|
formatHoursDecimal,
|
||||||
calculateNightMinutes,
|
|
||||||
normalizeDateStr,
|
normalizeDateStr,
|
||||||
holidaysInMonth,
|
computeMzdaSummary,
|
||||||
calculateFreeHolidayHours,
|
|
||||||
getCzechWeekday,
|
getCzechWeekday,
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
import type {
|
import type {
|
||||||
@@ -29,7 +27,7 @@ import type {
|
|||||||
ProjectLog,
|
ProjectLog,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
} from "../components/ShiftFormModal";
|
} from "../components/shiftFormTypes";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -99,6 +97,7 @@ interface UserTotal {
|
|||||||
weekend_hours?: number;
|
weekend_hours?: number;
|
||||||
night_minutes?: number;
|
night_minutes?: number;
|
||||||
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
worked_holiday_hours?: number; // sum of hours worked ON a holiday
|
||||||
|
manko?: number; // Chybějící hodiny: real clock time below the fond
|
||||||
records?: AttendanceRecord[];
|
records?: AttendanceRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,117 +215,36 @@ function computeUserTotals(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add fund data per user (matching PHP addFundDataToUserTotals)
|
// Rekapitulace per user — the SAME shared computation the print uses
|
||||||
const [yearStr, monthStr] = month.split("-");
|
// (computeMzdaSummary), so the screen and the printed sheet can never
|
||||||
const yr = parseInt(yearStr, 10);
|
// disagree. Accountant spec 2026-07: Odpracováno = worked minus
|
||||||
const mo = parseInt(monthStr, 10) - 1;
|
// holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked − fond);
|
||||||
|
// Svátek = hours actually worked on holidays.
|
||||||
// Count Mon-Fri business days in month (INCLUDING holidays).
|
|
||||||
// The fund is rawBizDays × 8 — holidays stay in the count so the
|
|
||||||
// user is paid for them via the fund. Svátek (free holiday hours) is
|
|
||||||
// computed separately as "8h per holiday the user did not work".
|
|
||||||
let rawBizDays = 0;
|
|
||||||
const cur = new Date(yr, mo, 1);
|
|
||||||
while (cur.getMonth() === mo) {
|
|
||||||
const dow = cur.getDay();
|
|
||||||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
const userRecords = recordsByUser.get(Number(uid)) || [];
|
const userRecords = recordsByUser.get(Number(uid)) || [];
|
||||||
|
const s = computeMzdaSummary(userRecords, month);
|
||||||
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
|
|
||||||
const workedHoursRaw = t.minutes / 60;
|
|
||||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
|
||||||
|
|
||||||
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
|
|
||||||
const weekendHours = userRecords
|
|
||||||
.filter(
|
|
||||||
(r) =>
|
|
||||||
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
|
|
||||||
)
|
|
||||||
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
|
|
||||||
|
|
||||||
// Práce v noci: minutes in 22:00-06:00 across all work records.
|
|
||||||
const nightMinutes = userRecords
|
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
|
||||||
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
|
|
||||||
|
|
||||||
// Svátek (free): 8h per holiday the user did not work.
|
|
||||||
const freeHolidayHours = calculateFreeHolidayHours(
|
|
||||||
userRecords,
|
|
||||||
holidayDates,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Worked holidays: holidays the user DID work (counted toward
|
|
||||||
// Odpracováno, but their 8h must not flow into Přesčas).
|
|
||||||
const workedDatesSet = new Set(
|
|
||||||
userRecords
|
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
|
||||||
.map((r) => normalizeDateStr(r.shift_date))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
let workedHolidayCount = 0;
|
|
||||||
let workedHolidayHours = 0;
|
|
||||||
for (const hd of holidayDates) {
|
|
||||||
if (workedDatesSet.has(hd)) workedHolidayCount++;
|
|
||||||
}
|
|
||||||
// Sum the actual hours worked on holiday dates (for the KPI card's
|
|
||||||
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
|
|
||||||
for (const r of userRecords) {
|
|
||||||
if ((r.leave_type || "work") !== "work") continue;
|
|
||||||
const d = normalizeDateStr(r.shift_date);
|
|
||||||
if (d && holidayDates.includes(d)) {
|
|
||||||
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
|
|
||||||
const fund = rawBizDays * 8;
|
|
||||||
|
|
||||||
// Včetně svátků a přesčasů (mzda formula):
|
|
||||||
// odpracovano + vacation + remainder − min(freeHols, workedHols × 8)
|
|
||||||
//
|
|
||||||
// Two-way logic matching the print:
|
|
||||||
// • If the user worked at least one holiday, that worked holiday's
|
|
||||||
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
|
|
||||||
// subtract one 8h per worked holiday (capped at the total free
|
|
||||||
// holiday hours — can't go negative).
|
|
||||||
// • If the user did NOT work any holiday, no adjustment is needed:
|
|
||||||
// all unworked holidays are already paid via the Svátek line and
|
|
||||||
// don't flow into Přesčas, so vcetne_svatku just equals the
|
|
||||||
// work + vacation + remainder.
|
|
||||||
const remainder = workedHoursRaw - odpracovano;
|
|
||||||
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
|
|
||||||
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
|
|
||||||
|
|
||||||
// Přesčas = vcetneSv − odpracovano (derived).
|
|
||||||
const prescas = vcetneSv - odpracovano;
|
|
||||||
|
|
||||||
// Legacy fields (kept so existing UI doesn't break).
|
// Legacy fields (kept so existing UI doesn't break).
|
||||||
const workedHours = Math.round(workedHoursRaw * 10) / 10;
|
const workedHours = Math.round(s.workedHoursRaw * 10) / 10;
|
||||||
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
|
||||||
|
|
||||||
t.fund = fund;
|
t.fund = s.fund;
|
||||||
t.business_days = rawBizDays;
|
t.business_days = s.businessDays;
|
||||||
t.worked_hours = workedHours;
|
t.worked_hours = workedHours;
|
||||||
t.covered = covered;
|
t.covered = covered;
|
||||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
|
t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
|
||||||
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
|
t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
|
||||||
|
|
||||||
t.worked_hours_raw = workedHoursRaw;
|
t.worked_hours_raw = s.workedHoursRaw;
|
||||||
t.odpracovano = odpracovano;
|
t.odpracovano = s.odpracovano;
|
||||||
t.vcetne_svatku = vcetneSv;
|
t.vcetne_svatku = s.vcetneSvatku;
|
||||||
t.prescas = prescas;
|
t.prescas = s.prescas;
|
||||||
t.svatek = freeHolidayHours;
|
t.svatek = s.svatekHours;
|
||||||
t.weekend_hours = weekendHours;
|
t.weekend_hours = s.weekendHours;
|
||||||
t.night_minutes = nightMinutes;
|
t.night_minutes = s.nightMinutes;
|
||||||
t.worked_holiday_hours = workedHolidayHours;
|
t.worked_holiday_hours = s.svatekHours;
|
||||||
|
t.manko = s.manko;
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals;
|
return totals;
|
||||||
@@ -345,14 +263,25 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProjectLogsHtml(record: Record<string, any>): string {
|
/** Shape of the print endpoint payload consumed by the HTML builders. */
|
||||||
|
interface PrintData {
|
||||||
|
month: string;
|
||||||
|
month_name: string;
|
||||||
|
selected_user_name?: string | null;
|
||||||
|
user_totals: Record<string, UserTotal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectLogsHtml(record: AttendanceRecord): string {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return record.project_logs
|
return record.project_logs
|
||||||
.map((log: any) => {
|
.map((log) => {
|
||||||
let h: number, m: number;
|
let h: number, m: number;
|
||||||
if (log.hours !== null && log.hours !== undefined) {
|
if (log.hours !== null && log.hours !== undefined) {
|
||||||
h = parseInt(log.hours) || 0;
|
// hours/minutes arrive as string or number — String() before parseInt
|
||||||
m = parseInt(log.minutes) || 0;
|
// (parseInt already coerces to string internally, so this is a no-op
|
||||||
|
// at runtime, it just satisfies parseInt's string parameter type).
|
||||||
|
h = parseInt(String(log.hours)) || 0;
|
||||||
|
m = parseInt(String(log.minutes)) || 0;
|
||||||
} else if (log.started_at && log.ended_at) {
|
} else if (log.started_at && log.ended_at) {
|
||||||
const mins = Math.max(
|
const mins = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -375,15 +304,28 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
|||||||
return escapeHtml(record.project_name || "—");
|
return escapeHtml(record.project_name || "—");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserSectionHtml(
|
export function buildUserSectionHtml(
|
||||||
userId: string,
|
userId: string,
|
||||||
userData: Record<string, any>,
|
userData: UserTotal,
|
||||||
printData: Record<string, any>,
|
printData: PrintData,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
// Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
|
||||||
const recordsByDate = new Map<string, Record<string, any>>();
|
// records (two shifts, or work + partial-day vacation) — keep them ALL; a
|
||||||
|
// single-record map silently dropped every shift but the last one.
|
||||||
|
const recordsByDate = new Map<string, AttendanceRecord[]>();
|
||||||
for (const r of userData.records || []) {
|
for (const r of userData.records || []) {
|
||||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
const key = normalizeDateStr(r.shift_date);
|
||||||
|
const list = recordsByDate.get(key);
|
||||||
|
if (list) list.push(r);
|
||||||
|
else recordsByDate.set(key, [r]);
|
||||||
|
}
|
||||||
|
// Stable in-day order: by arrival time (leave records without times last).
|
||||||
|
for (const list of recordsByDate.values()) {
|
||||||
|
list.sort((a, b) =>
|
||||||
|
String(a.arrival_time || "9999").localeCompare(
|
||||||
|
String(b.arrival_time || "9999"),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate one row per day of the month.
|
// Iterate one row per day of the month.
|
||||||
@@ -392,24 +334,19 @@ function buildUserSectionHtml(
|
|||||||
const mo = parseInt(monthStr, 10);
|
const mo = parseInt(monthStr, 10);
|
||||||
const daysInMonth = getDaysInMonth(yr, mo);
|
const daysInMonth = getDaysInMonth(yr, mo);
|
||||||
|
|
||||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
const userRecords = userData.records ?? [];
|
||||||
|
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
const dateStr = shiftDateForMonth(yr, mo, d);
|
const dateStr = shiftDateForMonth(yr, mo, d);
|
||||||
const record = recordsByDate.get(dateStr);
|
const dayRecords = recordsByDate.get(dateStr);
|
||||||
const weekend = isWeekendDate(dateStr);
|
const weekend = isWeekendDate(dateStr);
|
||||||
const holiday = isHoliday(dateStr);
|
const holiday = isHoliday(dateStr);
|
||||||
const leaveType = (record?.leave_type as string) || "work";
|
|
||||||
const rowClasses = [
|
|
||||||
weekend && "weekend",
|
|
||||||
holiday && "holiday",
|
|
||||||
leaveType === "vacation" && "vacation",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
if (!record) {
|
if (!dayRecords || dayRecords.length === 0) {
|
||||||
|
const rowClasses = [weekend && "weekend", holiday && "holiday"]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
rows.push(`<tr class="${rowClasses}">
|
rows.push(`<tr class="${rowClasses}">
|
||||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||||
@@ -424,25 +361,37 @@ function buildUserSectionHtml(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLeave = leaveType !== "work";
|
// One row PER RECORD — a day can hold two shifts, or work + partial-day
|
||||||
const workMinutes = calculateWorkMinutesPrint(record);
|
// vacation. (A single row per day silently dropped the extra shifts.)
|
||||||
const hours = Math.floor(workMinutes / 60);
|
for (const record of dayRecords) {
|
||||||
const mins = workMinutes % 60;
|
const leaveType = (record.leave_type as string) || "work";
|
||||||
const breakCell =
|
const rowClasses = [
|
||||||
isLeave || !record.break_start || !record.break_end
|
weekend && "weekend",
|
||||||
? "—"
|
holiday && "holiday",
|
||||||
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
leaveType === "vacation" && "vacation",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
let hoursCell: string;
|
const isLeave = leaveType !== "work";
|
||||||
if (workMinutes > 0 && !isLeave) {
|
const workMinutes = calculateWorkMinutesPrint(record);
|
||||||
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
|
const hours = Math.floor(workMinutes / 60);
|
||||||
} else if (isLeave) {
|
const mins = workMinutes % 60;
|
||||||
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
|
const breakCell =
|
||||||
} else {
|
isLeave || !record.break_start || !record.break_end
|
||||||
hoursCell = "—";
|
? "—"
|
||||||
}
|
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
|
||||||
|
|
||||||
rows.push(`<tr class="${rowClasses}">
|
let hoursCell: string;
|
||||||
|
if (workMinutes > 0 && !isLeave) {
|
||||||
|
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
|
||||||
|
} else if (isLeave) {
|
||||||
|
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
|
||||||
|
} else {
|
||||||
|
hoursCell = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(`<tr class="${rowClasses}">
|
||||||
<td>${escapeHtml(formatDate(dateStr))}</td>
|
<td>${escapeHtml(formatDate(dateStr))}</td>
|
||||||
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
|
||||||
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
|
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
|
||||||
@@ -453,113 +402,52 @@ function buildUserSectionHtml(
|
|||||||
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
|
||||||
<td>${escapeHtml(record.notes || "")}</td>
|
<td>${escapeHtml(record.notes || "")}</td>
|
||||||
</tr>`);
|
</tr>`);
|
||||||
}
|
|
||||||
|
|
||||||
// ----- mzda-style summary numbers (computed here, not from userData,
|
|
||||||
// because the backend's getPrintData only returns the legacy fields). -----
|
|
||||||
|
|
||||||
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
|
|
||||||
let workedMinutes = 0;
|
|
||||||
let weekendHoursRaw = 0;
|
|
||||||
let nightMinutes = 0;
|
|
||||||
for (const r of userRecords) {
|
|
||||||
const leaveType = r.leave_type || "work";
|
|
||||||
if (leaveType !== "work") continue;
|
|
||||||
const m = calculateWorkMinutesPrint(r);
|
|
||||||
workedMinutes += m;
|
|
||||||
if (isWeekendDate(r.shift_date)) {
|
|
||||||
weekendHoursRaw += m / 60;
|
|
||||||
}
|
}
|
||||||
nightMinutes += calculateNightMinutes(r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
// Rekapitulace — single shared implementation (accountant spec 2026-07):
|
||||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
// Odpracováno = all worked hours EXCEPT hours worked on holidays
|
||||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
// Vč. svátků a přesč. = ALL worked hours (line2 − line1 = Svátek)
|
||||||
let vacationHours = 0,
|
// Přesčas = max(0, worked − fond)
|
||||||
sickHours = 0;
|
// Svátek = hours actually worked on holiday dates
|
||||||
for (const r of userRecords) {
|
const s = computeMzdaSummary(userRecords, printData.month);
|
||||||
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.
|
|
||||||
const workedHoursRaw = workedMinutes / 60;
|
|
||||||
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
.map((r) => normalizeDateStr(r.shift_date))
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
let freeHolidayHours = 0;
|
|
||||||
let workedHolidayCount = 0;
|
|
||||||
for (const hd of holidayDates) {
|
|
||||||
if (workedDates.has(hd)) workedHolidayCount++;
|
|
||||||
else freeHolidayHours += 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fund: count Mon-Fri (incl. holidays) × 8h.
|
|
||||||
let rawBizDays = 0;
|
|
||||||
const cur = new Date(yr, mo - 1, 1);
|
|
||||||
while (cur.getMonth() === mo - 1) {
|
|
||||||
const dow = cur.getDay();
|
|
||||||
if (dow !== 0 && dow !== 6) rawBizDays++;
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
const businessDays = rawBizDays;
|
|
||||||
const fund = businessDays * 8;
|
|
||||||
|
|
||||||
// Včetně svátků a přesčasů: floor(worked/8)*8 − worked_holiday*8 + vacation + remainder.
|
|
||||||
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
|
|
||||||
// into Přesčas — so we subtract it here. Unworked holidays are paid via
|
|
||||||
// the fund and don't affect this line.)
|
|
||||||
const remainder = workedHoursRaw - odpracovano;
|
|
||||||
const vcetneSv =
|
|
||||||
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
|
|
||||||
|
|
||||||
// Přesčas = #2 - #1.
|
|
||||||
const prescas = vcetneSv - odpracovano;
|
|
||||||
|
|
||||||
const summaryHtml = `<div class="user-summary">
|
const summaryHtml = `<div class="user-summary">
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno:</span>
|
<span class="summary-label">Odpracováno:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.odpracovano * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vcetneSvatku * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Dovolená:</span>
|
<span class="summary-label">Dovolená:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.vacationHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Přesčas:</span>
|
<span class="summary-label">Přesčas:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
|
||||||
|
<span class="summary-label">Chybějící hodiny:</span>
|
||||||
|
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Svátek:</span>
|
<span class="summary-label">Svátek:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">So/Ne:</span>
|
<span class="summary-label">So/Ne:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.weekendHours * 60)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<span class="summary-label">Práce v noci:</span>
|
<span class="summary-label">Práce v noci:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
|
<span class="summary-value">${formatHoursDecimal(s.nightMinutes)}h</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-row summary-fund">
|
<div class="summary-row summary-fund">
|
||||||
<span class="summary-label">Fond měsíce:</span>
|
<span class="summary-label">Fond měsíce:</span>
|
||||||
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
|
<span class="summary-value">${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend">
|
<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 weekend"></span>Víkend (So/Ne)</span>
|
||||||
@@ -592,8 +480,8 @@ function buildUserSectionHtml(
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrintHtml(
|
export function buildPrintHtml(
|
||||||
pData: Record<string, any>,
|
pData: PrintData,
|
||||||
userSections: string,
|
userSections: string,
|
||||||
emptyMsg: string,
|
emptyMsg: string,
|
||||||
filterNote: string,
|
filterNote: string,
|
||||||
@@ -665,6 +553,9 @@ function buildPrintHtml(
|
|||||||
}
|
}
|
||||||
.user-summary .summary-label { color: #555; }
|
.user-summary .summary-label { color: #555; }
|
||||||
.user-summary .summary-value { font-weight: 600; }
|
.user-summary .summary-value { font-weight: 600; }
|
||||||
|
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
|
||||||
|
.user-summary .summary-alert .summary-label,
|
||||||
|
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
|
||||||
.user-summary .summary-fund {
|
.user-summary .summary-fund {
|
||||||
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
|
||||||
}
|
}
|
||||||
@@ -1334,7 +1225,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
const pData = result.data;
|
const pData = result.data;
|
||||||
const userSections = Object.entries(pData.user_totals)
|
const userSections = Object.entries(pData.user_totals)
|
||||||
.map(([uid, uData]) =>
|
.map(([uid, uData]) =>
|
||||||
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
|
buildUserSectionHtml(uid, uData as UserTotal, pData),
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
const emptyMsg =
|
const emptyMsg =
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// Snapshot the currently-cached grid for a given key. Returns
|
// Snapshot the currently-cached grid for a given key. Returns
|
||||||
// `undefined` if there's nothing cached (we don't patch empty data).
|
// `undefined` if there's nothing cached (we don't patch empty data).
|
||||||
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
|
||||||
return qc.getQueryData<GridData>(key as any);
|
return qc.getQueryData<GridData>(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
// Apply a per-date cell patch to the cached grid at `key`. Returns the
|
||||||
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||||
function rollbackCells(ctx: CellRollback | undefined) {
|
function rollbackCells(ctx: CellRollback | undefined) {
|
||||||
if (!ctx || !ctx.rolled) return;
|
if (!ctx || !ctx.rolled) return;
|
||||||
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
const prev = qc.getQueryData<GridData>(ctx.key);
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
const userPrev = prev.cells[ctx.userId] ?? {};
|
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||||
const userNext = { ...userPrev };
|
const userNext = { ...userPrev };
|
||||||
@@ -379,7 +379,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// Find the user that owns this entry in the current grid by
|
// Find the user that owns this entry in the current grid by
|
||||||
// looking for any cell with entryId === id (we already know
|
// looking for any cell with entryId === id (we already know
|
||||||
// the id from vars; it doesn't change across the update).
|
// the id from vars; it doesn't change across the update).
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
let ownerUserId: number | null = null;
|
let ownerUserId: number | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
@@ -430,7 +430,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// For delete we need to know the entry's user_id and full range.
|
// For delete we need to know the entry's user_id and full range.
|
||||||
// Look it up from the current grid: find the user that has a cell
|
// Look it up from the current grid: find the user that has a cell
|
||||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
let entryRange: { from: string; to: string } | null = null;
|
let entryRange: { from: string; to: string } | null = null;
|
||||||
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
onMutate: (vars): CellRollback => {
|
onMutate: (vars): CellRollback => {
|
||||||
// Find the user/date for this overrideId in the current grid, then
|
// Find the user/date for this overrideId in the current grid, then
|
||||||
// patch that single cell with the new values.
|
// patch that single cell with the new values.
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
onMutate: (vars): CellRollback => {
|
onMutate: (vars): CellRollback => {
|
||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey);
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cells] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
@@ -600,7 +600,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
||||||
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||||
// restored the patched cells, so there is nothing left to do here.
|
// restored the patched cells, so there is nothing left to do here.
|
||||||
|
|
||||||
function rollbackMutation(_mutation: unknown, _userId: number) {
|
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||||
/* no-op — see onMutate/onError above */
|
/* no-op — see onMutate/onError above */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Single source of truth for document status labels (Czech) + chip colors
|
* Single source of truth for document status labels (Czech) + chip colors
|
||||||
* (MUI semantic) across the Offers / Invoices / Orders modules.
|
* (MUI semantic) across the Offers / Invoices / Orders / Projects modules.
|
||||||
*
|
*
|
||||||
* Previously every list AND detail page defined its own `STATUS_LABELS` +
|
* Previously every list AND detail page defined its own `STATUS_LABELS` +
|
||||||
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
|
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
|
||||||
@@ -46,7 +46,7 @@ export const ORDER_STATUS: Record<string, StatusMeta> = {
|
|||||||
prijata: { label: "Přijatá", color: "info" },
|
prijata: { label: "Přijatá", color: "info" },
|
||||||
v_realizaci: { label: "V realizaci", color: "warning" },
|
v_realizaci: { label: "V realizaci", color: "warning" },
|
||||||
dokoncena: { label: "Dokončená", color: "success" },
|
dokoncena: { label: "Dokončená", color: "success" },
|
||||||
stornovana: { label: "Stornována", color: "error" },
|
stornovana: { label: "Stornovaná", color: "error" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
|
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
|
||||||
@@ -75,6 +75,17 @@ export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
|
|||||||
paid: { label: "Uhrazena", color: "success" },
|
paid: { label: "Uhrazena", color: "success" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROJECT (projects). Czech-keyed like customer orders. Colors follow the
|
||||||
|
* app-wide convention: open/in-progress = info, done = success,
|
||||||
|
* cancelled = error.
|
||||||
|
*/
|
||||||
|
export const PROJECT_STATUS: Record<string, StatusMeta> = {
|
||||||
|
aktivni: { label: "Aktivní", color: "info" },
|
||||||
|
dokonceny: { label: "Dokončený", color: "success" },
|
||||||
|
zruseny: { label: "Zrušený", color: "error" },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display label for a document's official number. Drafts (deferred numbering)
|
* Display label for a document's official number. Drafts (deferred numbering)
|
||||||
* have a NULL/empty number until they are finalized — show "Koncept" instead
|
* have a NULL/empty number until they are finalized — show "Koncept" instead
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export interface BalancesData {
|
|||||||
export interface FundUserData {
|
export interface FundUserData {
|
||||||
name: string;
|
name: string;
|
||||||
worked: number;
|
worked: number;
|
||||||
|
/** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
|
||||||
covered: number;
|
covered: number;
|
||||||
|
/** worked + dovolená only — the Přesčas base (print-recap parity). */
|
||||||
|
covered_prescas: number;
|
||||||
overtime: number;
|
overtime: number;
|
||||||
missing: number;
|
missing: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
|
||||||
|
|
||||||
export const auditLogOptions = (filters: {
|
|
||||||
search?: string;
|
|
||||||
action?: string;
|
|
||||||
entityType?: string;
|
|
||||||
dateFrom?: string;
|
|
||||||
dateTo?: string;
|
|
||||||
page?: number;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["audit-log", filters],
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters.search) params.set("search", filters.search);
|
|
||||||
if (filters.action) params.set("action", filters.action);
|
|
||||||
if (filters.entityType) params.set("entity_type", filters.entityType);
|
|
||||||
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
|
|
||||||
if (filters.dateTo) params.set("date_to", filters.dateTo);
|
|
||||||
if (filters.page) params.set("page", String(filters.page));
|
|
||||||
const qs = params.toString();
|
|
||||||
return jsonQuery<{
|
|
||||||
data: Record<string, unknown>[];
|
|
||||||
pagination: Record<string, unknown>;
|
|
||||||
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -38,6 +38,7 @@ export interface InvoiceItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
vat_rate?: number;
|
vat_rate?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
@@ -84,6 +85,7 @@ export interface InvoiceDetail {
|
|||||||
bank_account_id?: number | null;
|
bank_account_id?: number | null;
|
||||||
items?: InvoiceItem[];
|
items?: InvoiceItem[];
|
||||||
sections?: InvoiceSection[];
|
sections?: InvoiceSection[];
|
||||||
|
selected_custom_fields?: number[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
vat_amount: number;
|
vat_amount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
sections: IssuedOrderSection[];
|
sections: IssuedOrderSection[];
|
||||||
supplier: Record<string, unknown> | null;
|
supplier: Record<string, unknown> | null;
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||||
locked_by: {
|
locked_by: {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -143,6 +144,9 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
|
|||||||
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
||||||
// GETs and keeps the detail page on an infinite spinner.
|
// GETs and keeps the detail page on an infinite spinner.
|
||||||
retry: false,
|
retry: false,
|
||||||
|
// IssuedOrderDetail toasts its own Czech error + navigates away (and
|
||||||
|
// suppresses it during delete) — the global QueryCache toast would double up.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const issuedOrderNextNumberOptions = () =>
|
export const issuedOrderNextNumberOptions = () =>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function performMutation<TIn, TOut>(opts: {
|
|||||||
return result.data as TOut;
|
return result.data as TOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiMutationOptions<TIn, TOut> {
|
export interface ApiMutationOptions<TIn> {
|
||||||
url: string | ((input: TIn) => string);
|
url: string | ((input: TIn) => string);
|
||||||
method: HttpMethod | ((input: TIn) => HttpMethod);
|
method: HttpMethod | ((input: TIn) => HttpMethod);
|
||||||
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
|
||||||
@@ -122,7 +122,7 @@ export interface ApiMutationOptions<TIn, TOut> {
|
|||||||
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
|
||||||
*/
|
*/
|
||||||
export function useApiMutation<TIn, TOut>(
|
export function useApiMutation<TIn, TOut>(
|
||||||
opts: ApiMutationOptions<TIn, TOut> &
|
opts: ApiMutationOptions<TIn> &
|
||||||
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
|
||||||
) {
|
) {
|
||||||
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface OfferItemData {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -183,6 +184,7 @@ export interface OfferDetailData {
|
|||||||
locked_by: OfferLockInfo | null;
|
locked_by: OfferLockInfo | null;
|
||||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const offerDetailOptions = (id: string | undefined) =>
|
export const offerDetailOptions = (id: string | undefined) =>
|
||||||
@@ -193,6 +195,9 @@ export const offerDetailOptions = (id: string | undefined) =>
|
|||||||
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
||||||
// GETs and fires the detail-page redirect toast repeatedly.
|
// GETs and fires the detail-page redirect toast repeatedly.
|
||||||
retry: false,
|
retry: false,
|
||||||
|
// OfferDetail toasts its own Czech error + navigates away (and suppresses
|
||||||
|
// it during delete) — the global QueryCache toast would double up.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const offerNextNumberOptions = () =>
|
export const offerNextNumberOptions = () =>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import apiFetch from "../../utils/api";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
import { jsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
export interface PlanUser {
|
export interface PlanUser {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
|
|||||||
export interface ProjectNote {
|
export interface ProjectNote {
|
||||||
id: number;
|
id: number;
|
||||||
content: string;
|
content: string;
|
||||||
|
user_id: number | null;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
edited_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
@@ -26,6 +28,8 @@ export interface ProjectData {
|
|||||||
quotation_number?: string;
|
quotation_number?: string;
|
||||||
has_nas_folder?: boolean;
|
has_nas_folder?: boolean;
|
||||||
project_notes?: ProjectNote[];
|
project_notes?: ProjectNote[];
|
||||||
|
/** Valid status-machine targets from the current status (server-computed). */
|
||||||
|
valid_transitions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ export interface Role {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-key domains to invalidate on any user create/update/delete: a user's
|
||||||
|
* display name is embedded in these domains (CLAUDE.md: "user CRUD →
|
||||||
|
* trips+attendance").
|
||||||
|
*/
|
||||||
|
export const USER_INVALIDATE: readonly string[] = [
|
||||||
|
"users",
|
||||||
|
"trips",
|
||||||
|
"attendance",
|
||||||
|
"leave-requests",
|
||||||
|
"leave",
|
||||||
|
"projects",
|
||||||
|
];
|
||||||
|
|
||||||
export const userListOptions = (permission?: string) =>
|
export const userListOptions = (permission?: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["users", { permission }],
|
queryKey: ["users", { permission }],
|
||||||
|
|||||||
@@ -1,6 +1,39 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global query-error notifier. AlertContext registers its `error()` toast
|
||||||
|
* here on mount; until then failures fall back to console.error.
|
||||||
|
*/
|
||||||
|
let queryErrorNotifier: ((msg: string) => void) | null = null;
|
||||||
|
|
||||||
|
export function registerQueryErrorNotifier(fn: (msg: string) => void) {
|
||||||
|
queryErrorNotifier = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate limit: at most one query-error toast per window (a page can have many failing queries). */
|
||||||
|
const ERROR_TOAST_WINDOW_MS = 4000;
|
||||||
|
let lastErrorToastAt = 0;
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error, query) => {
|
||||||
|
// Pages with bespoke error handling (detail pages that toast + navigate,
|
||||||
|
// purely-local queries like QR generation) opt out via query meta.
|
||||||
|
if (query.meta?.suppressGlobalErrorToast) return;
|
||||||
|
// Background refetch failure with stale data still on screen — stay silent.
|
||||||
|
if (query.state.data !== undefined) return;
|
||||||
|
// 401s are handled by apiFetch/AuthContext (token refresh / logout).
|
||||||
|
if (error instanceof Error && error.message === "Unauthorized") return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastErrorToastAt < ERROR_TOAST_WINDOW_MS) return;
|
||||||
|
lastErrorToastAt = now;
|
||||||
|
if (queryErrorNotifier) {
|
||||||
|
queryErrorNotifier("Nepodařilo se načíst data ze serveru");
|
||||||
|
} else {
|
||||||
|
console.error("Query failed before alert notifier registered:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -835,7 +835,7 @@ export default function Attendance() {
|
|||||||
onChange={(val) => handleSwitchProject(val || null)}
|
onChange={(val) => handleSwitchProject(val || null)}
|
||||||
disabled={switchingProject}
|
disabled={switchingProject}
|
||||||
options={[
|
options={[
|
||||||
{ value: "", label: "— Bez projektu —" },
|
{ value: "", label: "Vyberte projekt" },
|
||||||
...projects.map((p) => ({
|
...projects.map((p) => ({
|
||||||
value: String(p.id),
|
value: String(p.id),
|
||||||
label: `${p.project_number} – ${p.name}`,
|
label: `${p.project_number} – ${p.name}`,
|
||||||
|
|||||||
@@ -46,28 +46,29 @@ interface UserTotalData {
|
|||||||
prescas?: number;
|
prescas?: number;
|
||||||
svatek?: number;
|
svatek?: number;
|
||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
|
/** Chybějící hodiny: real clock time below the fond (approved absences count). */
|
||||||
|
manko?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fond "used" total mirrors the Fond footer line below:
|
/** Fond coverage — print-recap parity (computeMzdaSummary): real worked
|
||||||
* real_worked + vacation + worked_holiday + free_holiday. */
|
* hours + every approved absence (dovolená + nemoc + neplacené volno).
|
||||||
|
* This is the base the Chybějící hodiny line measures against. */
|
||||||
function getFondUsed(data: UserTotalData): number {
|
function getFondUsed(data: UserTotalData): number {
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||||
return (
|
return (
|
||||||
realWorked +
|
realWorked +
|
||||||
(data.vacation_hours ?? 0) +
|
(data.vacation_hours ?? 0) +
|
||||||
(data.worked_holiday_hours ?? 0) +
|
(data.sick_hours ?? 0) +
|
||||||
(data.svatek ?? 0)
|
(data.unpaid_hours ?? 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
|
||||||
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
* hodiny > 0 → error, exactly covered → success. */
|
||||||
* at fund → success, under fund → primary (was the accent gradient). */
|
|
||||||
function getFundBarColor(data: UserTotalData): ProgressColor {
|
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||||
const delta = getFondUsed(data) - (data.fund ?? 0);
|
if ((data.prescas ?? 0) > 0) return "warning";
|
||||||
if (delta > 0) return "warning";
|
if ((data.manko ?? 0) > 0) return "error";
|
||||||
if (delta >= 0) return "success";
|
return "success";
|
||||||
return "primary";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PrintIcon = (
|
const PrintIcon = (
|
||||||
@@ -293,17 +294,24 @@ export default function AttendanceAdmin() {
|
|||||||
label={`Nep: ${ut.unpaid_hours}h`}
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{ut.manko !== undefined && ut.manko > 0 && (
|
||||||
|
<StatusChip
|
||||||
|
color="error"
|
||||||
|
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
|
||||||
|
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Fond usage */}
|
{/* Fond usage — the ± badges are the PRINT recap values
|
||||||
|
(Přesčas / Chybějící hodiny), so the card and the
|
||||||
|
printed sheet can never disagree. */}
|
||||||
{ut.fund !== null &&
|
{ut.fund !== null &&
|
||||||
(() => {
|
(() => {
|
||||||
// Fond "used" = real_worked + vacation +
|
|
||||||
// worked_holiday + free_holiday (everything the user
|
|
||||||
// actually got paid for this month).
|
|
||||||
const fondUsed = getFondUsed(ut);
|
const fondUsed = getFondUsed(ut);
|
||||||
const fundVal = ut.fund ?? 0;
|
const fundVal = ut.fund ?? 0;
|
||||||
const delta = fondUsed - fundVal;
|
const prescas = ut.prescas ?? 0;
|
||||||
|
const manko = ut.manko ?? 0;
|
||||||
const pct = Math.min(
|
const pct = Math.min(
|
||||||
100,
|
100,
|
||||||
(fondUsed / (ut.fund || 1)) * 100,
|
(fondUsed / (ut.fund || 1)) * 100,
|
||||||
@@ -325,26 +333,28 @@ export default function AttendanceAdmin() {
|
|||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
{formatHoursDecimal(fundVal * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
{delta > 0 && (
|
{prescas > 0 && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
title="Přesčas (worked + dovolená nad fond)"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "warning.main",
|
color: "warning.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+{formatHoursDecimal(delta * 60)}h
|
+{formatHoursDecimal(prescas * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{delta < 0 && (
|
{manko > 0 && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
|
title="Chybějící hodiny do fondu"
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "error.main",
|
color: "error.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
-{formatHoursDecimal(manko * 60)}h
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user