Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.23",
|
"version": "2.4.43",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.23",
|
"version": "2.4.43",
|
||||||
"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.23",
|
"version": "2.4.43",
|
||||||
"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 {
|
||||||
|
|||||||
374
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
374
src/__tests__/__snapshots__/attendance-print-html.test.ts.snap
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`buildUserSectionHtml > produces a stable per-user section (full output pinned) 1`] = `
|
||||||
|
"<div class="user-section">
|
||||||
|
<div class="user-header">
|
||||||
|
<h3>Jan Novák</h3>
|
||||||
|
<span class="total">Odpracováno: 8:00 h</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:55px">Datum</th>
|
||||||
|
<th style="width:55px">Den</th>
|
||||||
|
<th style="width:60px">Typ</th>
|
||||||
|
<th class="text-center" style="width:55px">Příchod</th>
|
||||||
|
<th class="text-center" style="width:80px">Pauza</th>
|
||||||
|
<th class="text-center" style="width:55px">Odchod</th>
|
||||||
|
<th class="text-center" style="width:85px">Hodiny</th>
|
||||||
|
<th>Projekty</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody><tr class="weekend">
|
||||||
|
<td>1. 3. 2098</td>
|
||||||
|
<td>Sobota</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="weekend">
|
||||||
|
<td>2. 3. 2098</td>
|
||||||
|
<td>Neděle</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>3. 3. 2098</td>
|
||||||
|
<td>Pondělí</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td class="text-center">—</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr><tr class="">
|
||||||
|
<td>4. 3. 2098</td>
|
||||||
|
<td>Úterý</td>
|
||||||
|
<td>Práce</td>
|
||||||
|
<td class="text-center">08:00</td>
|
||||||
|
<td class="text-center">12:00 - 12:30</td>
|
||||||
|
<td class="text-center">16:30</td>
|
||||||
|
<td class="text-center">8:00 (8,00)</td>
|
||||||
|
<td style="font-size:8px"><div>Alpha (8:00h)</div></td>
|
||||||
|
<td>Poznámka <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">16,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Dovolená:</span>
|
||||||
|
<span class="summary-value">8,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Přesčas:</span>
|
||||||
|
<span class="summary-value">8,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Svátek:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">So/Ne:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row">
|
||||||
|
<span class="summary-label">Práce v noci:</span>
|
||||||
|
<span class="summary-value">0,00h</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-row summary-fund">
|
||||||
|
<span class="summary-label">Fond měsíce:</span>
|
||||||
|
<span class="summary-value">168,00h (21 dnů)</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
|
||||||
|
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>"
|
||||||
|
`;
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,9 +189,11 @@ beforeAll(async () => {
|
|||||||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||||
const FIX_RI_SUPPLIER = "AiTest Supplier 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 fixOfferId = 0;
|
||||||
let fixSupplierId = 0;
|
let fixSupplierId = 0;
|
||||||
let fixRiId = 0;
|
let fixRiId = 0;
|
||||||
|
let fixCustomerId = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.quotations.deleteMany({
|
await prisma.quotations.deleteMany({
|
||||||
@@ -205,6 +207,21 @@ beforeAll(async () => {
|
|||||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||||
});
|
});
|
||||||
fixSupplierId = sup.id;
|
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({
|
const ri = await prisma.received_invoices.create({
|
||||||
data: {
|
data: {
|
||||||
month: 6,
|
month: 6,
|
||||||
@@ -265,6 +282,9 @@ afterAll(async () => {
|
|||||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
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 () => {
|
||||||
@@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||||
expect(s).toMatchObject({ ico: "99999998" });
|
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(
|
const veh = await executeTool(
|
||||||
"list_vehicles",
|
"list_vehicles",
|
||||||
{ search: FIX.spz },
|
{ search: FIX.spz },
|
||||||
@@ -669,6 +701,25 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
// 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 });
|
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(
|
expect(
|
||||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -714,6 +765,78 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_received_invoice_detail",
|
"get_received_invoice_detail",
|
||||||
|
|||||||
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type {
|
|||||||
ProjectLog,
|
ProjectLog,
|
||||||
Project,
|
Project,
|
||||||
User,
|
User,
|
||||||
} from "../components/ShiftFormModal";
|
} from "../components/shiftFormTypes";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -234,7 +234,6 @@ function computeUserTotals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const holidayDates = holidaysInMonth(yr, mo + 1);
|
const holidayDates = holidaysInMonth(yr, mo + 1);
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
|
|
||||||
for (const uid of Object.keys(totals)) {
|
for (const uid of Object.keys(totals)) {
|
||||||
const t = totals[uid];
|
const t = totals[uid];
|
||||||
@@ -345,14 +344,25 @@ function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, "'");
|
.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,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
|
|||||||
return escapeHtml(record.project_name || "—");
|
return escapeHtml(record.project_name || "—");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserSectionHtml(
|
export function buildUserSectionHtml(
|
||||||
userId: string,
|
userId: string,
|
||||||
userData: Record<string, any>,
|
userData: UserTotal,
|
||||||
printData: Record<string, any>,
|
printData: PrintData,
|
||||||
): string {
|
): string {
|
||||||
// Build a date-keyed lookup of the user's records.
|
// Build a date-keyed lookup of the user's records.
|
||||||
const recordsByDate = new Map<string, Record<string, any>>();
|
const recordsByDate = new Map<string, AttendanceRecord>();
|
||||||
for (const r of userData.records || []) {
|
for (const r of userData.records || []) {
|
||||||
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
recordsByDate.set(normalizeDateStr(r.shift_date), r);
|
||||||
}
|
}
|
||||||
@@ -392,7 +402,7 @@ function buildUserSectionHtml(
|
|||||||
const mo = parseInt(monthStr, 10);
|
const mo = parseInt(monthStr, 10);
|
||||||
const daysInMonth = getDaysInMonth(yr, mo);
|
const daysInMonth = getDaysInMonth(yr, mo);
|
||||||
|
|
||||||
const userRecords = (userData.records || []) as Record<string, any>[];
|
const userRecords = userData.records ?? [];
|
||||||
|
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
@@ -476,14 +486,12 @@ function buildUserSectionHtml(
|
|||||||
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
|
||||||
// "holiday" leave_type is auto-computed from Czech holidays further down
|
// "holiday" leave_type is auto-computed from Czech holidays further down
|
||||||
// and no longer selectable in the UI, so it's deliberately omitted here.
|
// and no longer selectable in the UI, so it's deliberately omitted here.
|
||||||
let vacationHours = 0,
|
let vacationHours = 0;
|
||||||
sickHours = 0;
|
|
||||||
for (const r of userRecords) {
|
for (const r of userRecords) {
|
||||||
const lt = r.leave_type || "work";
|
const lt = r.leave_type || "work";
|
||||||
if (lt === "work") continue;
|
if (lt === "work") continue;
|
||||||
const h = Number(r.leave_hours) || 8;
|
const h = Number(r.leave_hours) || 8;
|
||||||
if (lt === "vacation") vacationHours += h;
|
if (lt === "vacation") vacationHours += h;
|
||||||
else if (lt === "sick") sickHours += h;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Odpracováno: floor(worked / 8) × 8.
|
// Odpracováno: floor(worked / 8) × 8.
|
||||||
@@ -492,7 +500,6 @@ function buildUserSectionHtml(
|
|||||||
|
|
||||||
// Free Svátek: 8h per holiday date the user did not work.
|
// Free Svátek: 8h per holiday date the user did not work.
|
||||||
const holidayDates = holidaysInMonth(yr, mo);
|
const holidayDates = holidaysInMonth(yr, mo);
|
||||||
const holidayCount = holidayDates.length;
|
|
||||||
const workedDates = new Set(
|
const workedDates = new Set(
|
||||||
userRecords
|
userRecords
|
||||||
.filter((r) => (r.leave_type || "work") === "work")
|
.filter((r) => (r.leave_type || "work") === "work")
|
||||||
@@ -592,8 +599,8 @@ function buildUserSectionHtml(
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrintHtml(
|
export function buildPrintHtml(
|
||||||
pData: Record<string, any>,
|
pData: PrintData,
|
||||||
userSections: string,
|
userSections: string,
|
||||||
emptyMsg: string,
|
emptyMsg: string,
|
||||||
filterNote: string,
|
filterNote: string,
|
||||||
@@ -1334,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
const pData = result.data;
|
const pData = result.data;
|
||||||
const userSections = Object.entries(pData.user_totals)
|
const userSections = Object.entries(pData.user_totals)
|
||||||
.map(([uid, uData]) =>
|
.map(([uid, uData]) =>
|
||||||
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
|
buildUserSectionHtml(uid, uData as UserTotal, pData),
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
const emptyMsg =
|
const emptyMsg =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -9,7 +9,6 @@ import { iconBadgeSx } from "../theme";
|
|||||||
import {
|
import {
|
||||||
attendanceHistoryOptions,
|
attendanceHistoryOptions,
|
||||||
type AttendanceRecord,
|
type AttendanceRecord,
|
||||||
type ProjectLogEntry,
|
|
||||||
} from "../lib/queries/attendance";
|
} from "../lib/queries/attendance";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -201,7 +200,6 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
|
|
||||||
export default function AttendanceHistory() {
|
export default function AttendanceHistory() {
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { data: companySettings } = useQuery(companySettingsOptions());
|
const { data: companySettings } = useQuery(companySettingsOptions());
|
||||||
const companyName = companySettings?.company_name || "";
|
const companyName = companySettings?.company_name || "";
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -212,7 +210,7 @@ export default function AttendanceHistory() {
|
|||||||
const { data, isPending } = useQuery(
|
const { data, isPending } = useQuery(
|
||||||
attendanceHistoryOptions({ month, userId: user?.id }),
|
attendanceHistoryOptions({ month, userId: user?.id }),
|
||||||
);
|
);
|
||||||
const records = data ?? [];
|
const records = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
const computed = useMemo(() => {
|
const computed = useMemo(() => {
|
||||||
const [yearStr, monthStr] = month.split("-");
|
const [yearStr, monthStr] = month.split("-");
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ const LocationMap = styled("div")(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||||
import {
|
import { attendanceLocationOptions } from "../lib/queries/attendance";
|
||||||
attendanceLocationOptions,
|
|
||||||
type LocationRecord,
|
|
||||||
} from "../lib/queries/attendance";
|
|
||||||
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||||
|
|
||||||
const BackIcon = (
|
const BackIcon = (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { czechPlural } from "../utils/formatters";
|
import { czechPlural } from "../utils/formatters";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||||
@@ -122,6 +123,9 @@ export default function AuditLog() {
|
|||||||
date_from: "",
|
date_from: "",
|
||||||
date_to: "",
|
date_to: "",
|
||||||
});
|
});
|
||||||
|
// Raw value stays in the TextField; only the debounced value hits the
|
||||||
|
// queryKey/request, so typing doesn't refire the query on every keystroke.
|
||||||
|
const debouncedSearch = useDebounce(filters.search, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage] = useState(50);
|
const [perPage] = useState(50);
|
||||||
const [showCleanup, setShowCleanup] = useState(false);
|
const [showCleanup, setShowCleanup] = useState(false);
|
||||||
@@ -136,7 +140,7 @@ export default function AuditLog() {
|
|||||||
queryKey: [
|
queryKey: [
|
||||||
"audit-log",
|
"audit-log",
|
||||||
{
|
{
|
||||||
search: filters.search,
|
search: debouncedSearch,
|
||||||
action: filters.action,
|
action: filters.action,
|
||||||
entityType: filters.entity_type,
|
entityType: filters.entity_type,
|
||||||
dateFrom: filters.date_from,
|
dateFrom: filters.date_from,
|
||||||
@@ -150,7 +154,7 @@ export default function AuditLog() {
|
|||||||
page: String(page),
|
page: String(page),
|
||||||
per_page: String(perPage),
|
per_page: String(perPage),
|
||||||
});
|
});
|
||||||
if (filters.search) params.set("search", filters.search);
|
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||||
if (filters.action) params.set("action", filters.action);
|
if (filters.action) params.set("action", filters.action);
|
||||||
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
||||||
if (filters.date_from) params.set("date_from", filters.date_from);
|
if (filters.date_from) params.set("date_from", filters.date_from);
|
||||||
@@ -177,6 +181,12 @@ export default function AuditLog() {
|
|||||||
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||||
// whole entrance animation on every filter change).
|
// whole entrance animation on every filter change).
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
// logAudit() writes on every mutation app-wide and none of those
|
||||||
|
// mutations invalidate ["audit-log"], so cached data goes stale the
|
||||||
|
// moment anything happens elsewhere — always refetch on mount (same
|
||||||
|
// rationale as dashboardOptions).
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user