Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 | ||
|
|
4674ff3389 | ||
|
|
4bb7de7247 | ||
|
|
719d1f2659 | ||
|
|
3787cc4dd0 | ||
|
|
1ee59b54bc | ||
|
|
907ee574e6 | ||
|
|
b51ea2505e | ||
|
|
fd2e613aa5 | ||
|
|
1f2aff8325 | ||
|
|
e11765bf0e | ||
|
|
8f74efba97 | ||
|
|
b9103c59f0 | ||
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 | ||
|
|
164ce54adf | ||
|
|
6877919133 | ||
|
|
5ec70599aa | ||
|
|
7e4469b29a | ||
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 | ||
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 | ||
|
|
5b380863cf | ||
|
|
1293da7f25 | ||
|
|
d9cf8f53e8 | ||
|
|
f509e24942 | ||
|
|
4159ae57a4 | ||
|
|
4c8ed39d85 | ||
|
|
06519d521f | ||
|
|
3c6d175857 | ||
|
|
7582cf88f3 | ||
|
|
4deabbfcc0 | ||
|
|
302a0f8b3f | ||
|
|
78f39e9f82 | ||
|
|
949bf6c86f | ||
|
|
c8fc662552 | ||
|
|
10c8454c9b | ||
|
|
9496944ad2 | ||
|
|
608ebb02bd | ||
|
|
3a3485d029 | ||
|
|
674b44d047 | ||
|
|
2dbacc3bec | ||
|
|
c21f02e5d1 | ||
|
|
f87e110359 | ||
|
|
f1b1329c1b | ||
|
|
88d5d43448 | ||
|
|
4745af3639 | ||
|
|
4258699b73 | ||
|
|
bfd2c59ad3 | ||
|
|
0b69dbfde0 | ||
|
|
db7a5c3d15 | ||
|
|
f1ce76d21d | ||
|
|
575c07aac8 | ||
|
|
4bf245d35c | ||
|
|
79d026e756 | ||
|
|
40a859f5e1 | ||
|
|
ffae1a4e74 | ||
|
|
5c9ebddf50 | ||
|
|
f268907848 | ||
|
|
dd8c699420 | ||
|
|
268a947c85 | ||
|
|
75dc97516e | ||
|
|
d1533ffc4d | ||
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 |
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.
|
||||||
767
CLAUDE.md
767
CLAUDE.md
@@ -1,25 +1,26 @@
|
|||||||
# CLAUDE.md — boha-app-ts
|
# CLAUDE.md — boha-app-ts
|
||||||
|
|
||||||
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
||||||
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
|
Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
|
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
|
| Runtime | Node.js, TypeScript (strict, all tsconfigs) |
|
||||||
| HTTP Framework | Fastify 5.8.2 |
|
| HTTP Framework | Fastify 5 |
|
||||||
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
|
| ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL |
|
||||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||||
| Validation | Zod 4.3.6 |
|
| Validation | Zod 4 |
|
||||||
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) |
|
| Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
|
||||||
| Testing | Vitest 4.1.0 + Supertest |
|
| Testing | Vitest + Supertest against a real `app_test` DB |
|
||||||
| PDF | Puppeteer 24.x |
|
| PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
|
||||||
| Email | nodemailer 8.x |
|
| Email / Cron | nodemailer / node-cron |
|
||||||
| Cron | node-cron 4.x |
|
| AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
|
||||||
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
|
|
||||||
|
(Exact versions live in `package.json` — don't trust any pinned here.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,34 +28,28 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── server.ts # Fastify server entry point — plugins, routes, error handler
|
├── server.ts # Fastify entry — plugins, routes, error handler
|
||||||
├── routes/admin/ # HTTP route handlers (one file per entity)
|
├── routes/admin/ # HTTP route handlers (one file per entity)
|
||||||
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
|
├── services/ # Business logic (no classes, exported functions, own Prisma)
|
||||||
├── schemas/ # Zod validation schemas (one file per entity)
|
├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
|
||||||
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
|
├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
|
||||||
│ # security.ts (CSP, HSTS, security headers)
|
├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
|
||||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
|
||||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
|
||||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
├── admin/ # React 19 + MUI v7 frontend
|
||||||
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
|
|
||||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||||
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
│ ├── theme.ts / GlobalStyles.tsx
|
||||||
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
|
│ ├── ui/ # MUI component kit — pages import from here
|
||||||
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
|
│ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
|
||||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
|
|
||||||
│ ├── pages/ # One file per page/feature
|
│ ├── pages/ # One file per page/feature
|
||||||
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
|
│ ├── lib/queries/ # React Query options + useApiMutation
|
||||||
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
|
│ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
|
||||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
│ └── utils/ # api.ts (apiFetch with token refresh), formatters
|
||||||
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
|
└── __tests__/ # Vitest suites (server-side only; real app_test DB)
|
||||||
|
|
||||||
prisma/
|
prisma/ # schema.prisma (snake_case columns) + migrations/
|
||||||
├── schema.prisma # 52 models, MySQL, snake_case columns
|
dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
|
||||||
└── migrations/ # Applied migrations
|
|
||||||
|
|
||||||
dist/ # Compiled server (CommonJS, ES2022)
|
|
||||||
dist-client/ # Built frontend (Vite, ES2020)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -62,508 +57,380 @@ dist-client/ # Built frontend (Vite, ES2020)
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development
|
npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
|
||||||
npm run dev # Starts server in watch mode (manage frontend separately)
|
npm run build # server + client
|
||||||
npm run dev:server # tsx watch src/server.ts
|
npm test # vitest run — against app_test via .env.test
|
||||||
npm run dev:client # Vite dev server
|
npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing)
|
||||||
|
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 0 errors
|
||||||
# Build
|
|
||||||
npm run build # Build server + client
|
|
||||||
npm run build:server # tsc -p tsconfig.server.json → dist/
|
|
||||||
npm run build:client # vite build → dist-client/
|
|
||||||
|
|
||||||
# Run (production)
|
|
||||||
npm start # node dist/server.js
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
npm test # vitest run (single pass) — runs against the app_test DB via .env.test
|
|
||||||
npm run test:watch # vitest watch
|
|
||||||
|
|
||||||
# Quality gates
|
|
||||||
npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
|
|
||||||
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
|
|
||||||
npm run format # prettier --write .
|
npm run format # prettier --write .
|
||||||
|
npx prisma generate # after every schema change (client is not committed)
|
||||||
# Database
|
npx prisma studio # DB browser
|
||||||
npx prisma migrate dev # Create migration from schema changes + apply to dev
|
|
||||||
npx prisma migrate dev --name <descriptive_name> # Named migration
|
|
||||||
npx prisma migrate deploy # Apply pending migrations to production
|
|
||||||
npx prisma generate # Regenerate Prisma client after schema changes
|
|
||||||
npx prisma studio # DB browser GUI
|
|
||||||
npx prisma db seed # (Re)seed the dev database
|
|
||||||
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
|
|
||||||
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Do not start the dev server.** The user manages it separately.
|
**Do not start the dev server.** The user manages it separately.
|
||||||
|
|
||||||
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
|
**Before any migration, ask the user to stop their dev server and wait for
|
||||||
|
confirmation** (the running server holds DB connections).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Required:
|
Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
|
||||||
|
|
||||||
```
|
Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
|
||||||
DATABASE_URL=mysql://user:pass@host:3306/dbname
|
hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
|
||||||
JWT_SECRET=<64-char hex string>
|
`ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
|
||||||
TOTP_ENCRYPTION_KEY=<64-char hex string>
|
`REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files),
|
||||||
```
|
`NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees),
|
||||||
|
`MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`,
|
||||||
|
`APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it).
|
||||||
|
|
||||||
Optional (with defaults):
|
`.env` for dev, `.env.test` for tests (both gitignored — **never edit env
|
||||||
|
files; the user manages them**).
|
||||||
```
|
|
||||||
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
|
||||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
|
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
|
|
||||||
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
|
|
||||||
MAX_UPLOAD_SIZE=52428800 # 50MB
|
|
||||||
CONTACT_EMAIL_TO=
|
|
||||||
CONTACT_EMAIL_FROM=
|
|
||||||
SMTP_FROM=
|
|
||||||
LEAVE_NOTIFY_EMAIL=
|
|
||||||
APP_URL= # Used in email links
|
|
||||||
CORS_ORIGINS= # Comma-separated, production only
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `.env` for dev, `.env.test` for tests.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture & Key Patterns
|
## Architecture & Key Patterns
|
||||||
|
|
||||||
### Request Flow
|
### Request flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Request → CORS → Cookie → Rate-limit → Security headers
|
Request → CORS → Cookie → Rate-limit → Security headers
|
||||||
→ requirePermission() or requireAuth()
|
→ requirePermission() / requireAnyPermission()
|
||||||
→ Zod schema validation (parseBody helper)
|
→ parseBody(Schema) / parseId(param)
|
||||||
→ Route handler
|
→ Route handler → Service function → Prisma
|
||||||
→ Service function
|
→ success(reply, data) | error(reply, czechMessage, status)
|
||||||
→ Prisma
|
|
||||||
→ success(reply, data) or error(reply, message, status)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Response Format
|
### Response format
|
||||||
|
|
||||||
All responses use this shape:
|
`{ success: true, data, message?, pagination? }` on success,
|
||||||
|
`{ success: false, error }` on failure. Always the `success()`/`error()`/
|
||||||
|
paginated helpers — never raw `reply.send()`.
|
||||||
|
|
||||||
```typescript
|
### Services
|
||||||
// Success
|
|
||||||
{ success: true, data: T, message?: string, pagination?: {...} }
|
|
||||||
|
|
||||||
// Error
|
Plain exported async functions; services own Prisma, routes stay thin.
|
||||||
{ success: false, error: string }
|
Preferred error shape: return **token literals** (`"not_found"`,
|
||||||
```
|
`"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map
|
||||||
|
tokens to Czech messages + HTTP codes (the offers/issued-orders modules are
|
||||||
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
|
the reference). Legacy services return `{ error: czech, status }` — fine to
|
||||||
|
keep until touched; never the discriminated-union style. Respect soft-delete
|
||||||
### Service Pattern
|
(`is_deleted: false`) in reads where the model has it.
|
||||||
|
|
||||||
Services are plain exported async functions, no classes:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/services/foo.service.ts
|
|
||||||
export async function getFoo(id: number) {
|
|
||||||
const result = await prisma.foo.findUnique({ where: { id } });
|
|
||||||
if (!result) return { error: "Not found", status: 404 };
|
|
||||||
return { data: result };
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/routes/admin/foo.ts
|
|
||||||
const result = await getFoo(id);
|
|
||||||
if ("error" in result) return error(reply, result.error, result.status ?? 400);
|
|
||||||
return success(reply, result.data);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Routes map service errors to HTTP responses using the pattern above.
|
|
||||||
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
|
|
||||||
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
|
|
||||||
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
|
|
||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
|
||||||
```typescript
|
`requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
|
||||||
// Route-level guard
|
role bypasses checks (shortcut: `authData.roleName === "admin"` —
|
||||||
fastify.addHook("preHandler", requirePermission("invoices.view"));
|
⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
|
||||||
// or multiple
|
GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
|
||||||
fastify.addHook(
|
(bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
|
||||||
"preHandler",
|
permission opens pages read-only; an _edit_ permission unlocks fields and
|
||||||
requirePermission("invoices.view", "invoices.edit"),
|
save buttons.
|
||||||
);
|
|
||||||
|
|
||||||
// Admin role bypasses all permission checks
|
### Audit
|
||||||
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audit Logging
|
`logAudit()` on every create/update/delete (and security-relevant actions)
|
||||||
|
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
|
||||||
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
|
descriptions for unnumbered drafts. Audit failures are non-fatal.
|
||||||
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const body = parseBody(FooSchema, request.body);
|
|
||||||
if ("error" in body) return error(reply, body.error, 400);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Date & Timezone Handling (Critical Gotcha)
|
## Dates & Timezones (Critical — three rules)
|
||||||
|
|
||||||
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
|
1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
|
||||||
`Date.prototype.toJSON()` to return local time (not UTC). This means:
|
monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration
|
||||||
|
parity). All API date strings are local. Never assume UTC; never manually
|
||||||
|
offset. Do not remove the override.
|
||||||
|
2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date
|
||||||
|
part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of
|
||||||
|
the _previous_ day) silently shifts the queried window a day back — this was
|
||||||
|
a 14-site production bug class. Build `@db.Date` filter boundaries and
|
||||||
|
"today" writes as UTC-midnight instants of the LOCAL calendar day:
|
||||||
|
`new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from
|
||||||
|
`src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare
|
||||||
|
`new Date()` into a `@db.Date` column (stores yesterday between 00:00–02:00
|
||||||
|
Prague).
|
||||||
|
3. **Two deliberate write regimes — don't "fix" either:** the plan module
|
||||||
|
does date-only math in UTC; attendance/leave writes `@db.Date` at **local
|
||||||
|
noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from
|
||||||
|
`localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) —
|
||||||
|
**never** `new Date().toISOString().split("T")[0]` (UTC date, a day early
|
||||||
|
in the Prague evening).
|
||||||
|
|
||||||
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
|
---
|
||||||
- All API responses with Date fields will contain local time strings.
|
|
||||||
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
|
## Document Modules (offers · orders · issued orders · invoices)
|
||||||
- **Never assume UTC** when working with Date objects in this codebase.
|
|
||||||
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
|
**Business rules (user/accountant decisions — do not revert):**
|
||||||
- The override exists for PHP migration compatibility and Czech date display.
|
|
||||||
|
- **VAT exists ONLY on invoices and received invoices.** Offers, order
|
||||||
|
confirmations and issued orders are NOT tax documents: net prices only, one
|
||||||
|
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
|
||||||
|
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
|
||||||
|
from an order prefill the company default VAT rate.
|
||||||
|
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
|
||||||
|
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
|
||||||
|
offers take customers. Issued orders share the `orders._` permission family
|
||||||
|
(deliberate).
|
||||||
|
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
|
||||||
|
design; invoices + issued orders share the red-accent (#de3a3a) family.
|
||||||
|
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
|
||||||
|
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
|
||||||
|
orders deliberately have NO scope-template picker, NO item templates, NO
|
||||||
|
duplicate action.
|
||||||
|
|
||||||
|
**Platform conventions:**
|
||||||
|
|
||||||
|
- **Deferred numbering:** drafts carry a NULL document number
|
||||||
|
(nullable-unique column); the sequence number is consumed in-transaction on
|
||||||
|
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
|
||||||
|
released-if-highest on delete. Never hardcode numbering; previews use the
|
||||||
|
collision-advancing helpers.
|
||||||
|
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
|
||||||
|
responses return `valid_transitions` and the UI renders transition buttons
|
||||||
|
from it. Field edits outside the editable states (offers: draft/active;
|
||||||
|
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
|
||||||
|
payloads still pass.
|
||||||
|
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
|
||||||
|
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
|
||||||
|
enriches `locked_by {user_id, username, full_name}` only when fresh and
|
||||||
|
held by another user; client side is the shared `useDocumentLock` hook +
|
||||||
|
`LockBanner`.
|
||||||
|
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
|
||||||
|
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
|
||||||
|
so the UI hides PDF buttons on drafts. Numbered-document archives write to
|
||||||
|
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||||
|
suffixes. The issued-order PDF gets language from the document's `language`
|
||||||
|
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||||
|
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating-
|
||||||
|
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:**
|
||||||
|
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||||
|
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||||
|
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
|
||||||
|
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
|
||||||
|
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
|
||||||
|
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
|
||||||
|
diacritics in headers make Node throw 500s).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TOTP / 2FA
|
## TOTP / 2FA
|
||||||
|
|
||||||
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
|
Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
|
||||||
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
|
hex formats both supported); encrypted backup codes with their own
|
||||||
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
|
`/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
|
||||||
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
|
enrollment. Login flow: password → (if enrolled) single-use 5-min
|
||||||
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
|
`loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
|
||||||
|
QR is generated locally with the `qrcode` package (never an external QR URL —
|
||||||
|
CSP blocks it and it would leak the secret).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
|
- The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
|
||||||
|
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
|
||||||
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy` → `DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`.
|
unless `DATABASE_URL` names a `*test*` database.
|
||||||
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
|
- **After any schema change, apply migrations to the test DB too** or the
|
||||||
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
|
suite fails: set `DATABASE_URL` to the app_test URL and run
|
||||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
`npx prisma migrate deploy`.
|
||||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
|
- To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
|
||||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
|
||||||
|
`npx tsx prisma/seed.ts`.
|
||||||
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
|
- **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
|
||||||
|
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
|
||||||
|
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
|
||||||
|
sequence deadlocks. Tests are type-checked by `tsc -b`.
|
||||||
|
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
|
||||||
|
cleanup; FK-safe deletion order. New features get tests in
|
||||||
|
`src/__tests__/<feature>.test.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Frontend Conventions
|
## Frontend Conventions
|
||||||
|
|
||||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
- Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
|
||||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
toasts via `useAlert()`.
|
||||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
- **React Query for all fetching** (query options live in
|
||||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
`src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
|
||||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
|
||||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
server message through). `apiFetch` refreshes tokens proactively.
|
||||||
|
- **Rules of Hooks:** every hook runs BEFORE any early return — the
|
||||||
|
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
|
||||||
|
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
|
||||||
|
— prefix matching covers sub-queries), and a mutation invalidates every
|
||||||
|
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
|
||||||
|
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
|
||||||
|
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
|
||||||
|
still must invalidate their domains.
|
||||||
|
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
|
||||||
|
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
|
||||||
|
DB. Don't duplicate label maps.
|
||||||
|
|
||||||
### Query Invalidation Convention
|
### Styling (MUI v7 — no custom .css, no Tailwind)
|
||||||
|
|
||||||
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
|
- Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
|
||||||
|
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/` —
|
||||||
- `["users"]` over `["users", "list"]`
|
reach for raw `@mui/material` only for one-off layout/infra.
|
||||||
- `["trips"]` over `["trips", "vehicles"]`
|
- Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
|
||||||
|
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
|
||||||
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
|
one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
|
||||||
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
|
- Don't regress: status row tints are channel-alpha washes (never `.light`
|
||||||
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
|
fills — invisible text in dark mode); icon badges are solid `X.main` +
|
||||||
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
|
white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
|
||||||
|
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
|
||||||
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
|
`useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
|
||||||
|
fade; ConfirmDialogs stay open with `loading` during their request.
|
||||||
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
|
- When refactoring pages, **preserve ALL data logic verbatim** (hooks,
|
||||||
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
|
mutations, invalidate arrays, validation, permissions).
|
||||||
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
## AI Assistant — "Odin"
|
||||||
|
|
||||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
`ai.use`-gated Claude assistant at `/odin` (granted to admin). **Phase 2a:
|
||||||
|
read-only agentic assistant** — chat turns run an agentic loop
|
||||||
|
(`agenticChat` in `src/services/ai.service.ts`, max 6 round-trips, budget
|
||||||
|
re-checked between iterations) over the read-only tools in
|
||||||
|
`src/services/ai-tools.ts` (invoices, offers, orders, projects, customers,
|
||||||
|
warehouse, attendance).
|
||||||
|
|
||||||
**Where styling lives**
|
**Security model (do not weaken):** Odin is the **user's delegate** — the
|
||||||
|
tool list is pre-filtered by the caller's permissions AND every handler
|
||||||
|
re-checks them (`ctxCan`); there are **NO write tools** (writes come in
|
||||||
|
Phase 2b as propose→confirm action cards, never autonomous). Tool handlers
|
||||||
|
call the same service functions the routes use. Assistant turns persist
|
||||||
|
their tool trace in `ai_chat_messages.content_json` (plain `content` stays
|
||||||
|
the display text); the UI shows consulted-tool chips. The system prompt
|
||||||
|
enforces plain-text replies (the chat renders pre-wrap text, no Markdown).
|
||||||
|
|
||||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when
|
||||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured
|
||||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
output) → review cards → existing `POST /received-invoices`.
|
||||||
|
**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is
|
||||||
**Building / editing pages**
|
back-calculated (`vatFromGross`). Phase-2 design notes live in
|
||||||
|
`docs/superpowers/specs/`.
|
||||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
|
||||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
|
||||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
|
||||||
|
|
||||||
**Conventions learned — don't regress**
|
|
||||||
|
|
||||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
|
||||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
|
||||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
|
||||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
|
||||||
|
|
||||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AI Assistant — "Odin" (shipped v2.1.5)
|
## Database & Migrations
|
||||||
|
|
||||||
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase.
|
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
|
||||||
|
soft-delete via `is_deleted` where present; `number_sequences` owns all
|
||||||
|
document numbering.
|
||||||
|
|
||||||
- **SDK / model:** `@anthropic-ai/sdk` → `claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`).
|
**Golden rules:**
|
||||||
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
|
|
||||||
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
|
|
||||||
- **Frontend:** `src/admin/pages/Odin.tsx` → `src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces`→`Newsreader` font added to `index.html`.
|
|
||||||
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
|
|
||||||
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
|
|
||||||
|
|
||||||
---
|
- **NEVER `prisma db push`** — it bypasses migration history and causes drift.
|
||||||
|
- **Every DB change is a tracked migration** — schema, permission/seed rows,
|
||||||
|
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
|
||||||
|
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
|
||||||
|
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
|
||||||
|
`APP_ENV=production`) — prod baselines belong in migrations.
|
||||||
|
|
||||||
## Database Conventions
|
**Making a schema change (this shell is non-interactive — `prisma migrate
|
||||||
|
dev` will refuse to run; use this recipe):**
|
||||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
|
||||||
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
|
|
||||||
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
|
|
||||||
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
|
|
||||||
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Migrations (Critical Workflow)
|
|
||||||
|
|
||||||
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
|
|
||||||
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
|
|
||||||
|
|
||||||
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
|
|
||||||
|
|
||||||
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
|
|
||||||
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
|
|
||||||
- Lookup data, default settings, or any other row-level baseline that production needs
|
|
||||||
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
|
|
||||||
|
|
||||||
**What is NOT acceptable:**
|
|
||||||
|
|
||||||
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
|
|
||||||
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
|
|
||||||
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
|
|
||||||
|
|
||||||
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
|
|
||||||
|
|
||||||
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
|
|
||||||
|
|
||||||
### Making schema changes
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Edit prisma/schema.prisma
|
|
||||||
2. npx prisma migrate dev --name descriptive_name
|
|
||||||
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
|
|
||||||
3. npx prisma generate
|
|
||||||
4. Commit BOTH schema.prisma AND the new migration folder
|
|
||||||
git add prisma/schema.prisma prisma/migrations/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verifying before production deploy
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Preview what SQL will run on production (no changes applied)
|
# 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
|
||||||
npx prisma migrate diff \
|
# 2. Generate the migration SQL into a new folder:
|
||||||
--from-url "mysql://user:pass@prod:3306/app" \
|
mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
|
||||||
--to-schema-datamodel prisma/schema.prisma \
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
--script
|
> prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
|
||||||
|
# 3. Review the SQL, then apply + regenerate:
|
||||||
# Empty output = no diff. If it shows SQL, review it before deploying.
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
# 4. Apply to the test DB as well (suite breaks otherwise):
|
||||||
|
# DATABASE_URL=<app_test url> npx prisma migrate deploy
|
||||||
|
# 5. Commit schema.prisma AND the migration folder together.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploying migrations to production
|
Deployment, drift checks, hotfix-migration shipping and baselining:
|
||||||
|
see **`docs/release.md`**.
|
||||||
The release process runs `prisma migrate deploy` on production.
|
|
||||||
This applies only pending migrations — safe, idempotent.
|
|
||||||
|
|
||||||
### If migrations get out of sync (drift)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Diff production DB against local schema to find drift
|
|
||||||
npx prisma migrate diff \
|
|
||||||
--from-url "mysql://prod" \
|
|
||||||
--to-schema-datamodel prisma/schema.prisma \
|
|
||||||
--script
|
|
||||||
|
|
||||||
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
|
|
||||||
# If drift includes DROPs: investigate before touching production
|
|
||||||
```
|
|
||||||
|
|
||||||
### Baselinining a database that has no migrations
|
|
||||||
|
|
||||||
If production was synced with `db push` and has no `_prisma_migrations` table:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create initial migration locally
|
|
||||||
npx prisma migrate dev --name init
|
|
||||||
|
|
||||||
# 2. Copy to production and mark as applied (no SQL runs)
|
|
||||||
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
|
|
||||||
ssh user@prod
|
|
||||||
cd /var/www/app-ts
|
|
||||||
npx prisma migrate resolve --applied <migration_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Conventions (enforced — verified by the 2026-06-06 audit)
|
## Enforced Conventions (single source — merged 2026-06 audits)
|
||||||
|
|
||||||
These are the unified rules across the codebase. Follow them for new code; the
|
|
||||||
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
|
|
||||||
|
|
||||||
**Routes**
|
**Routes**
|
||||||
|
|
||||||
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
|
- `success()`/`error()`/paginated helpers only; `parseId` for numeric params
|
||||||
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
|
(`if (id === null) return;`); `parseBody` for every body; permission guards
|
||||||
- **Bodies:** validate every body with `parseBody(Schema, request.body)` → `if ("error" in body) return error(reply, body.error, 400)`.
|
on reads AND writes; `logAudit` with old/new values on every mutation.
|
||||||
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
|
- Role-management writes are admin-only and re-checked (no creating or
|
||||||
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
|
rewriting the `admin` role).
|
||||||
|
|
||||||
**Services**
|
|
||||||
|
|
||||||
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
|
|
||||||
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
|
|
||||||
|
|
||||||
**Schemas (Zod 4)**
|
**Schemas (Zod 4)**
|
||||||
|
|
||||||
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
|
- `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
|
||||||
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
|
- **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
|
||||||
- User-facing messages in **Czech**; identifiers/keys in English.
|
form-coerced fields: `numberFromForm`, `numberInRange`,
|
||||||
|
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
|
||||||
|
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
|
||||||
|
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
|
||||||
|
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
|
||||||
|
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
|
||||||
|
the single biggest historical bug class). FK ids → intIdFromForm;
|
||||||
|
quantities/prices → nonNegative/positive; bounded → numberInRange.
|
||||||
|
- The date/time helpers are deliberately **lenient** (strip trailing time
|
||||||
|
components) because edit forms re-submit `toJSON`-serialized values.
|
||||||
|
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
|
||||||
|
- Align `max()` caps with the DB column widths (an over-cap string 500s at
|
||||||
|
Prisma instead of 400ing at Zod). Update schemas derive via
|
||||||
|
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
|
||||||
|
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
|
||||||
|
still injects field defaults into partial payloads).
|
||||||
|
- User-facing messages in **Czech**; identifiers/tokens in English.
|
||||||
|
|
||||||
**Frontend**
|
**Determinism & concurrency**
|
||||||
|
|
||||||
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
|
- Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
|
||||||
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
|
columns make single-key sorts non-deterministic).
|
||||||
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
|
- Read-modify-write of shared rows (stock, balances, sequences) runs inside
|
||||||
|
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
|
||||||
|
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
|
||||||
|
writes (header + items + sections) belong in ONE transaction.
|
||||||
|
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
|
||||||
|
the checker) and catch `P2002` → 409 as backstop.
|
||||||
|
|
||||||
**Dates (two deliberate regimes — don't "fix" either)**
|
**Errors**
|
||||||
|
|
||||||
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
|
- Never silently swallow — every catch logs (`console.*` in services; there
|
||||||
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
|
is no request-scoped logger there). The only allowed silent catch is an
|
||||||
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
|
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
|
||||||
|
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
|
||||||
## Conventions (enforced — added by the 2026-06-09 full audit)
|
|
||||||
|
|
||||||
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
|
|
||||||
|
|
||||||
**Zod validation — shared coercion helpers are MANDATORY**
|
|
||||||
|
|
||||||
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
|
|
||||||
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0–100`, month `1–12`) → `numberInRange`.
|
|
||||||
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
|
|
||||||
- An optional email a form may submit as `""` → **`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
|
|
||||||
|
|
||||||
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
|
|
||||||
|
|
||||||
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
|
|
||||||
|
|
||||||
**Concurrency — locking discipline for stock / balance / sequence mutations**
|
|
||||||
|
|
||||||
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
|
|
||||||
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
|
|
||||||
|
|
||||||
**Permissions — guard reads, not just writes**
|
|
||||||
|
|
||||||
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
|
|
||||||
|
|
||||||
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
|
|
||||||
|
|
||||||
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error** — `npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
|
|
||||||
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
|
|
||||||
|
|
||||||
**Safety**
|
|
||||||
|
|
||||||
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
|
|
||||||
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known Issues & Gotchas
|
## Known Gotchas
|
||||||
|
|
||||||
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
|
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
|
||||||
|
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
|
||||||
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
|
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
|
||||||
|
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
|
||||||
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
|
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
|
||||||
|
pass unsanitized user data into Puppeteer templates; string-built HTML
|
||||||
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
|
(print views, PDF templates) must `escapeHtml` every interpolation.
|
||||||
|
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
|
||||||
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
|
with logged errors, and tests stub NAS reads.
|
||||||
|
4. **Prisma client**: regenerate after every schema change; it is not
|
||||||
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
|
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
|
||||||
|
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
|
||||||
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
|
6. **Czech locale hardcoded** in messages/month names — intentional.
|
||||||
|
7. **Cross-login caches:** React Query keys are user-agnostic; the query
|
||||||
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
|
cache is cleared on login/logout in AuthContext — keep it that way.
|
||||||
|
|
||||||
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
|
|
||||||
|
|
||||||
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
|
|
||||||
|
|
||||||
11. **Seed file is dev-only** — `prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Release Process
|
## Release
|
||||||
|
|
||||||
1. Bump version in `package.json`
|
Process, deployment commands, hotfix-migration shipping and verification
|
||||||
2. `npm run build`
|
checklist: **`docs/release.md`**. Run the full test suite before tagging; the
|
||||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
user picks version numbers. **Never push to production or restart services
|
||||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
without explicit confirmation.**
|
||||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
|
|
||||||
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
|
||||||
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
|
|
||||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
|
||||||
- Path: `/var/www/app-ts`
|
|
||||||
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
|
|
||||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
|
||||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
|
||||||
- Install dependencies: `npm install --omit=dev`
|
|
||||||
- Regenerate the Prisma client: `npx prisma generate` — **MANDATORY**.
|
|
||||||
`npm install` skips regeneration when dependencies didn't change, leaving a
|
|
||||||
stale client that still selects dropped/renamed columns → P2022 500s in
|
|
||||||
prod (bit the v2.4.0 supplier release).
|
|
||||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
|
||||||
- Restart: `pm2 restart app-ts --update-env`
|
|
||||||
|
|
||||||
### Hotfixing a migration that was added after the tarball was built
|
|
||||||
|
|
||||||
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
|
|
||||||
release tarball has already been built and shipped, that migration is
|
|
||||||
NOT in the tarball and `prisma migrate deploy` on prod will report
|
|
||||||
"No pending migrations". The release tarball re-build re-runs the whole
|
|
||||||
release, but for a one-off hotfix you can ship just the new migration
|
|
||||||
folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local
|
|
||||||
cd prisma/migrations
|
|
||||||
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
|
|
||||||
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
|
||||||
|
|
||||||
# On prod
|
|
||||||
ssh boha_admin@192.168.50.100
|
|
||||||
cd /var/www/app-ts/prisma/migrations
|
|
||||||
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
|
|
||||||
cd /var/www/app-ts
|
|
||||||
npx prisma migrate deploy
|
|
||||||
pm2 restart app-ts --update-env
|
|
||||||
```
|
|
||||||
|
|
||||||
The migration is still tracked in git and applied via `prisma migrate
|
|
||||||
deploy` — the only thing the tarball is doing is delivering the
|
|
||||||
`migration.sql` to prod, which is the same mechanism the main release
|
|
||||||
uses. This is preferred over running raw SQL on prod (which the policy
|
|
||||||
in "Database Migrations" forbids).
|
|
||||||
|
|
||||||
Do not push directly to production or restart services without confirmation.
|
|
||||||
|
|||||||
237
REVIEW.md
237
REVIEW.md
@@ -1,81 +1,102 @@
|
|||||||
# Codebase Audit — REVIEW.md
|
# Codebase Audit Checklist
|
||||||
|
|
||||||
**Date:** 2026-06-08
|
**Date:** 2026-06-09 | **Total files:** 304 | Scope: full project (source + config). Excluded: docs/*.md, prisma/migrations/*.sql (generated SQL), package-lock.json, binaries.
|
||||||
**Scope:** entire repository (tracked source/config files via `git ls-files`)
|
|
||||||
**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction.
|
|
||||||
|
|
||||||
**Total files to review:** 277
|
## (root)
|
||||||
|
|
||||||
---
|
- [x] .env.example
|
||||||
|
- [x] .gitignore
|
||||||
|
- [x] .prettierignore
|
||||||
|
- [x] .prettierrc.json
|
||||||
|
- [x] boneyard.config.json
|
||||||
|
- [x] CLAUDE.md
|
||||||
|
- [x] eslint.config.mjs
|
||||||
|
- [x] index.html
|
||||||
|
- [x] package.json
|
||||||
|
- [x] prisma.config.ts
|
||||||
|
- [x] tsconfig.app.json
|
||||||
|
- [x] tsconfig.json
|
||||||
|
- [x] tsconfig.server.json
|
||||||
|
- [x] tsconfig.test.json
|
||||||
|
- [x] vite.config.ts
|
||||||
|
- [x] vitest.config.ts
|
||||||
|
|
||||||
|
## .claude
|
||||||
## .claude/hooks/
|
|
||||||
|
|
||||||
- [x] .claude/hooks/block-env.js
|
- [x] .claude/hooks/block-env.js
|
||||||
- [x] .claude/hooks/format-on-save.js
|
- [x] .claude/hooks/format-on-save.js
|
||||||
|
|
||||||
## .claude/
|
|
||||||
|
|
||||||
- [x] .claude/settings.json
|
- [x] .claude/settings.json
|
||||||
- [x] .claude/settings.local.json
|
- [x] .claude/settings.local.json
|
||||||
|
|
||||||
## ./
|
## prisma
|
||||||
|
|
||||||
- [x] .env.example
|
|
||||||
- [x] CLAUDE.md
|
|
||||||
- [x] boneyard.config.json
|
|
||||||
- [x] index.html
|
|
||||||
- [x] package.json
|
|
||||||
|
|
||||||
## prisma/
|
|
||||||
|
|
||||||
- [x] prisma/schema.prisma
|
|
||||||
- [x] prisma/seed.ts
|
- [x] prisma/seed.ts
|
||||||
|
- [x] prisma/schema.prisma
|
||||||
|
|
||||||
## scripts/
|
## scripts
|
||||||
|
|
||||||
|
- [x] scripts/check-roles.mjs
|
||||||
- [x] scripts/migrate-received-invoices-to-nas.ts
|
- [x] scripts/migrate-received-invoices-to-nas.ts
|
||||||
- [x] scripts/rotate-totp-key.ts
|
- [x] scripts/rotate-totp-key.ts
|
||||||
|
- [x] scripts/seed-test-users.mjs
|
||||||
|
|
||||||
## src/
|
## src/__tests__
|
||||||
|
|
||||||
- [x] src/App.tsx
|
|
||||||
|
|
||||||
## src/__tests__/
|
|
||||||
|
|
||||||
- [x] src/__tests__/ai.test.ts
|
- [x] src/__tests__/ai.test.ts
|
||||||
- [x] src/__tests__/auth.service.test.ts
|
- [x] src/__tests__/auth.service.test.ts
|
||||||
- [x] src/__tests__/auth.test.ts
|
- [x] src/__tests__/auth.test.ts
|
||||||
|
- [x] src/__tests__/bank-accounts.test.ts
|
||||||
|
- [x] src/__tests__/company-settings-numbering.test.ts
|
||||||
- [x] src/__tests__/customers.schema.test.ts
|
- [x] src/__tests__/customers.schema.test.ts
|
||||||
|
- [x] src/__tests__/drafts-aggregation.test.ts
|
||||||
|
- [x] src/__tests__/drafts-deferred-numbering.test.ts
|
||||||
- [x] src/__tests__/env.test.ts
|
- [x] src/__tests__/env.test.ts
|
||||||
- [x] src/__tests__/exchange-rates.test.ts
|
- [x] src/__tests__/exchange-rates.test.ts
|
||||||
- [x] src/__tests__/helpers.ts
|
- [x] src/__tests__/helpers.ts
|
||||||
- [x] src/__tests__/invoices.service.test.ts
|
- [x] src/__tests__/invoices.service.test.ts
|
||||||
|
- [x] src/__tests__/issued-orders.test.ts
|
||||||
- [x] src/__tests__/manual-create.test.ts
|
- [x] src/__tests__/manual-create.test.ts
|
||||||
|
- [x] src/__tests__/nas-document-manager.test.ts
|
||||||
- [x] src/__tests__/nas-file-manager.test.ts
|
- [x] src/__tests__/nas-file-manager.test.ts
|
||||||
- [x] src/__tests__/nas-project-folder.test.ts
|
- [x] src/__tests__/nas-project-folder.test.ts
|
||||||
- [x] src/__tests__/numbering.test.ts
|
- [x] src/__tests__/numbering.test.ts
|
||||||
|
- [x] src/__tests__/offer-invoice-totals.test.ts
|
||||||
|
- [x] src/__tests__/orders-list.test.ts
|
||||||
|
- [x] src/__tests__/order-totals.test.ts
|
||||||
- [x] src/__tests__/plan.test.ts
|
- [x] src/__tests__/plan.test.ts
|
||||||
- [x] src/__tests__/planAuditDescription.test.ts
|
- [x] src/__tests__/planAuditDescription.test.ts
|
||||||
- [x] src/__tests__/planCategory.test.ts
|
- [x] src/__tests__/planCategory.test.ts
|
||||||
- [x] src/__tests__/received-invoices-vat.test.ts
|
- [x] src/__tests__/received-invoices-vat.test.ts
|
||||||
- [x] src/__tests__/schema-nan.test.ts
|
|
||||||
- [x] src/__tests__/setup.ts
|
- [x] src/__tests__/setup.ts
|
||||||
|
- [x] src/__tests__/schema-nan.test.ts
|
||||||
- [x] src/__tests__/warehouse.test.ts
|
- [x] src/__tests__/warehouse.test.ts
|
||||||
|
|
||||||
## src/admin/
|
## src/admin (AdminApp.tsx)
|
||||||
|
|
||||||
- [x] src/admin/AdminApp.tsx
|
- [x] src/admin/AdminApp.tsx
|
||||||
- [x] src/admin/GlobalStyles.tsx
|
|
||||||
|
|
||||||
## src/admin/components/
|
## src/admin (components)
|
||||||
|
|
||||||
- [x] src/admin/components/AlertContainer.tsx
|
- [x] src/admin/components/AlertContainer.tsx
|
||||||
- [x] src/admin/components/AttendanceShiftTable.tsx
|
- [x] src/admin/components/AttendanceShiftTable.tsx
|
||||||
- [x] src/admin/components/BulkAttendanceModal.tsx
|
- [x] src/admin/components/BulkAttendanceModal.tsx
|
||||||
- [x] src/admin/components/BulkPlanModal.tsx
|
- [x] src/admin/components/BulkPlanModal.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashKpiCards.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashProfile.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashQuickActions.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashSessions.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
|
||||||
- [x] src/admin/components/ErrorBoundary.tsx
|
- [x] src/admin/components/ErrorBoundary.tsx
|
||||||
- [x] src/admin/components/Forbidden.tsx
|
- [x] src/admin/components/Forbidden.tsx
|
||||||
|
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinComposer.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinChat.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinMark.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinSidebar.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinThread.tsx
|
||||||
|
- [x] src/admin/components/odin/types.ts
|
||||||
- [x] src/admin/components/OrderConfirmationModal.tsx
|
- [x] src/admin/components/OrderConfirmationModal.tsx
|
||||||
- [x] src/admin/components/PlanCategoriesModal.tsx
|
- [x] src/admin/components/PlanCategoriesModal.tsx
|
||||||
- [x] src/admin/components/PlanCellModal.tsx
|
- [x] src/admin/components/PlanCellModal.tsx
|
||||||
@@ -85,38 +106,19 @@
|
|||||||
- [x] src/admin/components/RichEditor.tsx
|
- [x] src/admin/components/RichEditor.tsx
|
||||||
- [x] src/admin/components/ShiftFormModal.tsx
|
- [x] src/admin/components/ShiftFormModal.tsx
|
||||||
- [x] src/admin/components/ShortcutsHelp.tsx
|
- [x] src/admin/components/ShortcutsHelp.tsx
|
||||||
|
|
||||||
## src/admin/components/dashboard/
|
|
||||||
|
|
||||||
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashKpiCards.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashProfile.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashQuickActions.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashSessions.tsx
|
|
||||||
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
|
|
||||||
|
|
||||||
## src/admin/components/odin/
|
|
||||||
|
|
||||||
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
|
|
||||||
- [x] src/admin/components/odin/OdinChat.tsx
|
|
||||||
- [x] src/admin/components/odin/OdinComposer.tsx
|
|
||||||
- [x] src/admin/components/odin/OdinMark.tsx
|
|
||||||
- [x] src/admin/components/odin/OdinSidebar.tsx
|
|
||||||
- [x] src/admin/components/odin/OdinThread.tsx
|
|
||||||
- [x] src/admin/components/odin/types.ts
|
|
||||||
|
|
||||||
## src/admin/components/warehouse/
|
|
||||||
|
|
||||||
- [x] src/admin/components/warehouse/ItemPicker.tsx
|
- [x] src/admin/components/warehouse/ItemPicker.tsx
|
||||||
- [x] src/admin/components/warehouse/ReservationPicker.tsx
|
- [x] src/admin/components/warehouse/ReservationPicker.tsx
|
||||||
|
|
||||||
## src/admin/context/
|
## src/admin (context)
|
||||||
|
|
||||||
- [x] src/admin/context/AlertContext.tsx
|
- [x] src/admin/context/AlertContext.tsx
|
||||||
- [x] src/admin/context/AuthContext.tsx
|
- [x] src/admin/context/AuthContext.tsx
|
||||||
|
|
||||||
## src/admin/hooks/
|
## src/admin (GlobalStyles.tsx)
|
||||||
|
|
||||||
|
- [x] src/admin/GlobalStyles.tsx
|
||||||
|
|
||||||
|
## src/admin (hooks)
|
||||||
|
|
||||||
- [x] src/admin/hooks/useAttendanceAdmin.ts
|
- [x] src/admin/hooks/useAttendanceAdmin.ts
|
||||||
- [x] src/admin/hooks/useDebounce.ts
|
- [x] src/admin/hooks/useDebounce.ts
|
||||||
@@ -126,19 +128,18 @@
|
|||||||
- [x] src/admin/hooks/useReducedMotion.ts
|
- [x] src/admin/hooks/useReducedMotion.ts
|
||||||
- [x] src/admin/hooks/useTableSort.ts
|
- [x] src/admin/hooks/useTableSort.ts
|
||||||
|
|
||||||
## src/admin/lib/
|
## src/admin (lib)
|
||||||
|
|
||||||
- [x] src/admin/lib/apiAdapter.ts
|
- [x] src/admin/lib/apiAdapter.ts
|
||||||
|
- [x] src/admin/lib/documentStatus.ts
|
||||||
- [x] src/admin/lib/entityTypeLabels.ts
|
- [x] src/admin/lib/entityTypeLabels.ts
|
||||||
|
|
||||||
## src/admin/lib/queries/
|
|
||||||
|
|
||||||
- [x] src/admin/lib/queries/ai.ts
|
- [x] src/admin/lib/queries/ai.ts
|
||||||
- [x] src/admin/lib/queries/attendance.ts
|
- [x] src/admin/lib/queries/attendance.ts
|
||||||
- [x] src/admin/lib/queries/auditLog.ts
|
- [x] src/admin/lib/queries/auditLog.ts
|
||||||
- [x] src/admin/lib/queries/common.ts
|
- [x] src/admin/lib/queries/common.ts
|
||||||
- [x] src/admin/lib/queries/dashboard.ts
|
- [x] src/admin/lib/queries/dashboard.ts
|
||||||
- [x] src/admin/lib/queries/invoices.ts
|
- [x] src/admin/lib/queries/invoices.ts
|
||||||
|
- [x] src/admin/lib/queries/issued-orders.ts
|
||||||
- [x] src/admin/lib/queries/leave.ts
|
- [x] src/admin/lib/queries/leave.ts
|
||||||
- [x] src/admin/lib/queries/mutations.ts
|
- [x] src/admin/lib/queries/mutations.ts
|
||||||
- [x] src/admin/lib/queries/offers.ts
|
- [x] src/admin/lib/queries/offers.ts
|
||||||
@@ -150,12 +151,9 @@
|
|||||||
- [x] src/admin/lib/queries/users.ts
|
- [x] src/admin/lib/queries/users.ts
|
||||||
- [x] src/admin/lib/queries/vehicles.ts
|
- [x] src/admin/lib/queries/vehicles.ts
|
||||||
- [x] src/admin/lib/queries/warehouse.ts
|
- [x] src/admin/lib/queries/warehouse.ts
|
||||||
|
|
||||||
## src/admin/lib/
|
|
||||||
|
|
||||||
- [x] src/admin/lib/queryClient.ts
|
- [x] src/admin/lib/queryClient.ts
|
||||||
|
|
||||||
## src/admin/pages/
|
## src/admin (pages)
|
||||||
|
|
||||||
- [x] src/admin/pages/Attendance.tsx
|
- [x] src/admin/pages/Attendance.tsx
|
||||||
- [x] src/admin/pages/AttendanceAdmin.tsx
|
- [x] src/admin/pages/AttendanceAdmin.tsx
|
||||||
@@ -168,6 +166,8 @@
|
|||||||
- [x] src/admin/pages/Dashboard.tsx
|
- [x] src/admin/pages/Dashboard.tsx
|
||||||
- [x] src/admin/pages/InvoiceDetail.tsx
|
- [x] src/admin/pages/InvoiceDetail.tsx
|
||||||
- [x] src/admin/pages/Invoices.tsx
|
- [x] src/admin/pages/Invoices.tsx
|
||||||
|
- [x] src/admin/pages/IssuedOrderDetail.tsx
|
||||||
|
- [x] src/admin/pages/IssuedOrders.tsx
|
||||||
- [x] src/admin/pages/LeaveApproval.tsx
|
- [x] src/admin/pages/LeaveApproval.tsx
|
||||||
- [x] src/admin/pages/LeaveRequests.tsx
|
- [x] src/admin/pages/LeaveRequests.tsx
|
||||||
- [x] src/admin/pages/Login.tsx
|
- [x] src/admin/pages/Login.tsx
|
||||||
@@ -183,6 +183,7 @@
|
|||||||
- [x] src/admin/pages/ProjectDetail.tsx
|
- [x] src/admin/pages/ProjectDetail.tsx
|
||||||
- [x] src/admin/pages/Projects.tsx
|
- [x] src/admin/pages/Projects.tsx
|
||||||
- [x] src/admin/pages/ReceivedInvoices.tsx
|
- [x] src/admin/pages/ReceivedInvoices.tsx
|
||||||
|
- [x] src/admin/pages/ReceivedOrders.tsx
|
||||||
- [x] src/admin/pages/Settings.tsx
|
- [x] src/admin/pages/Settings.tsx
|
||||||
- [x] src/admin/pages/Trips.tsx
|
- [x] src/admin/pages/Trips.tsx
|
||||||
- [x] src/admin/pages/TripsAdmin.tsx
|
- [x] src/admin/pages/TripsAdmin.tsx
|
||||||
@@ -208,29 +209,35 @@
|
|||||||
- [x] src/admin/pages/WarehouseReservations.tsx
|
- [x] src/admin/pages/WarehouseReservations.tsx
|
||||||
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
||||||
|
|
||||||
## src/admin/
|
## src/admin (theme.test.ts)
|
||||||
|
|
||||||
- [x] src/admin/theme.test.ts
|
- [x] src/admin/theme.test.ts
|
||||||
|
|
||||||
|
## src/admin (theme.ts)
|
||||||
|
|
||||||
- [x] src/admin/theme.ts
|
- [x] src/admin/theme.ts
|
||||||
|
|
||||||
## src/admin/ui/
|
## src/admin (ui)
|
||||||
|
|
||||||
- [x] src/admin/ui/Alert.tsx
|
- [x] src/admin/ui/Alert.tsx
|
||||||
- [x] src/admin/ui/AppShell.tsx
|
- [x] src/admin/ui/AppShell.tsx
|
||||||
- [x] src/admin/ui/Button.tsx
|
- [x] src/admin/ui/Button.tsx
|
||||||
- [x] src/admin/ui/Card.tsx
|
- [x] src/admin/ui/Card.tsx
|
||||||
- [x] src/admin/ui/Checkbox.tsx
|
|
||||||
- [x] src/admin/ui/ConfirmDialog.tsx
|
- [x] src/admin/ui/ConfirmDialog.tsx
|
||||||
|
- [x] src/admin/ui/CustomerPicker.tsx
|
||||||
- [x] src/admin/ui/DataTable.tsx
|
- [x] src/admin/ui/DataTable.tsx
|
||||||
- [x] src/admin/ui/DateField.tsx
|
- [x] src/admin/ui/DateField.tsx
|
||||||
- [x] src/admin/ui/EmptyState.tsx
|
- [x] src/admin/ui/EmptyState.tsx
|
||||||
- [x] src/admin/ui/Field.tsx
|
- [x] src/admin/ui/Field.tsx
|
||||||
- [x] src/admin/ui/FileUpload.tsx
|
- [x] src/admin/ui/FileUpload.tsx
|
||||||
- [x] src/admin/ui/FilterBar.tsx
|
- [x] src/admin/ui/FilterBar.tsx
|
||||||
|
- [x] src/admin/ui/Checkbox.tsx
|
||||||
|
- [x] src/admin/ui/index.ts
|
||||||
- [x] src/admin/ui/LoadingState.tsx
|
- [x] src/admin/ui/LoadingState.tsx
|
||||||
- [x] src/admin/ui/Modal.tsx
|
- [x] src/admin/ui/Modal.tsx
|
||||||
- [x] src/admin/ui/MonthField.tsx
|
- [x] src/admin/ui/MonthField.tsx
|
||||||
- [x] src/admin/ui/MuiProvider.tsx
|
- [x] src/admin/ui/MuiProvider.tsx
|
||||||
|
- [x] src/admin/ui/navData.tsx
|
||||||
- [x] src/admin/ui/PageEnter.tsx
|
- [x] src/admin/ui/PageEnter.tsx
|
||||||
- [x] src/admin/ui/PageHeader.tsx
|
- [x] src/admin/ui/PageHeader.tsx
|
||||||
- [x] src/admin/ui/Pagination.tsx
|
- [x] src/admin/ui/Pagination.tsx
|
||||||
@@ -244,36 +251,38 @@
|
|||||||
- [x] src/admin/ui/TextField.tsx
|
- [x] src/admin/ui/TextField.tsx
|
||||||
- [x] src/admin/ui/ThemeToggle.tsx
|
- [x] src/admin/ui/ThemeToggle.tsx
|
||||||
- [x] src/admin/ui/TimeField.tsx
|
- [x] src/admin/ui/TimeField.tsx
|
||||||
- [x] src/admin/ui/index.ts
|
|
||||||
- [x] src/admin/ui/navData.tsx
|
|
||||||
- [x] src/admin/ui/useDialogScrollLock.ts
|
- [x] src/admin/ui/useDialogScrollLock.ts
|
||||||
|
|
||||||
## src/admin/utils/
|
## src/admin (utils)
|
||||||
|
|
||||||
- [x] src/admin/utils/api.ts
|
- [x] src/admin/utils/api.ts
|
||||||
- [x] src/admin/utils/attendanceHelpers.ts
|
- [x] src/admin/utils/attendanceHelpers.ts
|
||||||
- [x] src/admin/utils/dashboardHelpers.ts
|
- [x] src/admin/utils/dashboardHelpers.ts
|
||||||
- [x] src/admin/utils/formatters.ts
|
- [x] src/admin/utils/formatters.ts
|
||||||
|
|
||||||
## src/config/
|
## src/App.tsx
|
||||||
|
|
||||||
|
- [x] src/App.tsx
|
||||||
|
|
||||||
|
## src/config
|
||||||
|
|
||||||
- [x] src/config/database.ts
|
- [x] src/config/database.ts
|
||||||
- [x] src/config/env.ts
|
- [x] src/config/env.ts
|
||||||
|
|
||||||
## src/context/
|
## src/context
|
||||||
|
|
||||||
- [x] src/context/ThemeContext.tsx
|
- [x] src/context/ThemeContext.tsx
|
||||||
|
|
||||||
## src/
|
## src/main.tsx
|
||||||
|
|
||||||
- [x] src/main.tsx
|
- [x] src/main.tsx
|
||||||
|
|
||||||
## src/middleware/
|
## src/middleware
|
||||||
|
|
||||||
- [x] src/middleware/auth.ts
|
- [x] src/middleware/auth.ts
|
||||||
- [x] src/middleware/security.ts
|
- [x] src/middleware/security.ts
|
||||||
|
|
||||||
## src/routes/admin/
|
## src/routes
|
||||||
|
|
||||||
- [x] src/routes/admin/ai.ts
|
- [x] src/routes/admin/ai.ts
|
||||||
- [x] src/routes/admin/attendance.ts
|
- [x] src/routes/admin/attendance.ts
|
||||||
@@ -283,12 +292,14 @@
|
|||||||
- [x] src/routes/admin/company-settings.ts
|
- [x] src/routes/admin/company-settings.ts
|
||||||
- [x] src/routes/admin/customers.ts
|
- [x] src/routes/admin/customers.ts
|
||||||
- [x] src/routes/admin/dashboard.ts
|
- [x] src/routes/admin/dashboard.ts
|
||||||
- [x] src/routes/admin/invoices-pdf.ts
|
|
||||||
- [x] src/routes/admin/invoices.ts
|
- [x] src/routes/admin/invoices.ts
|
||||||
|
- [x] src/routes/admin/invoices-pdf.ts
|
||||||
|
- [x] src/routes/admin/issued-orders.ts
|
||||||
|
- [x] src/routes/admin/issued-orders-pdf.ts
|
||||||
- [x] src/routes/admin/leave-requests.ts
|
- [x] src/routes/admin/leave-requests.ts
|
||||||
- [x] src/routes/admin/offers-pdf.ts
|
- [x] src/routes/admin/offers-pdf.ts
|
||||||
- [x] src/routes/admin/orders-pdf.ts
|
|
||||||
- [x] src/routes/admin/orders.ts
|
- [x] src/routes/admin/orders.ts
|
||||||
|
- [x] src/routes/admin/orders-pdf.ts
|
||||||
- [x] src/routes/admin/plan.ts
|
- [x] src/routes/admin/plan.ts
|
||||||
- [x] src/routes/admin/profile.ts
|
- [x] src/routes/admin/profile.ts
|
||||||
- [x] src/routes/admin/project-files.ts
|
- [x] src/routes/admin/project-files.ts
|
||||||
@@ -304,7 +315,36 @@
|
|||||||
- [x] src/routes/admin/vehicles.ts
|
- [x] src/routes/admin/vehicles.ts
|
||||||
- [x] src/routes/admin/warehouse.ts
|
- [x] src/routes/admin/warehouse.ts
|
||||||
|
|
||||||
## src/schemas/
|
## src/server.ts
|
||||||
|
|
||||||
|
- [x] src/server.ts
|
||||||
|
|
||||||
|
## src/services
|
||||||
|
|
||||||
|
- [x] src/services/ai.service.ts
|
||||||
|
- [x] src/services/attendance.service.ts
|
||||||
|
- [x] src/services/audit.ts
|
||||||
|
- [x] src/services/auth.ts
|
||||||
|
- [x] src/services/exchange-rates.ts
|
||||||
|
- [x] src/services/invoice-alerts.ts
|
||||||
|
- [x] src/services/invoices.service.ts
|
||||||
|
- [x] src/services/issued-orders.service.ts
|
||||||
|
- [x] src/services/leave-notification.ts
|
||||||
|
- [x] src/services/mailer.ts
|
||||||
|
- [x] src/services/nas-file-manager.ts
|
||||||
|
- [x] src/services/nas-financials-manager.ts
|
||||||
|
- [x] src/services/nas-offers-manager.ts
|
||||||
|
- [x] src/services/numbering.service.ts
|
||||||
|
- [x] src/services/offers.service.ts
|
||||||
|
- [x] src/services/orders.service.ts
|
||||||
|
- [x] src/services/plan.service.ts
|
||||||
|
- [x] src/services/planCategory.service.ts
|
||||||
|
- [x] src/services/projects.service.ts
|
||||||
|
- [x] src/services/system-settings.ts
|
||||||
|
- [x] src/services/users.service.ts
|
||||||
|
- [x] src/services/warehouse.service.ts
|
||||||
|
|
||||||
|
## src/schemas
|
||||||
|
|
||||||
- [x] src/schemas/ai.schema.ts
|
- [x] src/schemas/ai.schema.ts
|
||||||
- [x] src/schemas/attendance.schema.ts
|
- [x] src/schemas/attendance.schema.ts
|
||||||
@@ -313,6 +353,7 @@
|
|||||||
- [x] src/schemas/common.ts
|
- [x] src/schemas/common.ts
|
||||||
- [x] src/schemas/customers.schema.ts
|
- [x] src/schemas/customers.schema.ts
|
||||||
- [x] src/schemas/invoices.schema.ts
|
- [x] src/schemas/invoices.schema.ts
|
||||||
|
- [x] src/schemas/issued-orders.schema.ts
|
||||||
- [x] src/schemas/leave-requests.schema.ts
|
- [x] src/schemas/leave-requests.schema.ts
|
||||||
- [x] src/schemas/offers.schema.ts
|
- [x] src/schemas/offers.schema.ts
|
||||||
- [x] src/schemas/orders.schema.ts
|
- [x] src/schemas/orders.schema.ts
|
||||||
@@ -329,40 +370,12 @@
|
|||||||
- [x] src/schemas/vehicles.schema.ts
|
- [x] src/schemas/vehicles.schema.ts
|
||||||
- [x] src/schemas/warehouse.schema.ts
|
- [x] src/schemas/warehouse.schema.ts
|
||||||
|
|
||||||
## src/
|
## src/types
|
||||||
|
|
||||||
- [x] src/server.ts
|
|
||||||
|
|
||||||
## src/services/
|
|
||||||
|
|
||||||
- [x] src/services/ai.service.ts
|
|
||||||
- [x] src/services/attendance.service.ts
|
|
||||||
- [x] src/services/audit.ts
|
|
||||||
- [x] src/services/auth.ts
|
|
||||||
- [x] src/services/exchange-rates.ts
|
|
||||||
- [x] src/services/invoice-alerts.ts
|
|
||||||
- [x] src/services/invoices.service.ts
|
|
||||||
- [x] src/services/leave-notification.ts
|
|
||||||
- [x] src/services/mailer.ts
|
|
||||||
- [x] src/services/nas-file-manager.ts
|
|
||||||
- [x] src/services/nas-financials-manager.ts
|
|
||||||
- [x] src/services/nas-offers-manager.ts
|
|
||||||
- [x] src/services/numbering.service.ts
|
|
||||||
- [x] src/services/offers.service.ts
|
|
||||||
- [x] src/services/orders.service.ts
|
|
||||||
- [x] src/services/plan.service.ts
|
|
||||||
- [x] src/services/planCategory.service.ts
|
|
||||||
- [x] src/services/projects.service.ts
|
|
||||||
- [x] src/services/system-settings.ts
|
|
||||||
- [x] src/services/users.service.ts
|
|
||||||
- [x] src/services/warehouse.service.ts
|
|
||||||
|
|
||||||
## src/types/
|
|
||||||
|
|
||||||
- [x] src/types/fastify.d.ts
|
- [x] src/types/fastify.d.ts
|
||||||
- [x] src/types/index.ts
|
- [x] src/types/index.ts
|
||||||
|
|
||||||
## src/utils/
|
## src/utils
|
||||||
|
|
||||||
- [x] src/utils/czech-holidays.ts
|
- [x] src/utils/czech-holidays.ts
|
||||||
- [x] src/utils/date.ts
|
- [x] src/utils/date.ts
|
||||||
@@ -373,14 +386,8 @@
|
|||||||
- [x] src/utils/response.ts
|
- [x] src/utils/response.ts
|
||||||
- [x] src/utils/totp.ts
|
- [x] src/utils/totp.ts
|
||||||
|
|
||||||
## src/
|
## src/vite-env.d.ts
|
||||||
|
|
||||||
- [x] src/vite-env.d.ts
|
- [x] src/vite-env.d.ts
|
||||||
|
|
||||||
## ./
|
|
||||||
|
|
||||||
- [x] tsconfig.app.json
|
|
||||||
- [x] tsconfig.json
|
|
||||||
- [x] tsconfig.server.json
|
|
||||||
- [x] vite.config.ts
|
|
||||||
- [x] vitest.config.ts
|
|
||||||
|
|||||||
2212
REVIEW_FINDINGS.md
2212
REVIEW_FINDINGS.md
File diff suppressed because it is too large
Load Diff
1385
REVIEW_FIXES.md
1385
REVIEW_FIXES.md
File diff suppressed because it is too large
Load Diff
@@ -1,385 +0,0 @@
|
|||||||
# Deployment Guide — boha-app-ts (Ubuntu Server)
|
|
||||||
|
|
||||||
Migration from PHP boha-app to TypeScript boha-app-ts on the same Ubuntu server.
|
|
||||||
Both apps share the same MySQL database. The PHP app stays running during migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Ubuntu server with the PHP boha-app already running
|
|
||||||
- nginx with SSL (Let's Encrypt) already configured
|
|
||||||
- MySQL database already running with production data
|
|
||||||
- NAS storage mounted (e.g., `/mnt/nas/02_PROJEKTY`)
|
|
||||||
- SSH access to the server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Prepare on Dev Machine (Windows)
|
|
||||||
|
|
||||||
### 1.1 Create Prisma migration baseline
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd D:\cortex\boha-app-ts
|
|
||||||
mkdir -p prisma/migrations/0_init
|
|
||||||
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
|
|
||||||
npx prisma migrate resolve --applied 0_init
|
|
||||||
git add prisma/migrations/
|
|
||||||
git commit -m "chore: create Prisma migration baseline"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 Build the application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
- `dist/` — compiled server (Node.js)
|
|
||||||
- `dist-client/` — compiled frontend (static files)
|
|
||||||
|
|
||||||
### 1.3 Generate new production secrets
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -hex 32
|
|
||||||
# Save this as JWT_SECRET
|
|
||||||
|
|
||||||
openssl rand -hex 32
|
|
||||||
# Save this as TOTP_ENCRYPTION_KEY (only if you want a new key)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 Test the production build locally (optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_ENV=production JWT_SECRET=<your-dev-key> TOTP_ENCRYPTION_KEY=<your-dev-key> DATABASE_URL=<your-dev-db> node dist/server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify it starts and responds at `http://localhost:3001/api/health`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Prepare Ubuntu Server
|
|
||||||
|
|
||||||
### 2.1 Install Node.js 22
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
||||||
sudo apt-get install -y nodejs
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Install PM2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo npm install -g pm2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Create application directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /var/www/boha-app-ts
|
|
||||||
sudo chown $USER:$USER /var/www/boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Transfer Files to Server
|
|
||||||
|
|
||||||
### 3.1 Copy built files
|
|
||||||
|
|
||||||
From your Windows machine (Git Bash or PowerShell with SCP):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scp -r dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use rsync if available:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -avz --exclude node_modules --exclude .env dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Install production dependencies on server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh user@server
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
npm install --production
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Configure Environment
|
|
||||||
|
|
||||||
### 4.1 Create production .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Fill in:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Database — same as the PHP app
|
|
||||||
DATABASE_URL=mysql://user:password@localhost:3306/your_db_name
|
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=production
|
|
||||||
|
|
||||||
# Auth — use the NEW secrets generated in step 1.3
|
|
||||||
JWT_SECRET=<paste-new-jwt-secret>
|
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
|
||||||
|
|
||||||
# TOTP — use SAME key as PHP app (unless you want to re-encrypt)
|
|
||||||
TOTP_ENCRYPTION_KEY=<same-key-as-php-app>
|
|
||||||
|
|
||||||
# NAS — Linux mount point
|
|
||||||
NAS_PATH=/mnt/nas/02_PROJEKTY
|
|
||||||
MAX_UPLOAD_SIZE=52428800
|
|
||||||
|
|
||||||
# Email
|
|
||||||
CONTACT_EMAIL_TO=manager@boha-automation.cz
|
|
||||||
CONTACT_EMAIL_FROM=web@boha-automation.cz
|
|
||||||
SMTP_FROM=noreply@boha-automation.cz
|
|
||||||
|
|
||||||
# CORS — your production domain(s)
|
|
||||||
CORS_ORIGINS=https://app.boha-automation.cz,https://www.boha-automation.cz
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important decisions:**
|
|
||||||
|
|
||||||
| Setting | Recommendation |
|
|
||||||
|---------|---------------|
|
|
||||||
| `JWT_SECRET` | **New key.** All PHP sessions will be invalid — users re-login. This is expected. |
|
|
||||||
| `TOTP_ENCRYPTION_KEY` | **Same key as PHP app.** Avoids re-encrypting all TOTP secrets. |
|
|
||||||
| `DATABASE_URL` | **Same database as PHP app.** Both apps share it. |
|
|
||||||
| `NAS_PATH` | **Linux mount point** instead of Windows drive letter. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Database Setup
|
|
||||||
|
|
||||||
### 5.1 Mark Prisma baseline as applied
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
npx prisma migrate resolve --applied 0_init
|
|
||||||
```
|
|
||||||
|
|
||||||
This tells Prisma the database already has all tables. No SQL is executed.
|
|
||||||
|
|
||||||
### 5.2 Verify database connection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db pull --print | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Should show your existing tables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: TOTP Key Rotation (only if using new encryption key)
|
|
||||||
|
|
||||||
**Skip this section if you're using the same `TOTP_ENCRYPTION_KEY` as the PHP app.**
|
|
||||||
|
|
||||||
If you generated a new encryption key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
|
|
||||||
# Dry run — verify all secrets can be decrypted and re-encrypted
|
|
||||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key> --dry-run
|
|
||||||
|
|
||||||
# If all [OK], run for real
|
|
||||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key>
|
|
||||||
```
|
|
||||||
|
|
||||||
After this, the PHP app's TOTP verification will break (secrets are now encrypted with the new key). Only do this when you're ready to cut over.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Start Application with PM2
|
|
||||||
|
|
||||||
### 7.1 Create PM2 config
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
cat > ecosystem.config.js << 'EOF'
|
|
||||||
module.exports = {
|
|
||||||
apps: [{
|
|
||||||
name: 'boha-app-ts',
|
|
||||||
script: 'dist/server.js',
|
|
||||||
cwd: '/var/www/boha-app-ts',
|
|
||||||
instances: 1,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Start the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js
|
|
||||||
pm2 save
|
|
||||||
pm2 startup
|
|
||||||
# Follow the printed command to enable auto-start on boot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 status
|
|
||||||
pm2 logs boha-app-ts --lines 20
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `{"status":"ok","timestamp":"..."}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Nginx Configuration
|
|
||||||
|
|
||||||
### 8.1 Create nginx config
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/nginx/sites-available/boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Paste:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name app.boha-automation.cz;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/app.boha-automation.cz/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/app.boha-automation.cz/privkey.pem;
|
|
||||||
|
|
||||||
client_max_body_size 55M;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name app.boha-automation.cz;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust `server_name` and SSL paths to match your setup.
|
|
||||||
|
|
||||||
### 8.2 Enable and reload
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ln -s /etc/nginx/sites-available/boha-app-ts /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Testing
|
|
||||||
|
|
||||||
### 9.1 Verify in browser
|
|
||||||
|
|
||||||
Open `https://app.boha-automation.cz` and test:
|
|
||||||
|
|
||||||
- [ ] Login page loads
|
|
||||||
- [ ] Login works (users will need to re-login due to new JWT_SECRET)
|
|
||||||
- [ ] TOTP verification works (if using same encryption key)
|
|
||||||
- [ ] Dashboard loads with data
|
|
||||||
- [ ] Offers — list, create, edit, PDF export
|
|
||||||
- [ ] Orders — list, create from offer, status transitions
|
|
||||||
- [ ] Invoices — list, create, PDF with QR code
|
|
||||||
- [ ] Projects — list, create, file manager (upload/download)
|
|
||||||
- [ ] Attendance — clock in/out, admin view, print
|
|
||||||
- [ ] Trips — list, history
|
|
||||||
- [ ] Settings — company settings, users, roles
|
|
||||||
|
|
||||||
### 9.2 Check logs for errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 logs boha-app-ts --lines 50
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Cutover Strategy
|
|
||||||
|
|
||||||
Both apps run simultaneously on the same database. Recommended approach:
|
|
||||||
|
|
||||||
1. **Week 1:** Run both apps. Use the TS app for daily work. Fall back to PHP if issues arise.
|
|
||||||
2. **Week 2:** If stable, redirect the main domain to the TS app.
|
|
||||||
3. **Week 3:** Stop the PHP app.
|
|
||||||
|
|
||||||
To redirect the PHP domain to the TS app, update the nginx config for the PHP domain to proxy to port 3001 instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Updates
|
|
||||||
|
|
||||||
When you push code changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On dev machine
|
|
||||||
npm run build
|
|
||||||
git push
|
|
||||||
|
|
||||||
# On server
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
git pull
|
|
||||||
npm install --production
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate deploy # runs any new migrations
|
|
||||||
pm2 restart boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Problem | Solution |
|
|
||||||
|---------|----------|
|
|
||||||
| `EADDRINUSE: port 3001` | `pm2 stop boha-app-ts` then `pm2 start` |
|
|
||||||
| Prisma connection error | Check `DATABASE_URL` in `.env` |
|
|
||||||
| TOTP verification fails | Verify `TOTP_ENCRYPTION_KEY` matches the key used to encrypt secrets |
|
|
||||||
| NAS files not accessible | Check mount: `ls /mnt/nas/02_PROJEKTY`, verify permissions |
|
|
||||||
| 502 Bad Gateway | App not running: `pm2 status`, check logs: `pm2 logs` |
|
|
||||||
| CSS/JS not loading | Verify `dist-client/` was copied, check `APP_ENV=production` |
|
|
||||||
| CORS errors | Check `CORS_ORIGINS` in `.env` matches your domain exactly |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `pm2 start ecosystem.config.js` | Start the app |
|
|
||||||
| `pm2 restart boha-app-ts` | Restart after update |
|
|
||||||
| `pm2 stop boha-app-ts` | Stop the app |
|
|
||||||
| `pm2 logs boha-app-ts` | View logs |
|
|
||||||
| `pm2 monit` | Live monitoring |
|
|
||||||
| `npx prisma migrate deploy` | Apply database migrations |
|
|
||||||
| `npx prisma studio` | Database GUI (dev only) |
|
|
||||||
96
docs/release.md
Normal file
96
docs/release.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Release & deployment runbook — boha-app-ts
|
||||||
|
|
||||||
|
Referenced from CLAUDE.md. **Never deploy to production or restart services
|
||||||
|
without explicit user confirmation.**
|
||||||
|
|
||||||
|
## Standard release
|
||||||
|
|
||||||
|
1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`,
|
||||||
|
`npm run lint` (0 errors).
|
||||||
|
2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number).
|
||||||
|
3. `npm run build`
|
||||||
|
4. Commit and tag: `git tag -a vX.Y.Z`
|
||||||
|
5. Push to Gitea: `git push origin master && git push origin vX.Y.Z`
|
||||||
|
6. Create tarball:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||||
|
without it, `prisma generate`/`migrate deploy` on prod have no datasource.
|
||||||
|
|
||||||
|
7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
ssh boha_admin@192.168.50.100
|
||||||
|
cd /var/www/app-ts
|
||||||
|
rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
|
||||||
|
tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
|
||||||
|
npm install --omit=dev
|
||||||
|
npx prisma generate # MANDATORY — see below
|
||||||
|
npx prisma migrate deploy
|
||||||
|
pm2 restart app-ts --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
**`npx prisma generate` is mandatory.** `npm install` skips client
|
||||||
|
regeneration when dependencies didn't change, leaving a stale client that
|
||||||
|
still selects dropped/renamed columns → P2022 500s in prod (this caused
|
||||||
|
the v2.4.0 incident: every issued-orders query failed until generate ran).
|
||||||
|
|
||||||
|
8. Verify: `pm2 list` (status online, correct version, restart counter not
|
||||||
|
climbing), `npx prisma migrate status` ("up to date"),
|
||||||
|
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200,
|
||||||
|
and grep logs for errors from the CURRENT pid only (the out log keeps old
|
||||||
|
errors from prior pids).
|
||||||
|
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||||
|
|
||||||
|
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||||
|
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||||
|
constraint names the migration DROPs, check no data violates new
|
||||||
|
UNIQUE/RESTRICT constraints — and confirm with the user before the
|
||||||
|
irreversible steps. The prod DB is backed up by a cron job (no manual
|
||||||
|
mysqldump needed).
|
||||||
|
|
||||||
|
## Hotfixing a migration added after the tarball was built
|
||||||
|
|
||||||
|
A migration folder created after the release tarball shipped is not on prod,
|
||||||
|
so `prisma migrate deploy` reports "No pending migrations". Ship just the
|
||||||
|
migration folder (still tracked in git — this is NOT raw SQL on prod):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local
|
||||||
|
cd prisma/migrations
|
||||||
|
tar -czf /tmp/<name>_migration.tar.gz <timestamp>_<name>/
|
||||||
|
scp /tmp/<name>_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
|
||||||
|
# On prod
|
||||||
|
ssh boha_admin@192.168.50.100
|
||||||
|
cd /var/www/app-ts/prisma/migrations
|
||||||
|
tar -xzf /tmp/<name>_migration.tar.gz
|
||||||
|
cd /var/www/app-ts
|
||||||
|
npx prisma migrate deploy
|
||||||
|
pm2 restart app-ts --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drift check / preview before deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another
|
||||||
|
DB need its URL in a config/env override.) If drift includes DROPs,
|
||||||
|
investigate before touching production.
|
||||||
|
|
||||||
|
## Baselining a database that has no migrations
|
||||||
|
|
||||||
|
If a database was synced without migration history (no `_prisma_migrations`
|
||||||
|
table): create the initial migration locally, copy `prisma/migrations` to the
|
||||||
|
server, then mark it applied without running SQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate resolve --applied <migration_name>
|
||||||
|
```
|
||||||
3874
docs/superpowers/plans/2026-05-29-warehouse-module.md
Normal file
3874
docs/superpowers/plans/2026-05-29-warehouse-module.md
Normal file
File diff suppressed because it is too large
Load Diff
124
docs/superpowers/plans/2026-06-03-deferred-high-issues.md
Normal file
124
docs/superpowers/plans/2026-06-03-deferred-high-issues.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Deferred HIGH issues — performance & UX sprint
|
||||||
|
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
**Source audit:** Parallel 5-agent production risk audit (2026-06-03)
|
||||||
|
**Context:** All 8 CRITICAL and 3 of 8 HIGH issues were fixed in the same session.
|
||||||
|
The 5 items below were intentionally deferred — they are not data-integrity or
|
||||||
|
auth risks, but they are real production pain that should be addressed in a
|
||||||
|
dedicated performance / UX sprint before the user base notices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #9 — N+1 queries in warehouse list/detail/report
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- `src/services/warehouse.service.ts:184-199` (getBelowMinimumItems)
|
||||||
|
- `src/services/warehouse.service.ts:629-646` (somewhere in items list)
|
||||||
|
- `src/services/warehouse.service.ts:681-683`
|
||||||
|
- `src/services/warehouse.service.ts:2182-2203` (report)
|
||||||
|
|
||||||
|
**Pattern:** `items.map(async (i) => { const stock = await getStock(i.id); const available = await getAvailable(i.id); const value = await getValue(i.id); })` — fires N+1 round-trips per item. For a list of 50 items, that's 150 sequential queries.
|
||||||
|
|
||||||
|
**Production impact:** Slow page loads (1-3s on 50 items, scales linearly). Users already complain about the items page; this is the root cause for the warehouse report being the slowest page in the app.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Replace the `for` loop with a single grouped query: `prisma.sklad_batches.groupBy({ by: ['item_id'], _sum: { quantity: true }, where: { item_id: { in: itemIds } } })` for stocks, similar aggregates for reservations.
|
||||||
|
- Map the grouped results back to items in JS. One query instead of N.
|
||||||
|
- For the report view, do the same — group all batches and reservations by item_id in a single round-trip, then join with items in memory.
|
||||||
|
- Estimated effort: M (4-6 hours including testing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #10 — Missing FK indexes on hot warehouse tables
|
||||||
|
|
||||||
|
**File:** `prisma/schema.prisma` (no `@@index` directives on the new warehouse tables)
|
||||||
|
|
||||||
|
**Missing indexes (verify against `prisma/schema.prisma` and add to a new migration):**
|
||||||
|
|
||||||
|
- `sklad_receipt_lines.item_id` — every `confirmReceipt` reads/writes this
|
||||||
|
- `sklad_issue_lines.batch_id` — every `confirmIssue` reads/writes this
|
||||||
|
- `sklad_issue_lines.item_id`
|
||||||
|
- `sklad_reservations.project_id` — reservation list per project
|
||||||
|
- `sklad_reservations.item_id`
|
||||||
|
- `sklad_item_locations.item_id`
|
||||||
|
- `sklad_item_locations.location_id`
|
||||||
|
- `sklad_batches.receipt_line_id` (if nullable FK)
|
||||||
|
- `sklad_inventory_lines.item_id`
|
||||||
|
- `sklad_inventory_lines.session_id`
|
||||||
|
|
||||||
|
**Production impact:** MySQL currently does table scans for these joins. With even 10k batch rows, the receipts/issues/reservations pages will start to crawl. On production data (likely already 50k+ batches), this is a real performance cliff.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Add `@@index([item_id])`, `@@index([batch_id])` etc. to each model in `prisma/schema.prisma`.
|
||||||
|
- Run `npx prisma migrate dev --name warehouse_fk_indexes` (after asking user to stop dev server per CLAUDE.md).
|
||||||
|
- Run `npx prisma generate`.
|
||||||
|
- Verify with `EXPLAIN` on the slow queries after deploy.
|
||||||
|
- Estimated effort: S (1-2 hours).
|
||||||
|
|
||||||
|
**CLAUDE.md note:** Before running `prisma migrate dev`, the user must stop the dev server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #13 — Pagination does not auto-adjust after delete
|
||||||
|
|
||||||
|
**Files:** All list pages (Invoices, Offers, Projects, Orders, WarehouseXxx)
|
||||||
|
|
||||||
|
**Pattern:** User is on page 5 (e.g. 10 items per page, 47 total, pages 1-5). They delete the last item on page 5. Total becomes 46, but the page param is still 5. The list endpoint returns 0 results, the user sees an empty table, and the "next" button is disabled but there's no automatic jump back to page 4.
|
||||||
|
|
||||||
|
**Production impact:** Confusing UX. Users have to manually click "previous" or reset filters. Doesn't cause data loss but is a small papercut that erodes trust.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- In each list page's `useQuery` error/empty handler, detect `pagination.total === 0 && page > 1` and call `setPage(page - 1)`.
|
||||||
|
- Or more cleanly: have the API include `pagination.total_pages` and the frontend clamp `page` to `min(page, total_pages)` after each refetch.
|
||||||
|
- Estimated effort: S (2-3 hours; touches ~10-12 pages).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #14 — Offer lock lifecycle has multiple silent failures
|
||||||
|
|
||||||
|
**File:** `src/admin/pages/OfferDetail.tsx:472-516`
|
||||||
|
|
||||||
|
**Pattern:** Lock acquire, heartbeat (interval), and unlock all use `.catch(() => {})`. If any of them silently fails, the user thinks they have the lock but actually don't — or worse, the lock is held by a tab that's already closed because the unload handler also swallowed the error.
|
||||||
|
|
||||||
|
**Production impact:** Two users editing the same offer can both see "You have the lock" and clobber each other's changes. Data loss in a real, low-probability but high-impact scenario.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Replace `.catch(() => {})` with `.catch((err) => console.error("Offer lock error:", err))` at minimum.
|
||||||
|
- Better: surface lock acquisition failures via a toast (`alert.error("Nepodařilo se získat zámek, stránka bude pouze pro čtení.")`).
|
||||||
|
- For the unlock on unmount: best-effort `navigator.sendBeacon` or a synchronous fallback so the lock is released even if the page is closing.
|
||||||
|
- Estimated effort: S (1-2 hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #11 (audit item, not a real bug) — TOTP replay counter rewind
|
||||||
|
|
||||||
|
**File investigated:** `src/utils/totp.ts:5-30`, `src/routes/admin/auth.ts:165-184`
|
||||||
|
|
||||||
|
**Status:** False positive. The audit suggested `verifyResult.counter` could be lower than the actual step used. After reading the implementation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const delta = totp.validate({ token: code, window: 1 });
|
||||||
|
// ...
|
||||||
|
const counterDelta = Math.min(delta, 0); // -1 or 0, never +1
|
||||||
|
const counter = currentCounter + counterDelta;
|
||||||
|
```
|
||||||
|
|
||||||
|
`Math.min(delta, 0)` clamps to ≤ 0, so `counter` is always the **current** step or **one step in the past** — never a future step. The `counter <= lastCounter` check in the route is correct and complete. No fix needed.
|
||||||
|
|
||||||
|
If anyone revisits this in the future, this analysis is the basis for closing the ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order for the next sprint
|
||||||
|
|
||||||
|
1. **#10 (indexes)** — cheap, high impact, addresses a growing data cliff. Do first.
|
||||||
|
2. **#9 (N+1)** — bigger effort but the same root cause family. Tackle after indexes.
|
||||||
|
3. **#14 (offer lock)** — small but prevents data loss; fits in a single PR.
|
||||||
|
4. **#13 (pagination)** — UX polish; do last, batch with other UX cleanup.
|
||||||
|
|
||||||
|
Estimated total: 1-2 days of focused work.
|
||||||
3597
docs/superpowers/plans/2026-06-05-plan-praci.md
Normal file
3597
docs/superpowers/plans/2026-06-05-plan-praci.md
Normal file
File diff suppressed because it is too large
Load Diff
1154
docs/superpowers/plans/2026-06-06-manual-project-order-creation.md
Normal file
1154
docs/superpowers/plans/2026-06-06-manual-project-order-creation.md
Normal file
File diff suppressed because it is too large
Load Diff
1406
docs/superpowers/plans/2026-06-06-plan-categories-management.md
Normal file
1406
docs/superpowers/plans/2026-06-06-plan-categories-management.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal file
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
# Warehouse (Sklad) Module Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Approved
|
||||||
|
**Module:** Warehouse/Inventory management for boha-app-ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Full warehouse management module under the Administration section. Tracks material receiving, issuing, reservations, inventory, and project assignment. Uses true FIFO with batch-level tracking, document-driven architecture (header + lines), and integrates with existing projects, number sequences, and NAS file storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Movements
|
||||||
|
|
||||||
|
- **Receipt (Prijem):** Material enters warehouse. Multi-item document with delivery note attachment.
|
||||||
|
- **Issue (Vydej):** Material leaves warehouse, mandatory project assignment. Multi-item document.
|
||||||
|
- **Reservation:** Virtual hold on stock before issue. Reduces available qty but not physical stock. Issue consumes reservation.
|
||||||
|
- **Inventory (Inventura):** Corrective movements based on physical count vs system count.
|
||||||
|
- **Storno:** Cancel a confirmed receipt or issue. Creates opposite corrective movements, preserves audit trail.
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
|
||||||
|
- Each material has: catalog number, name, description, category, unit, min quantity.
|
||||||
|
- Categories are user-defined (CRUD).
|
||||||
|
- Units are fixed list: ks, m, kg, bal, sada, m2, m3, l.
|
||||||
|
- Items can be soft-deactivated (is_active=false).
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
|
||||||
|
- Purchase price per unit, recorded bez DPH.
|
||||||
|
- True FIFO: each receipt line creates a batch. Issues consume oldest batches first.
|
||||||
|
- Batch-level tracking with remaining quantity.
|
||||||
|
|
||||||
|
### Delivery Notes
|
||||||
|
|
||||||
|
- File attachment (PDF/image) uploaded to NAS.
|
||||||
|
- Delivery note number and date stored as metadata on receipt header.
|
||||||
|
|
||||||
|
### Suppliers
|
||||||
|
|
||||||
|
- Separate `sklad_suppliers` table (ICO, DIC, contact info).
|
||||||
|
- Not shared with customers table.
|
||||||
|
|
||||||
|
### Locations
|
||||||
|
|
||||||
|
- Warehouse has named locations/shelves (code + name, e.g. "A1 - Regal A, police 1").
|
||||||
|
- Junction table tracks qty per item per location.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- Every issue is mandatory FK to `projects` table.
|
||||||
|
- No free/issues without project assignment.
|
||||||
|
|
||||||
|
### Min Stock Alerts
|
||||||
|
|
||||||
|
- Each item has optional `min_quantity`.
|
||||||
|
- Dashboard highlights items below minimum.
|
||||||
|
- No email notification (UI alert only).
|
||||||
|
|
||||||
|
### Immutability
|
||||||
|
|
||||||
|
- All movements are immutable once confirmed.
|
||||||
|
- Corrections via storno (cancel receipt/issue) which creates opposite movements.
|
||||||
|
- Draft movements can be edited freely.
|
||||||
|
|
||||||
|
### Numbering
|
||||||
|
|
||||||
|
- Auto-numbering via `number_sequences` on confirm.
|
||||||
|
- Types: `warehouse_receipt` (e.g. PRI-2026-001), `warehouse_issue` (e.g. VYD-2026-001).
|
||||||
|
|
||||||
|
### Issue Documents
|
||||||
|
|
||||||
|
- No PDF generation for issues. Record only in system.
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
- Stock status: all items with qty, min_qty, value, below-min flag.
|
||||||
|
- Project consumption: material consumed per project (filter by project, date range).
|
||||||
|
- Movement log: all receipts + issues with filters.
|
||||||
|
- Below minimum: items under min_quantity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: Document-Driven
|
||||||
|
|
||||||
|
Each movement type is a document (header + lines), matching the existing invoice/offer pattern.
|
||||||
|
|
||||||
|
### Document Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT → CONFIRMED (affects stock)
|
||||||
|
DRAFT → CANCELLED (no stock effect)
|
||||||
|
CONFIRMED → CANCELLED (storno: reverses stock changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only CONFIRMED documents affect `sklad_batches`, `sklad_item_locations`, and reservations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum sklad_receipt_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_issue_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_reservation_status {
|
||||||
|
ACTIVE
|
||||||
|
FULFILLED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_inventory_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Master Data
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_categories {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
sort_order Int @default(0)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
items sklad_items[]
|
||||||
|
|
||||||
|
@@map("sklad_categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_suppliers {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
ico String? @db.VarChar(20)
|
||||||
|
dic String? @db.VarChar(20)
|
||||||
|
contact_person String? @db.VarChar(255)
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
phone String? @db.VarChar(50)
|
||||||
|
address String? @db.Text
|
||||||
|
notes String? @db.Text
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
receipts sklad_receipts[]
|
||||||
|
|
||||||
|
@@map("sklad_suppliers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_locations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique @db.VarChar(20)
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
items sklad_item_locations[]
|
||||||
|
|
||||||
|
@@map("sklad_locations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_items {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_number String? @unique @db.VarChar(50)
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
description String? @db.Text
|
||||||
|
category_id Int?
|
||||||
|
unit String @db.VarChar(20)
|
||||||
|
min_quantity Decimal? @db.Decimal(12, 3)
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
notes String? @db.Text
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
category sklad_categories? @relation(fields: [category_id], references: [id])
|
||||||
|
batches sklad_batches[]
|
||||||
|
item_locations sklad_item_locations[]
|
||||||
|
|
||||||
|
@@index([category_id], map: "sklad_items_category_id")
|
||||||
|
@@map("sklad_items")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FIFO Batches & Location Tracking
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_batches {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
receipt_line_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
original_qty Decimal @db.Decimal(12, 3)
|
||||||
|
unit_price Decimal @db.Decimal(12, 2)
|
||||||
|
received_at DateTime? @db.DateTime(0)
|
||||||
|
is_consumed Boolean @default(false)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
|
||||||
|
|
||||||
|
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
|
||||||
|
@@map("sklad_batches")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_item_locations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
location_id Int
|
||||||
|
quantity Decimal @default(0) @db.Decimal(12, 3)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations @relation(fields: [location_id], references: [id])
|
||||||
|
|
||||||
|
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
|
||||||
|
@@map("sklad_item_locations")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receipts
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_receipts {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_number String? @db.VarChar(50)
|
||||||
|
supplier_id Int?
|
||||||
|
delivery_note_number String? @db.VarChar(100)
|
||||||
|
delivery_note_date DateTime? @db.DateTime(0)
|
||||||
|
received_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_receipt_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
|
||||||
|
received_by_user users? @relation(fields: [received_by], references: [id])
|
||||||
|
lines sklad_receipt_lines[]
|
||||||
|
attachments sklad_receipt_attachments[]
|
||||||
|
|
||||||
|
@@map("sklad_receipts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_receipt_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_id Int
|
||||||
|
item_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
unit_price Decimal @db.Decimal(12, 2)
|
||||||
|
location_id Int?
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
batch sklad_batches?
|
||||||
|
|
||||||
|
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
|
||||||
|
@@map("sklad_receipt_lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_receipt_attachments {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_id Int
|
||||||
|
file_name String @db.VarChar(255)
|
||||||
|
file_mime String @db.VarChar(100)
|
||||||
|
file_size Int
|
||||||
|
file_path String @db.VarChar(500)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
|
||||||
|
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||||
|
|
||||||
|
@@map("sklad_receipt_attachments")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_issues {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issue_number String? @db.VarChar(50)
|
||||||
|
project_id Int
|
||||||
|
issued_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_issue_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
project projects @relation(fields: [project_id], references: [id])
|
||||||
|
issued_by_user users? @relation(fields: [issued_by], references: [id])
|
||||||
|
lines sklad_issue_lines[]
|
||||||
|
|
||||||
|
@@map("sklad_issues")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_issue_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issue_id Int
|
||||||
|
item_id Int
|
||||||
|
batch_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
location_id Int?
|
||||||
|
reservation_id Int?
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
issue sklad_issues @relation(fields: [issue_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
batch sklad_batches @relation(fields: [batch_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
|
||||||
|
|
||||||
|
@@index([issue_id], map: "sklad_issue_lines_issue_id")
|
||||||
|
@@map("sklad_issue_lines")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reservations
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_reservations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
project_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
remaining_qty Decimal @db.Decimal(12, 3)
|
||||||
|
reserved_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_reservation_status @default(ACTIVE)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
project projects @relation(fields: [project_id], references: [id])
|
||||||
|
reserved_by_user users? @relation(fields: [reserved_by], references: [id])
|
||||||
|
issue_lines sklad_issue_lines[]
|
||||||
|
|
||||||
|
@@index([item_id, status], map: "sklad_reservations_item_status")
|
||||||
|
@@map("sklad_reservations")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_inventory_sessions {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
session_number String? @db.VarChar(50)
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_inventory_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
lines sklad_inventory_lines[]
|
||||||
|
|
||||||
|
@@map("sklad_inventory_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_inventory_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
session_id Int
|
||||||
|
item_id Int
|
||||||
|
location_id Int?
|
||||||
|
system_qty Decimal @db.Decimal(12, 3)
|
||||||
|
actual_qty Decimal @db.Decimal(12, 3)
|
||||||
|
difference Decimal @db.Decimal(12, 3)
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
|
||||||
|
@@index([session_id], map: "sklad_inventory_lines_session_id")
|
||||||
|
@@map("sklad_inventory_lines")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total: 13 models + 5 enums**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All under prefix `/api/admin/warehouse`.
|
||||||
|
|
||||||
|
### Items (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------ | ------------------------------------------------------------- |
|
||||||
|
| GET | `/items` | List items (paginated, filter by category, search, below-min) |
|
||||||
|
| GET | `/items/:id` | Item detail with batches, locations, reservations |
|
||||||
|
| POST | `/items` | Create item |
|
||||||
|
| PUT | `/items/:id` | Update item |
|
||||||
|
| DELETE | `/items/:id` | Soft-delete (is_active=false) |
|
||||||
|
|
||||||
|
### Categories (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------- | ------------------------- |
|
||||||
|
| GET | `/categories` | List all |
|
||||||
|
| POST | `/categories` | Create |
|
||||||
|
| PUT | `/categories/:id` | Update |
|
||||||
|
| DELETE | `/categories/:id` | Delete (only if no items) |
|
||||||
|
|
||||||
|
### Suppliers (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------------- | ----------------------------- |
|
||||||
|
| GET | `/suppliers` | List (paginated, search) |
|
||||||
|
| GET | `/suppliers/:id` | Detail |
|
||||||
|
| POST | `/suppliers` | Create |
|
||||||
|
| PUT | `/suppliers/:id` | Update |
|
||||||
|
| DELETE | `/suppliers/:id` | Soft-delete (is_active=false) |
|
||||||
|
|
||||||
|
### Locations (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------------- | ------------------------------------- |
|
||||||
|
| GET | `/locations` | List all |
|
||||||
|
| POST | `/locations` | Create |
|
||||||
|
| PUT | `/locations/:id` | Update |
|
||||||
|
| DELETE | `/locations/:id` | Delete (only if no items at location) |
|
||||||
|
|
||||||
|
### Receipts (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| GET | `/receipts` | List (paginated, filter by status/supplier/date) |
|
||||||
|
| GET | `/receipts/:id` | Detail with lines + attachments |
|
||||||
|
| POST | `/receipts` | Create receipt (DRAFT, with lines) |
|
||||||
|
| PUT | `/receipts/:id` | Update receipt + lines (DRAFT only) |
|
||||||
|
| POST | `/receipts/:id/confirm` | Confirm: creates batches, updates locations, generates number |
|
||||||
|
| POST | `/receipts/:id/cancel` | Cancel: storno batches if confirmed |
|
||||||
|
| POST | `/receipts/:id/attachments` | Upload delivery note (multipart) |
|
||||||
|
| DELETE | `/receipts/:id/attachments/:attachmentId` | Delete attachment |
|
||||||
|
|
||||||
|
### Issues (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| GET | `/issues` | List (paginated, filter by status/project/date) |
|
||||||
|
| GET | `/issues/:id` | Detail with lines |
|
||||||
|
| POST | `/issues` | Create issue (DRAFT, with lines) |
|
||||||
|
| PUT | `/issues/:id` | Update issue + lines (DRAFT only) |
|
||||||
|
| POST | `/issues/:id/confirm` | Confirm: decrements batches, updates locations, fulfills reservations, generates number |
|
||||||
|
| POST | `/issues/:id/cancel` | Cancel: restores batch quantities if confirmed |
|
||||||
|
|
||||||
|
### Reservations (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------------------------- | ------------------------------------ |
|
||||||
|
| GET | `/reservations` | List (filter by item/project/status) |
|
||||||
|
| POST | `/reservations` | Create reservation |
|
||||||
|
| POST | `/reservations/:id/cancel` | Cancel reservation |
|
||||||
|
|
||||||
|
### Inventory (warehouse.inventory)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------------------------- | --------------------------------------- |
|
||||||
|
| GET | `/inventory-sessions` | List sessions |
|
||||||
|
| GET | `/inventory-sessions/:id` | Detail with lines |
|
||||||
|
| POST | `/inventory-sessions` | Create session (system_qty auto-filled) |
|
||||||
|
| PUT | `/inventory-sessions/:id` | Update lines (DRAFT only) |
|
||||||
|
| POST | `/inventory-sessions/:id/confirm` | Confirm: creates corrective movements |
|
||||||
|
|
||||||
|
### Reports (warehouse.view)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------------------ | ---------------------------------------------------- |
|
||||||
|
| GET | `/reports/stock-status` | All items with qty, min_qty, value, below-min flag |
|
||||||
|
| GET | `/reports/project-consumption` | Material per project (filter by project, date range) |
|
||||||
|
| GET | `/reports/movement-log` | All movements with filters |
|
||||||
|
| GET | `/reports/below-minimum` | Items below min_quantity |
|
||||||
|
|
||||||
|
### Utility (warehouse.view)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------------------------- | -------------------------------------- |
|
||||||
|
| GET | `/items/:id/available-qty` | Available qty (total stock - reserved) |
|
||||||
|
| GET | `/items/:id/batches` | FIFO batch queue for item |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Logic
|
||||||
|
|
||||||
|
### Receipt Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. Generate `receipt_number` via `number_sequences` (type: `warehouse_receipt`)
|
||||||
|
3. For each receipt line:
|
||||||
|
- Create `sklad_batches` row (quantity = original_qty = line qty, unit_price from line, received_at = now)
|
||||||
|
- Upsert `sklad_item_locations` (add qty to specified location)
|
||||||
|
4. Update receipt status to CONFIRMED
|
||||||
|
5. Log audit
|
||||||
|
|
||||||
|
### Receipt Cancel
|
||||||
|
|
||||||
|
**If DRAFT:** Set status to CANCELLED. No stock changes.
|
||||||
|
|
||||||
|
**If CONFIRMED (storno):**
|
||||||
|
|
||||||
|
1. Validate no issue lines have consumed batches from this receipt
|
||||||
|
2. For each receipt line / batch:
|
||||||
|
- If batch is fully consumed (is_consumed=true), reject cancellation
|
||||||
|
- Decrement `sklad_item_locations` by batch remaining quantity
|
||||||
|
- Delete the batch row
|
||||||
|
3. Set receipt status to CANCELLED
|
||||||
|
4. Log audit
|
||||||
|
|
||||||
|
### Issue Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. For each issue line, validate: batch has enough remaining quantity
|
||||||
|
3. Generate `issue_number` via `number_sequences` (type: `warehouse_issue`)
|
||||||
|
4. For each issue line:
|
||||||
|
- Decrement `sklad_batches.quantity`, set `is_consumed=true` if 0
|
||||||
|
- Decrement `sklad_item_locations.quantity`
|
||||||
|
- If `reservation_id` set, decrement `sklad_reservations.remaining_qty`
|
||||||
|
5. Check reservations: if remaining_qty = 0, set status to FULFILLED
|
||||||
|
6. Update issue status to CONFIRMED
|
||||||
|
7. Log audit
|
||||||
|
|
||||||
|
### Issue Cancel
|
||||||
|
|
||||||
|
**If DRAFT:** Set status to CANCELLED. No stock changes.
|
||||||
|
|
||||||
|
**If CONFIRMED (storno):**
|
||||||
|
|
||||||
|
1. For each issue line:
|
||||||
|
- Increment `sklad_batches.quantity`, set `is_consumed=false` if was true
|
||||||
|
- Increment `sklad_item_locations.quantity`
|
||||||
|
- If `reservation_id` set, increment `sklad_reservations.remaining_qty`, set status back to ACTIVE if was FULFILLED
|
||||||
|
2. Set issue status to CANCELLED
|
||||||
|
3. Log audit
|
||||||
|
|
||||||
|
### Auto-FIFO on Issue Creation
|
||||||
|
|
||||||
|
When creating an issue line, if user doesn't pick a specific batch:
|
||||||
|
|
||||||
|
- Service selects oldest unconsumed batches for the item (via `sklad_batches_fifo` index)
|
||||||
|
- May span multiple batches if quantity exceeds one batch's remaining qty
|
||||||
|
- Frontend shows available batches for manual override
|
||||||
|
|
||||||
|
### Reservation Creation
|
||||||
|
|
||||||
|
1. Validate item exists and is_active
|
||||||
|
2. Calculate available_qty = total stock qty - sum of active reservation quantities for this item
|
||||||
|
3. Validate available_qty >= requested quantity
|
||||||
|
4. Create reservation with quantity = remaining_qty
|
||||||
|
5. Does NOT modify `sklad_batches` or `sklad_item_locations`
|
||||||
|
|
||||||
|
### Inventory Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. For each inventory line where difference != 0:
|
||||||
|
- If difference > 0 (more stock than system): create a corrective receipt (type: inventory, auto-confirmed)
|
||||||
|
- If difference < 0 (less stock than system): create a corrective issue (type: inventory, auto-confirmed)
|
||||||
|
3. Generate `session_number` via `number_sequences` (type: `warehouse_inventory`)
|
||||||
|
4. Set status to CONFIRMED
|
||||||
|
5. Log audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
Entry in `Sidebar.tsx` with permission `warehouse.view`:
|
||||||
|
|
||||||
|
- Icon: package/box icon
|
||||||
|
- Label: "Sklad"
|
||||||
|
- Path: `/warehouse`
|
||||||
|
|
||||||
|
### Pages (lazy-loaded)
|
||||||
|
|
||||||
|
| Route | Component | Permission |
|
||||||
|
| ------------------------------ | ------------------------ | ------------------- |
|
||||||
|
| `/warehouse` | Warehouse | warehouse.view |
|
||||||
|
| `/warehouse/items` | WarehouseItems | warehouse.view |
|
||||||
|
| `/warehouse/items/:id` | WarehouseItemDetail | warehouse.view |
|
||||||
|
| `/warehouse/receipts` | WarehouseReceipts | warehouse.view |
|
||||||
|
| `/warehouse/receipts/new` | WarehouseReceiptForm | warehouse.operate |
|
||||||
|
| `/warehouse/receipts/:id` | WarehouseReceiptDetail | warehouse.view |
|
||||||
|
| `/warehouse/receipts/:id/edit` | WarehouseReceiptForm | warehouse.operate |
|
||||||
|
| `/warehouse/issues` | WarehouseIssues | warehouse.view |
|
||||||
|
| `/warehouse/issues/new` | WarehouseIssueForm | warehouse.operate |
|
||||||
|
| `/warehouse/issues/:id` | WarehouseIssueDetail | warehouse.view |
|
||||||
|
| `/warehouse/issues/:id/edit` | WarehouseIssueForm | warehouse.operate |
|
||||||
|
| `/warehouse/reservations` | WarehouseReservations | warehouse.view |
|
||||||
|
| `/warehouse/inventory` | WarehouseInventory | warehouse.inventory |
|
||||||
|
| `/warehouse/inventory/new` | WarehouseInventoryForm | warehouse.inventory |
|
||||||
|
| `/warehouse/inventory/:id` | WarehouseInventoryDetail | warehouse.inventory |
|
||||||
|
| `/warehouse/reports` | WarehouseReports | warehouse.view |
|
||||||
|
| `/warehouse/suppliers` | WarehouseSuppliers | warehouse.manage |
|
||||||
|
| `/warehouse/locations` | WarehouseLocations | warehouse.manage |
|
||||||
|
| `/warehouse/categories` | WarehouseCategories | warehouse.manage |
|
||||||
|
|
||||||
|
### Warehouse Dashboard
|
||||||
|
|
||||||
|
- Summary cards: total items, total stock value, below-minimum count, active reservations
|
||||||
|
- Below-minimum alert section (highlighted)
|
||||||
|
- Recent movements (last 10)
|
||||||
|
|
||||||
|
### WarehouseReports
|
||||||
|
|
||||||
|
- Tab-based: Stock Status | Project Consumption | Movement Log | Below Minimum
|
||||||
|
- Each tab has filters (date range, project, category, supplier)
|
||||||
|
- Paginated data tables with sorting
|
||||||
|
|
||||||
|
### Key Shared Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| ItemPicker | Searchable item selector (name + catalog number), used in receipt/issue forms |
|
||||||
|
| BatchPicker | FIFO batch selector for issue lines, shows available qty per batch |
|
||||||
|
| LocationSelect | Location dropdown |
|
||||||
|
| SupplierSelect | Supplier dropdown |
|
||||||
|
| WarehouseMovementTable | Reusable multi-line editor for receipt/issue lines |
|
||||||
|
|
||||||
|
### Query Keys
|
||||||
|
|
||||||
|
```
|
||||||
|
["warehouse"] -> invalidates all
|
||||||
|
["warehouse", "items", filters] -> item list
|
||||||
|
["warehouse", "items", id] -> item detail
|
||||||
|
["warehouse", "receipts", filters] -> receipt list
|
||||||
|
["warehouse", "receipts", id] -> receipt detail
|
||||||
|
["warehouse", "issues", filters] -> issue list
|
||||||
|
["warehouse", "issues", id] -> issue detail
|
||||||
|
["warehouse", "reservations", filters] -> reservation list
|
||||||
|
["warehouse", "inventory", filters] -> inventory sessions
|
||||||
|
["warehouse", "inventory", id] -> inventory detail
|
||||||
|
["warehouse", "suppliers", filters] -> supplier list
|
||||||
|
["warehouse", "locations"] -> location list
|
||||||
|
["warehouse", "categories"] -> category list
|
||||||
|
["warehouse", "reports", type, filters] -> report data
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutation invalidation: broad `["warehouse"]` for any create/update/delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Display Name | Module | Description |
|
||||||
|
| --------------------- | -------------- | ------ | ------------------------------------------------------ |
|
||||||
|
| `warehouse.view` | Zobrazit sklad | sklad | View stock status, items, reports, movement history |
|
||||||
|
| `warehouse.operate` | Prijem a vydej | sklad | Create/confirm receipts, issues, reservations |
|
||||||
|
| `warehouse.manage` | Sprava skladu | sklad | Manage items catalog, suppliers, locations, categories |
|
||||||
|
| `warehouse.inventory` | Inventura | sklad | Create/confirm inventory sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Number Sequences
|
||||||
|
|
||||||
|
Three new types registered in `number_sequences`:
|
||||||
|
|
||||||
|
- `warehouse_receipt` - generated on receipt confirm
|
||||||
|
- `warehouse_issue` - generated on issue confirm
|
||||||
|
- `warehouse_inventory` - generated on inventory session confirm
|
||||||
|
|
||||||
|
Pattern configurable via `company_settings`, same as invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
Receipt attachments use existing `NasFinancialsManager`:
|
||||||
|
|
||||||
|
- Stored under `warehouse/YYYY/MM/` on NAS
|
||||||
|
- DB metadata in `sklad_receipt_attachments` (file_name, file_mime, file_size, file_path)
|
||||||
|
- Upload via `@fastify/multipart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
routes/admin/warehouse.ts
|
||||||
|
services/warehouse.service.ts
|
||||||
|
schemas/warehouse.schema.ts
|
||||||
|
admin/
|
||||||
|
pages/
|
||||||
|
Warehouse.tsx
|
||||||
|
WarehouseItems.tsx
|
||||||
|
WarehouseItemDetail.tsx
|
||||||
|
WarehouseReceipts.tsx
|
||||||
|
WarehouseReceiptDetail.tsx
|
||||||
|
WarehouseReceiptForm.tsx
|
||||||
|
WarehouseIssues.tsx
|
||||||
|
WarehouseIssueDetail.tsx
|
||||||
|
WarehouseIssueForm.tsx
|
||||||
|
WarehouseReservations.tsx
|
||||||
|
WarehouseInventory.tsx
|
||||||
|
WarehouseInventoryDetail.tsx
|
||||||
|
WarehouseInventoryForm.tsx
|
||||||
|
WarehouseReports.tsx
|
||||||
|
WarehouseSuppliers.tsx
|
||||||
|
WarehouseLocations.tsx
|
||||||
|
WarehouseCategories.tsx
|
||||||
|
lib/queries/warehouse.ts
|
||||||
|
components/warehouse/
|
||||||
|
ItemPicker.tsx
|
||||||
|
BatchPicker.tsx
|
||||||
|
LocationSelect.tsx
|
||||||
|
SupplierSelect.tsx
|
||||||
|
WarehouseMovementTable.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ---------------------------------- | ---------------------------------- |
|
||||||
|
| `src/server.ts` | Import + register warehouse routes |
|
||||||
|
| `src/types/index.ts` | Add 8 EntityType values |
|
||||||
|
| `src/admin/AdminApp.tsx` | Lazy imports + routes |
|
||||||
|
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry |
|
||||||
|
| `prisma/schema.prisma` | Add 13 models + 5 enums |
|
||||||
|
| `prisma/seed.ts` | Add 4 permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Entity Types
|
||||||
|
|
||||||
|
New values added to `EntityType` union in `src/types/index.ts`:
|
||||||
|
|
||||||
|
- `warehouse_item`
|
||||||
|
- `warehouse_receipt`
|
||||||
|
- `warehouse_issue`
|
||||||
|
- `warehouse_reservation`
|
||||||
|
- `warehouse_inventory`
|
||||||
|
- `warehouse_supplier`
|
||||||
|
- `warehouse_category`
|
||||||
|
- `warehouse_location`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints & Edge Cases
|
||||||
|
|
||||||
|
1. **Batch consumption on cancel:** If any batch from a receipt has been partially or fully consumed by an issue, the receipt cannot be cancelled. User must first cancel the consuming issues.
|
||||||
|
|
||||||
|
2. **Reservation availability:** Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested.
|
||||||
|
|
||||||
|
3. **Location qty consistency:** `sklad_item_locations` is a denormalized cache for quick lookups. It must always stay in sync with `sklad_batches`. The confirm/cancel transactions maintain this.
|
||||||
|
|
||||||
|
4. **FIFO spanning batches:** When auto-FIFO selects batches for an issue line and the quantity spans multiple batches, the service creates **multiple issue lines** (one per batch consumed), all under the same issue document. The user's original requested quantity is preserved across the split lines. This works because each `sklad_issue_lines` row has exactly one `batch_id`.
|
||||||
|
|
||||||
|
5. **Inventory corrective movements:** On confirm, each line with a difference creates an auto-confirmed receipt or issue. The `notes` field of these corrective documents references the inventory session ID and line, so they can be traced back. These corrective documents are fully audited like manual receipts/issues.
|
||||||
|
|
||||||
|
6. **Unit consistency:** Once an item is created with a unit, it cannot be changed if any movements exist. This prevents unit mismatches in stock calculations.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Manual project & order creation — design
|
||||||
|
|
||||||
|
Date: 2026-06-06
|
||||||
|
Status: approved (pending spec review)
|
||||||
|
Author: Claude + BOHA
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today both entities are only ever _derived_ from a parent:
|
||||||
|
|
||||||
|
- A **project** is created solely as a side-effect of `createOrderFromQuotation`
|
||||||
|
(`orders.service.ts`). There is **no** `POST /projects`, no `createProject`
|
||||||
|
service, no `CreateProjectSchema`. A manual-create feature existed but was
|
||||||
|
**removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only
|
||||||
|
be created through orders (shared numbering sequence)."_ The old code pulled
|
||||||
|
`project_number` from the shared order/project pool, which consumed an order
|
||||||
|
number and left **gaps in order numbering**. That is the bug we must not repeat.
|
||||||
|
- An **order** is normally created from an offer. A manual `createOrder`
|
||||||
|
(`orders.service.ts`, offer-less) **already exists** and the route already
|
||||||
|
dispatches to it, but there is **no UI**, and it does not create a project.
|
||||||
|
|
||||||
|
We want: (1) manually create projects on `/projects`, (2) manually create
|
||||||
|
orders without an offer on `/orders`.
|
||||||
|
|
||||||
|
## Decisions (locked with the user)
|
||||||
|
|
||||||
|
1. **Project numbering: shared pool, _with_ proper tracking.** Standalone
|
||||||
|
projects take the next **shared** number via `generateSharedNumber()` — the
|
||||||
|
same pool as orders — but we add the tracking/transparency that was missing
|
||||||
|
before (see "Tracking" below). Auto-assigned; no manual number entry.
|
||||||
|
2. **Manual order → project: optional checkbox, default ON.** The order form
|
||||||
|
has a pre-checked "Vytvořit propojený projekt"; when set, the order and its
|
||||||
|
project share the order number (identical to the from-offer flow → no gap).
|
||||||
|
3. **Order form scope: header only.** Customer, customer order number,
|
||||||
|
currency/VAT, scope title/description, notes. No line-item editor.
|
||||||
|
4. **Customer: optional** on both forms (matches the existing nullable schema).
|
||||||
|
5. **Match existing house design** — `FormModal` + `FormField`, the
|
||||||
|
`Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx`
|
||||||
|
header-button + customer-selector pattern, existing list/badge styling.
|
||||||
|
|
||||||
|
## Why the shared pool is safe here (the tracking)
|
||||||
|
|
||||||
|
The shared sequence is effectively a single "zakázkové číslo" pool. An order and
|
||||||
|
its project share one number; a standalone project simply takes the next number
|
||||||
|
in that pool and has no order. The mechanics that make this coherent:
|
||||||
|
|
||||||
|
- **Collision-safe:** `isSharedNumberTaken()` already checks **both**
|
||||||
|
`orders.order_number` and `projects.project_number`, so the same number can
|
||||||
|
never be issued twice across the pool (`numbering.service.ts:161-167`).
|
||||||
|
- **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in
|
||||||
|
`prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock
|
||||||
|
(`getNextSequence`) serialises concurrent consumers.
|
||||||
|
- **Release on delete:** `deleteProject` already calls `releaseSharedNumber`,
|
||||||
|
which decrements the sequence only if the deleted number is the current
|
||||||
|
highest — so deleting a standalone project doesn't leave a permanent tail-gap
|
||||||
|
(`numbering.service.ts:116-158, 319-328`).
|
||||||
|
- **Audit trail:** `logAudit` on create/delete records the number in
|
||||||
|
`newValues`/`oldValues`, so every consumed pool number has a "who/when/what"
|
||||||
|
trail showing it went to a project (not a lost order).
|
||||||
|
- **UI transparency:** the Projects list shows a badge — **"z objednávky"**
|
||||||
|
(order-linked, `order_id != null`) vs **"samostatný"** (standalone) — so a
|
||||||
|
pool number with no order reads as intentional. The create form previews the
|
||||||
|
number it is about to assign (`previewSharedNumber()`).
|
||||||
|
|
||||||
|
## Part A — Manual projects
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **`src/schemas/projects.schema.ts`** — add `CreateProjectSchema`
|
||||||
|
(`z.strictObject`, shared coercers from `schemas/common.ts`, Czech messages):
|
||||||
|
- `name` — required, `z.string().min(1)`.
|
||||||
|
- `customer_id` — optional number (coerced, NaN-guarded).
|
||||||
|
- `responsible_user_id` — optional number.
|
||||||
|
- `status` — optional, default `"aktivni"`.
|
||||||
|
- `start_date`, `end_date` — optional date strings.
|
||||||
|
- `notes` — optional string.
|
||||||
|
- **No** `project_number` field (auto-assigned from the pool).
|
||||||
|
- **`src/services/projects.service.ts`** — add `createProject(data)`:
|
||||||
|
- `prisma.$transaction(async (tx) => …)`: `project_number = await
|
||||||
|
generateSharedNumber(tx)`; `tx.projects.create({ … order_id: null,
|
||||||
|
quotation_id: null, status: data.status ?? "aktivni", … })`.
|
||||||
|
- Validate `customer_id` / `responsible_user_id` exist (when provided) →
|
||||||
|
return `{ error, status: 400 }` (Czech) if not.
|
||||||
|
- After commit: `nasFileManager.createProjectFolder(number, name)` when
|
||||||
|
`isConfigured()` — non-fatal, logged on failure (mirrors the old code).
|
||||||
|
- Return `{ data: project }` (or `{ error, status }`).
|
||||||
|
- **`src/routes/admin/projects.ts`**:
|
||||||
|
- `POST /` guarded by `requirePermission("projects.create")` →
|
||||||
|
`parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit`
|
||||||
|
(`action: "create"`, `entityType: "project"`, `newValues`) →
|
||||||
|
`success(reply, project, 201, "Projekt vytvořen")`.
|
||||||
|
- `GET /next-number` guarded by `projects.create` → `previewSharedNumber()` →
|
||||||
|
`success(reply, { number })`. (Preview only; does not consume.)
|
||||||
|
|
||||||
|
### Frontend (`src/admin/pages/Projects.tsx` + new modal)
|
||||||
|
|
||||||
|
- Header **"Přidat projekt"** button, gated on `hasPermission("projects.create")`,
|
||||||
|
matching the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern.
|
||||||
|
- A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField`
|
||||||
|
rows for name (required), customer (select from customers list), responsible
|
||||||
|
user (select from `userListOptions`), status, start/end dates, notes, plus a
|
||||||
|
read-only "Číslo projektu: `<preview>` (přiděleno automaticky)" line fetched
|
||||||
|
from `GET /projects/next-number` when the modal opens.
|
||||||
|
- `useApiMutation` POST `/api/admin/projects`; on success invalidate
|
||||||
|
**`["projects"]`** (broad domain key, per the invalidation convention) and
|
||||||
|
close modal; surface server errors via `useAlert`.
|
||||||
|
- List: add the **"z objednávky" / "samostatný"** badge (derive from
|
||||||
|
`order_id`). Update the empty-state text (currently "Projekt se vytvoří
|
||||||
|
automaticky při vytvoření objednávky") to mention manual creation.
|
||||||
|
|
||||||
|
## Part B — Manual orders (header only, optional project)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **`src/schemas/orders.schema.ts`** — add `create_project` to
|
||||||
|
`CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v
|
||||||
|
=== 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path
|
||||||
|
uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.)
|
||||||
|
- **`src/services/orders.service.ts` `createOrder`** — inside the existing
|
||||||
|
transaction, after the order is created, when `body.create_project` is true and
|
||||||
|
there is no quotation link: `tx.projects.create({ project_number: orderNumber,
|
||||||
|
order_id: order.id, customer_id: order.customer_id, name: scope_title ??
|
||||||
|
customer_order_number ?? orderNumber, status: "aktivni" })` — mirrors
|
||||||
|
`createOrderFromQuotation`'s project block, so order + project share the number.
|
||||||
|
Return the project id so the route can audit it.
|
||||||
|
- **`src/routes/admin/orders.ts`** — the manual `POST /` branch already exists
|
||||||
|
(`orders.create`); add a second `logAudit` for the created project when present.
|
||||||
|
|
||||||
|
### Frontend (`src/admin/pages/Orders.tsx` + new modal)
|
||||||
|
|
||||||
|
- Header **"Vytvořit objednávku"** button, gated on `orders.create`.
|
||||||
|
- Header-only `FormModal`: customer (select), customer order number, currency,
|
||||||
|
VAT rate + apply-VAT, language, scope title, scope description, notes, and a
|
||||||
|
pre-checked **"Vytvořit propojený projekt"** checkbox. Read-only next
|
||||||
|
order-number preview via the existing `GET /api/admin/orders/next-number`.
|
||||||
|
- POST `/api/admin/orders` (no `quotationId`); on success invalidate
|
||||||
|
**`["orders"]`** and **`["projects"]`**; errors via `useAlert`.
|
||||||
|
|
||||||
|
## Part C — Permissions
|
||||||
|
|
||||||
|
`projects.create` was removed from the permission set and must come back the
|
||||||
|
**migration** way (per CLAUDE.md — never seed-only for prod data):
|
||||||
|
|
||||||
|
- New migration `prisma/migrations/<ts>_add_projects_create_permission/` that
|
||||||
|
`INSERT`s the `projects.create` permission row and grants it to the **admin**
|
||||||
|
role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use
|
||||||
|
`INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe.
|
||||||
|
- Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev).
|
||||||
|
- `orders.create` already exists — no change.
|
||||||
|
|
||||||
|
## Part D — Tests (`src/__tests__/`)
|
||||||
|
|
||||||
|
Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style:
|
||||||
|
|
||||||
|
- `createProject` assigns the next shared number, increments the `shared`
|
||||||
|
sequence by exactly 1, sets `order_id = null`, writes an audit row.
|
||||||
|
- `createOrder` with `create_project: true` creates an order **and** a project
|
||||||
|
sharing one `order_number`/`project_number`.
|
||||||
|
- Interleaved coherence: order → standalone project → order produces three
|
||||||
|
consecutive shared numbers with no duplicates (the tracking guarantee).
|
||||||
|
- `createOrder` with `create_project: false` creates only the order.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Service returns `{ error, status }` (Czech) for bad customer/user FK and
|
||||||
|
numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a
|
||||||
|
500 with a Czech message by the route, logged via `app.log.error`).
|
||||||
|
- NAS folder creation is non-fatal and logged (never blocks creation).
|
||||||
|
|
||||||
|
## Out of scope (v1) — noted for later
|
||||||
|
|
||||||
|
- Editing order line-items after creation.
|
||||||
|
- Attaching a standalone project to an order later (conflicts with current
|
||||||
|
`order_id` immutability + the `has_order` delete guard).
|
||||||
|
- Manual project-number override (user chose auto-from-pool).
|
||||||
|
- A unified order+project number lookup/search.
|
||||||
|
|
||||||
|
## Affected files (summary)
|
||||||
|
|
||||||
|
- `src/schemas/projects.schema.ts` (add `CreateProjectSchema`)
|
||||||
|
- `src/schemas/orders.schema.ts` (add `create_project`)
|
||||||
|
- `src/services/projects.service.ts` (add `createProject`)
|
||||||
|
- `src/services/orders.service.ts` (`createOrder` optional project)
|
||||||
|
- `src/routes/admin/projects.ts` (`POST /`, `GET /next-number`)
|
||||||
|
- `src/routes/admin/orders.ts` (audit project on manual order)
|
||||||
|
- `src/admin/pages/Projects.tsx` (+ create modal, badge, empty-state)
|
||||||
|
- `src/admin/pages/Orders.tsx` (+ create modal)
|
||||||
|
- `src/admin/lib/queries/projects.ts`, `orders.ts` (mutations/preview queries)
|
||||||
|
- `prisma/migrations/<ts>_add_projects_create_permission/migration.sql`
|
||||||
|
- `prisma/seed.ts` (+ `projects.create`)
|
||||||
|
- `src/__tests__/manual-create.test.ts` (new)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Plan Categories Management — Design Spec
|
||||||
|
|
||||||
|
Date: 2026-06-06
|
||||||
|
Status: Approved (approach A)
|
||||||
|
Area: Work Plan (`/plan-work`)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let managers (`attendance.manage`) **create, rename, recolor, and retire** the
|
||||||
|
categories used by the work plan, via a "Správa kategorií" button above the
|
||||||
|
calendar that opens a form modal with a per-category color picker. Categories
|
||||||
|
become data, not a hardcoded enum.
|
||||||
|
|
||||||
|
## Current state (what's hardcoded today)
|
||||||
|
|
||||||
|
- DB: `plan_category` **enum** (`work, preparation, travel, leave, sick,
|
||||||
|
training, other`) used by `work_plan_entries.category` and
|
||||||
|
`work_plan_overrides.category` (both `NOT NULL`).
|
||||||
|
- Validation: `planCategoryEnum` in `src/schemas/plan.schema.ts`.
|
||||||
|
- Frontend labels: `PLAN_CATEGORIES` / `planCategoryLabel` in
|
||||||
|
`src/admin/lib/queries/plan.ts`.
|
||||||
|
- Colors: per-category CSS variables `--plan-tape-<key>` in `src/admin/plan.css`,
|
||||||
|
applied via `.plan-cell:has(.plan-chip--<key>)::before` (left "tape" bar) and
|
||||||
|
`.plan-chip--<key>` (chip text/border/background via `color-mix`). 14 rules.
|
||||||
|
- Server audit: `PLAN_CATEGORY_LABELS_CS` in `src/utils/planAuditDescription.ts`.
|
||||||
|
- A decorative paper-grain gradient (`plan.css` ~lines 102–107) tints the page
|
||||||
|
background with `--plan-tape-work` / `--plan-tape-training` at 5%. This is
|
||||||
|
cosmetic and **not** category-semantic.
|
||||||
|
|
||||||
|
## Approach (A)
|
||||||
|
|
||||||
|
Introduce a `plan_categories` table. Keep the entry/override `category` column
|
||||||
|
as a **string key** (slug) referencing `plan_categories.key`. Change the column
|
||||||
|
type from the enum to `VARCHAR(50)`; existing values are preserved verbatim.
|
||||||
|
"Remove" means **deactivate** (`is_active = false`), never hard-delete, so
|
||||||
|
historical entries keep resolving their label and color.
|
||||||
|
|
||||||
|
Rejected: a real FK `category_id` (needs per-row backfill + read/write churn for
|
||||||
|
no user-visible benefit); keeping the enum (can't add categories).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
New table `plan_categories`:
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
| ---------- | ------------ | -------------------------------------------- |
|
||||||
|
| id | INT PK AI | |
|
||||||
|
| key | VARCHAR(50) | UNIQUE, slug, immutable after create |
|
||||||
|
| label | VARCHAR(100) | Czech display name, editable |
|
||||||
|
| color | VARCHAR(7) | `#RRGGBB` |
|
||||||
|
| sort_order | INT | display order; new categories append (max+1) |
|
||||||
|
| is_active | BOOLEAN | default true; false = retired |
|
||||||
|
| created_at | DATETIME | default now |
|
||||||
|
| updated_at | DATETIME | auto |
|
||||||
|
|
||||||
|
Column change: `work_plan_entries.category` and `work_plan_overrides.category`
|
||||||
|
`plan_category` → `VARCHAR(50) NOT NULL`. Drop the `plan_category` enum from the
|
||||||
|
Prisma schema once no column uses it.
|
||||||
|
|
||||||
|
## Migration (one tracked Prisma migration)
|
||||||
|
|
||||||
|
`npx prisma migrate dev --name plan_categories` (requires dev server stopped —
|
||||||
|
ask the user first per CLAUDE.md). The generated `migration.sql` will:
|
||||||
|
|
||||||
|
1. `CREATE TABLE plan_categories (...)`.
|
||||||
|
2. `ALTER` the two `category` columns enum → `VARCHAR(50)`.
|
||||||
|
3. `INSERT` the 7 seed rows (so production gets them — not seed-only, per the
|
||||||
|
migration policy). Seed values (key, label, color, sort_order):
|
||||||
|
|
||||||
|
| key | label | color | sort |
|
||||||
|
| ----------- | -------------- | ------- | ---- |
|
||||||
|
| work | Práce | #2563eb | 1 |
|
||||||
|
| preparation | Příprava | #0d9488 | 2 |
|
||||||
|
| travel | Cesta / Montáž | #ca8a04 | 3 |
|
||||||
|
| leave | Dovolená | #16a34a | 4 |
|
||||||
|
| sick | Nemoc | #dc2626 | 5 |
|
||||||
|
| training | Školení | #7c3aed | 6 |
|
||||||
|
| other | Jiné | #6b7280 | 7 |
|
||||||
|
|
||||||
|
After: `npx prisma generate`, commit schema + migration folder.
|
||||||
|
|
||||||
|
## API — `/api/admin/plan/categories`
|
||||||
|
|
||||||
|
All under the existing plan route file group.
|
||||||
|
|
||||||
|
- `GET /` — returns **all** categories (active + inactive), ordered by
|
||||||
|
`sort_order, id`. Readable by `attendance.record` OR `attendance.manage`
|
||||||
|
(every grid viewer needs labels/colors, including for retired categories on
|
||||||
|
historical entries).
|
||||||
|
- `POST /` — create. `attendance.manage`. Body: `{ label, color }`. `key` is
|
||||||
|
auto-slugged from `label` (ASCII, lowercased, deduped); `sort_order = max+1`.
|
||||||
|
- `PATCH /:id` — update `label` / `color` / `is_active`. `attendance.manage`.
|
||||||
|
`key` is immutable.
|
||||||
|
- `DELETE /:id` — deactivate (`is_active = false`). `attendance.manage`.
|
||||||
|
|
||||||
|
Service layer: `src/services/planCategory.service.ts` (plain async functions,
|
||||||
|
`{ data }` / `{ error, status }` envelope). Audit-logged via `logAudit` with a
|
||||||
|
new entity type `plan_category` (add to `EntityType` and the AuditLog +
|
||||||
|
dashboard `ENTITY_TYPE_LABELS` maps, e.g. "Plán prací – kategorie").
|
||||||
|
|
||||||
|
Validation (`src/schemas/plan.schema.ts` or a new `planCategory.schema.ts`):
|
||||||
|
|
||||||
|
- `label`: required, 1–100 chars, trimmed.
|
||||||
|
- `color`: regex `^#[0-9a-fA-F]{6}$`.
|
||||||
|
- `is_active`: boolean (PATCH only).
|
||||||
|
- Entry/override create/update: `category` becomes `z.string().min(1)`; the
|
||||||
|
**service** validates the key exists and `is_active` (reject unknown/inactive
|
||||||
|
with a Czech 400).
|
||||||
|
|
||||||
|
## Validation rules / edge cases
|
||||||
|
|
||||||
|
- **Slug collision:** if the slug derived from a new label already exists,
|
||||||
|
append `-2`, `-3`, … Keys are never shown to users.
|
||||||
|
- **At least one active category:** deactivating the last active category is
|
||||||
|
rejected (Czech 400) so the create form is never empty.
|
||||||
|
- **Deactivate in use:** allowed. Existing entries keep their key; the category
|
||||||
|
still renders (GET returns inactive ones) but is hidden from the create
|
||||||
|
`<select>`.
|
||||||
|
- **Rename/recolor:** applies to all entries with that key (reference is by
|
||||||
|
key) — this is the desired "recolor everywhere" behavior.
|
||||||
|
- **No hard delete** in this iteration (deactivate only). Possible later
|
||||||
|
enhancement: allow hard-delete when zero entries reference the key.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **Button** "Správa kategorií" in `PlanWork.tsx` header, rendered only when
|
||||||
|
`hasPermission("attendance.manage")`.
|
||||||
|
- **Modal** (`PlanCategoriesModal.tsx`, uses `FormModal`): a row per category =
|
||||||
|
`label` text input + native `<input type="color">` swatch + active toggle (or
|
||||||
|
a "retire/restore" control), plus an "add category" row (label + color +
|
||||||
|
add). Mutations invalidate `["plan", "categories"]` **and** `["plan"]` (so the
|
||||||
|
grid recolors) and `["dashboard"]`/`["audit-log"]` (audit feed).
|
||||||
|
- **Category query** `["plan","categories"]` loaded in `PlanWork`, passed to the
|
||||||
|
grid and cell modal. The create/edit plan `<select>` lists **active**
|
||||||
|
categories from this query (replacing hardcoded `PLAN_CATEGORIES`); display
|
||||||
|
uses the **full** map (so retired categories still render).
|
||||||
|
- `planCategoryLabel` becomes a lookup into the categories map (with raw-key
|
||||||
|
fallback). `PLAN_CATEGORIES` constant is removed.
|
||||||
|
|
||||||
|
## Color rendering (the notable refactor)
|
||||||
|
|
||||||
|
Replace the 14 hardcoded per-key rules with a generic, custom-property-driven
|
||||||
|
treatment:
|
||||||
|
|
||||||
|
- `PlanGrid` sets `style={{ "--cat-color": color }}` on the `.plan-cell`
|
||||||
|
`<button>` when the cell has a category (`color` from the categories map). The
|
||||||
|
custom property cascades to the chip and is available to the cell's `::before`
|
||||||
|
tape.
|
||||||
|
- `plan.css`:
|
||||||
|
- `.plan-cell::before` tape uses
|
||||||
|
`background: var(--cat-color, var(--plan-tape-other))` — the graphite
|
||||||
|
`#6b7280` is the defensive neutral fallback when a cell has no category
|
||||||
|
color (replaces the seven `:has(.plan-chip--<key>)::before` rules).
|
||||||
|
- `.plan-chip` (generic) uses `color: var(--cat-color)`,
|
||||||
|
`border-color: color-mix(in srgb, var(--cat-color) 35%, transparent)`,
|
||||||
|
`background: color-mix(in srgb, var(--cat-color) 10%, var(--plan-paper))`
|
||||||
|
(replaces the seven `.plan-chip--<key>` rules).
|
||||||
|
- Visual output is identical for the existing 7; new categories work with no CSS
|
||||||
|
changes.
|
||||||
|
- The decorative paper-grain gradient stays static (keep two literal colors or
|
||||||
|
the two `--plan-tape-*` vars it needs); it is not category-semantic.
|
||||||
|
|
||||||
|
## Audit description
|
||||||
|
|
||||||
|
`src/services/plan.service.ts` builds the audit description with the category
|
||||||
|
label. Since labels now live in the DB, resolve the label from `plan_categories`
|
||||||
|
by key (alongside the existing `resolvePlanLabels` lookups) and pass the plain
|
||||||
|
label into `buildPlanAuditDescription`. `src/utils/planAuditDescription.ts`'s
|
||||||
|
`buildPlanAuditDescription` stays pure (receives the resolved label);
|
||||||
|
`PLAN_CATEGORY_LABELS_CS`/`planCategoryLabel` there are removed or reduced to a
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vitest (real DB, per project convention):
|
||||||
|
|
||||||
|
- `planCategory.service` CRUD: create (slug generation, sort_order), update
|
||||||
|
(label/color/active), deactivate, and the "last active category" guard.
|
||||||
|
- Color/label validation rejects bad hex and empty label.
|
||||||
|
- Entry create accepts an active key and rejects an unknown/inactive key.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
|
||||||
|
- Per-category icons.
|
||||||
|
- Drag-to-reorder (order by `sort_order, id`; new categories append).
|
||||||
|
- Hard delete of unused categories (deactivate only).
|
||||||
|
|
||||||
|
## Assumptions to confirm
|
||||||
|
|
||||||
|
- "Remove" = deactivate (history preserved). ✅ implied by approach A.
|
||||||
|
- Native browser color picker (`<input type="color">`), not a custom palette.
|
||||||
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`
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.2",
|
"version": "2.4.39",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.2",
|
"version": "2.4.39",
|
||||||
"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.2",
|
"version": "2.4.39",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:pull": "prisma db pull",
|
"db:pull": "prisma db pull",
|
||||||
"db:push": "prisma db push",
|
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
|
||||||
|
ADD COLUMN `locked_by` INTEGER NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `issued_order_sections` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`issued_order_id` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
`title` VARCHAR(500) NULL,
|
||||||
|
`title_cz` VARCHAR(500) NULL,
|
||||||
|
`content` TEXT NULL,
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `issued_order_sections_order_id`(`issued_order_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||||
|
DROP COLUMN `issued_by`,
|
||||||
|
DROP COLUMN `notes`,
|
||||||
|
DROP COLUMN `payment_terms`;
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
|
||||||
|
-- the printed notes block; notes become internal-only. Existing printed notes
|
||||||
|
-- are preserved by merging them into internal_notes before the column drop
|
||||||
|
-- (both are Quill HTML — concatenation is valid markup).
|
||||||
|
|
||||||
|
-- Preserve data: move printed notes into internal_notes
|
||||||
|
UPDATE `invoices`
|
||||||
|
SET `internal_notes` = CASE
|
||||||
|
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
|
||||||
|
ELSE CONCAT(`internal_notes`, `notes`)
|
||||||
|
END
|
||||||
|
WHERE `notes` IS NOT NULL AND `notes` <> '';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` DROP COLUMN `notes`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `invoice_sections` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`invoice_id` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
`title` VARCHAR(500) NULL,
|
||||||
|
`title_cz` VARCHAR(500) NULL,
|
||||||
|
`content` TEXT NULL,
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `invoice_sections_invoice_id`(`invoice_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Suppliers get a structured address (street/city/postal_code/country) like
|
||||||
|
-- customers; the single free-text `address` blob is dropped. Existing data is
|
||||||
|
-- split best-effort: newlines normalized to ", ", first comma segment ->
|
||||||
|
-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city.
|
||||||
|
-- Unparseable one-segment addresses keep their full text in `street`.
|
||||||
|
|
||||||
|
-- AlterTable: add the structured columns
|
||||||
|
ALTER TABLE `sklad_suppliers`
|
||||||
|
ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`,
|
||||||
|
ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`,
|
||||||
|
ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`,
|
||||||
|
ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`;
|
||||||
|
|
||||||
|
-- Preserve data: best-effort split of the legacy free-text address
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET
|
||||||
|
`street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''),
|
||||||
|
`postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'),
|
||||||
|
`city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE(
|
||||||
|
SUBSTRING(
|
||||||
|
REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '),
|
||||||
|
CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2
|
||||||
|
),
|
||||||
|
'[0-9]{3}[ ]?[0-9]{2}', ''))), '')
|
||||||
|
WHERE `address` IS NOT NULL AND `address` <> '';
|
||||||
|
|
||||||
|
-- AlterTable: drop the legacy blob
|
||||||
|
ALTER TABLE `sklad_suppliers` DROP COLUMN `address`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Suppliers become a full mirror of the customers model: dedicated
|
||||||
|
-- contact_person/email/phone/notes columns are replaced by the same
|
||||||
|
-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}],
|
||||||
|
-- "field_order":[]}). Existing contact data is preserved as custom fields.
|
||||||
|
|
||||||
|
-- AlterTable: add the custom_fields blob
|
||||||
|
ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`;
|
||||||
|
|
||||||
|
-- Seed an empty container for rows that carry any contact data
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY())
|
||||||
|
WHERE COALESCE(`contact_person`, '') <> ''
|
||||||
|
OR COALESCE(`email`, '') <> ''
|
||||||
|
OR COALESCE(`phone`, '') <> ''
|
||||||
|
OR COALESCE(`notes`, '') <> '';
|
||||||
|
|
||||||
|
-- Preserve data: each legacy column becomes one labeled custom field
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`contact_person`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`email`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`phone`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`notes`, '') <> '';
|
||||||
|
|
||||||
|
-- AlterTable: drop the dedicated columns
|
||||||
|
ALTER TABLE `sklad_suppliers`
|
||||||
|
DROP COLUMN `contact_person`,
|
||||||
|
DROP COLUMN `email`,
|
||||||
|
DROP COLUMN `phone`,
|
||||||
|
DROP COLUMN `notes`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Odin Phase 2a: assistant turns carry structured metadata (tool-call trace,
|
||||||
|
-- later full content blocks). `content` stays the plain-text display
|
||||||
|
-- projection; `content_json` holds the structured form. Anticipated by the
|
||||||
|
-- Phase-2 design notes (2026-06-08).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `ai_chat_messages` ADD COLUMN `content_json` LONGTEXT NULL AFTER `content`;
|
||||||
@@ -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")
|
||||||
@@ -104,6 +106,7 @@ model ai_chat_messages {
|
|||||||
conversation_id Int
|
conversation_id Int
|
||||||
role String @db.VarChar(20)
|
role String @db.VarChar(20)
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
|
content_json String? @db.LongText
|
||||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -205,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")
|
||||||
@@ -234,11 +238,12 @@ model invoices {
|
|||||||
issued_by String? @db.VarChar(255)
|
issued_by String? @db.VarChar(255)
|
||||||
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)
|
||||||
notes String? @db.Text
|
|
||||||
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[]
|
||||||
|
invoice_sections invoice_sections[]
|
||||||
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
|
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
|
||||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
|
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
|
||||||
|
|
||||||
@@ -249,6 +254,19 @@ model invoices {
|
|||||||
@@index([order_id], map: "order_id")
|
@@index([order_id], map: "order_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model invoice_sections {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
invoice_id Int
|
||||||
|
position Int? @default(0)
|
||||||
|
title String? @db.VarChar(500)
|
||||||
|
title_cz String? @db.VarChar(500)
|
||||||
|
content String? @db.Text
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
|
||||||
|
|
||||||
|
@@index([invoice_id], map: "invoice_sections_invoice_id")
|
||||||
|
}
|
||||||
|
|
||||||
model item_templates {
|
model item_templates {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String? @db.VarChar(255)
|
name String? @db.VarChar(255)
|
||||||
@@ -347,8 +365,6 @@ model orders {
|
|||||||
status String? @default("prijata") @db.VarChar(30)
|
status String? @default("prijata") @db.VarChar(30)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
@@ -372,27 +388,41 @@ model issued_orders {
|
|||||||
supplier_id Int?
|
supplier_id Int?
|
||||||
status issued_orders_status @default(draft)
|
status issued_orders_status @default(draft)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
|
||||||
order_date DateTime? @db.Date
|
order_date DateTime? @db.Date
|
||||||
delivery_date DateTime? @db.Date
|
delivery_date DateTime? @db.Date
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
delivery_terms String? @db.VarChar(500)
|
|
||||||
payment_terms String? @db.VarChar(500)
|
|
||||||
issued_by String? @db.VarChar(255)
|
|
||||||
order_text String? @db.VarChar(500)
|
order_text String? @db.VarChar(500)
|
||||||
notes String? @db.Text
|
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
|
locked_by Int?
|
||||||
|
locked_at DateTime? @db.DateTime(0)
|
||||||
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)
|
||||||
issued_order_items issued_order_items[]
|
issued_order_items issued_order_items[]
|
||||||
|
issued_order_sections issued_order_sections[]
|
||||||
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
||||||
|
|
||||||
@@index([supplier_id], map: "issued_orders_supplier_id")
|
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
|
||||||
|
// rendered on their own PDF page. Kept as a separate table (not shared) so the
|
||||||
|
// FK cascade and per-document ordering stay simple.
|
||||||
|
model issued_order_sections {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issued_order_id Int
|
||||||
|
position Int? @default(0)
|
||||||
|
title String? @db.VarChar(500)
|
||||||
|
title_cz String? @db.VarChar(500)
|
||||||
|
content String? @db.Text
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
|
||||||
|
|
||||||
|
@@index([issued_order_id], map: "issued_order_sections_order_id")
|
||||||
|
}
|
||||||
|
|
||||||
model issued_order_items {
|
model issued_order_items {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
issued_order_id Int
|
issued_order_id Int
|
||||||
@@ -401,7 +431,6 @@ model issued_order_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)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
position Int? @default(0)
|
position Int? @default(0)
|
||||||
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
|
||||||
|
|
||||||
@@ -433,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")
|
||||||
@@ -478,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")
|
||||||
@@ -494,12 +525,11 @@ model quotations {
|
|||||||
valid_until DateTime? @db.Date
|
valid_until DateTime? @db.Date
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
|
||||||
apply_vat Boolean? @default(true)
|
|
||||||
order_id Int?
|
order_id Int?
|
||||||
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)
|
||||||
@@ -787,11 +817,11 @@ model sklad_suppliers {
|
|||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
ico String? @db.VarChar(20)
|
ico String? @db.VarChar(20)
|
||||||
dic String? @db.VarChar(20)
|
dic String? @db.VarChar(20)
|
||||||
contact_person String? @db.VarChar(255)
|
street String? @db.VarChar(255)
|
||||||
email String? @db.VarChar(255)
|
city String? @db.VarChar(255)
|
||||||
phone String? @db.VarChar(50)
|
postal_code String? @db.VarChar(20)
|
||||||
address String? @db.Text
|
country String? @db.VarChar(100)
|
||||||
notes String? @db.Text
|
custom_fields String? @db.LongText
|
||||||
is_active Boolean @default(true)
|
is_active Boolean @default(true)
|
||||||
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)
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ const PERMISSIONS: {
|
|||||||
module: "warehouse",
|
module: "warehouse",
|
||||||
description: "Vytvářet a potvrzovat inventurní sčítkání",
|
description: "Vytvářet a potvrzovat inventurní sčítkání",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// AI asistent (Odin) — mirrors migration 20260608120000_add_ai_assistant;
|
||||||
|
// the seed WIPES permissions, so every migration-added permission must also
|
||||||
|
// live here or a dev reseed silently loses it (happened 2026-06-10).
|
||||||
|
{
|
||||||
|
name: "ai.use",
|
||||||
|
display_name: "AI asistent",
|
||||||
|
module: "ai",
|
||||||
|
description: "Používat AI asistenta (chat a import faktur)",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -381,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1107
src/__tests__/ai-tools.test.ts
Normal file
1107
src/__tests__/ai-tools.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/__tests__/ares.test.ts
Normal file
116
src/__tests__/ares.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { aresLookupByIco, aresSearchByName } from "../services/ares.service";
|
||||||
|
|
||||||
|
// ARES is a true external — mock global fetch (the only allowed mock class).
|
||||||
|
const SUBJECT = {
|
||||||
|
ico: "22599851",
|
||||||
|
obchodniJmeno: "BOHA Automation s.r.o.",
|
||||||
|
dic: "CZ22599851",
|
||||||
|
sidlo: {
|
||||||
|
nazevStatu: "Česká republika",
|
||||||
|
nazevObce: "Turnov",
|
||||||
|
nazevCastiObce: "Turnov",
|
||||||
|
nazevUlice: "Nádražní",
|
||||||
|
cisloDomovni: 485,
|
||||||
|
psc: 51101,
|
||||||
|
textovaAdresa: "Nádražní 485, 51101 Turnov",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockFetchOnce(status: number, body?: unknown) {
|
||||||
|
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aresLookupByIco", () => {
|
||||||
|
it("maps a subject to the normalized prefill shape", async () => {
|
||||||
|
mockFetchOnce(200, SUBJECT);
|
||||||
|
const res = await aresLookupByIco("22599851");
|
||||||
|
expect(res).toEqual({
|
||||||
|
ico: "22599851",
|
||||||
|
dic: "CZ22599851",
|
||||||
|
name: "BOHA Automation s.r.o.",
|
||||||
|
street: "Nádražní 485",
|
||||||
|
city: "Turnov",
|
||||||
|
postal_code: "511 01",
|
||||||
|
country: "Česká republika",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => {
|
||||||
|
mockFetchOnce(200, {
|
||||||
|
...SUBJECT,
|
||||||
|
sidlo: {
|
||||||
|
...SUBJECT.sidlo,
|
||||||
|
cisloOrientacni: 12,
|
||||||
|
cisloOrientacniPismeno: "a",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await aresLookupByIco("22599851");
|
||||||
|
expect("street" in res && res.street).toBe("Nádražní 485/12a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-8-digit IČO without calling ARES", async () => {
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" });
|
||||||
|
expect(await aresLookupByIco("abcdefgh")).toEqual({
|
||||||
|
error: "invalid_ico",
|
||||||
|
});
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an IČO with stray whitespace", async () => {
|
||||||
|
const spy = mockFetchOnce(200, SUBJECT);
|
||||||
|
const res = await aresLookupByIco(" 225 99851 ");
|
||||||
|
expect("ico" in res && res.ico).toBe("22599851");
|
||||||
|
expect(String(spy.mock.calls[0][0])).toContain("/22599851");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 404 to not_found and network failure to ares_unavailable", async () => {
|
||||||
|
mockFetchOnce(404);
|
||||||
|
expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" });
|
||||||
|
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT"));
|
||||||
|
expect(await aresLookupByIco("12345678")).toEqual({
|
||||||
|
error: "ares_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("aresSearchByName", () => {
|
||||||
|
it("maps the result list and sends the name in the POST body", async () => {
|
||||||
|
const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] });
|
||||||
|
const res = await aresSearchByName("BOHA Automation");
|
||||||
|
expect(Array.isArray(res)).toBe(true);
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
expect(res).toHaveLength(1);
|
||||||
|
expect(res[0].name).toBe("BOHA Automation s.r.o.");
|
||||||
|
expect(res[0].postal_code).toBe("511 01");
|
||||||
|
}
|
||||||
|
const init = spy.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||||
|
obchodniJmeno: "BOHA Automation",
|
||||||
|
pocet: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for a blank query without calling ARES", async () => {
|
||||||
|
const spy = vi.spyOn(globalThis, "fetch");
|
||||||
|
expect(await aresSearchByName(" ")).toEqual([]);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps upstream failure to ares_unavailable", async () => {
|
||||||
|
mockFetchOnce(503);
|
||||||
|
expect(await aresSearchByName("BOHA")).toEqual({
|
||||||
|
error: "ares_unavailable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
import {
|
import {
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
previewInvoiceNumber,
|
previewInvoiceNumber,
|
||||||
@@ -19,23 +22,36 @@ import {
|
|||||||
createOffer,
|
createOffer,
|
||||||
updateOffer,
|
updateOffer,
|
||||||
deleteOffer,
|
deleteOffer,
|
||||||
|
invalidateOffer,
|
||||||
|
getOffer,
|
||||||
|
listOffers,
|
||||||
} from "../services/offers.service";
|
} from "../services/offers.service";
|
||||||
|
import quotationsRoutes from "../routes/admin/quotations";
|
||||||
|
|
||||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||||
const issuedOrderIds: number[] = [];
|
const issuedOrderIds: number[] = [];
|
||||||
const invoiceIds: number[] = [];
|
const invoiceIds: number[] = [];
|
||||||
const offerIds: number[] = [];
|
const offerIds: number[] = [];
|
||||||
|
const customerIds: number[] = [];
|
||||||
|
const linkedOrderIds: number[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
for (const id of issuedOrderIds)
|
for (const id of issuedOrderIds)
|
||||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||||
for (const id of invoiceIds)
|
for (const id of invoiceIds)
|
||||||
await prisma.invoices.deleteMany({ where: { id } });
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
|
||||||
|
for (const id of linkedOrderIds)
|
||||||
|
await prisma.orders.deleteMany({ where: { id } });
|
||||||
for (const id of offerIds)
|
for (const id of offerIds)
|
||||||
await prisma.quotations.deleteMany({ where: { id } });
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of customerIds)
|
||||||
|
await prisma.customers.deleteMany({ where: { id } });
|
||||||
issuedOrderIds.length = 0;
|
issuedOrderIds.length = 0;
|
||||||
invoiceIds.length = 0;
|
invoiceIds.length = 0;
|
||||||
|
linkedOrderIds.length = 0;
|
||||||
offerIds.length = 0;
|
offerIds.length = 0;
|
||||||
|
customerIds.length = 0;
|
||||||
// Reset every sequence this suite may have touched so preview values are
|
// Reset every sequence this suite may have touched so preview values are
|
||||||
// stable across tests and we don't leak consumed numbers into other files.
|
// stable across tests and we don't leak consumed numbers into other files.
|
||||||
await prisma.number_sequences.deleteMany({
|
await prisma.number_sequences.deleteMany({
|
||||||
@@ -43,6 +59,14 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** createOffer + narrow the token union + track cleanup. */
|
||||||
|
async function mkOffer(body: Record<string, unknown> = {}) {
|
||||||
|
const res = await createOffer(body);
|
||||||
|
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
|
||||||
|
offerIds.push(res.id);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Issued orders */
|
/* Issued orders */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -283,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
|
|||||||
expect(after).toBe(before);
|
expect(after).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offers — status transition table (service) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("offer status transitions (service)", () => {
|
||||||
|
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
|
||||||
|
const draft = await mkOffer({});
|
||||||
|
const res = await updateOffer(draft.id, { status: "ordered" });
|
||||||
|
expect("error" in res && res.error).toBe("invalid_transition");
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||||
|
expect(row!.status).toBe("draft");
|
||||||
|
expect(row!.quotation_number).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows active -> invalidated via update", async () => {
|
||||||
|
const offer = await mkOffer({ status: "active" });
|
||||||
|
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||||
|
expect(row!.status).toBe("invalidated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
|
||||||
|
const draft = await mkOffer({});
|
||||||
|
const rejected = await invalidateOffer(draft.id);
|
||||||
|
expect("error" in rejected && rejected.error).toBe("invalid_transition");
|
||||||
|
|
||||||
|
const ordered = await mkOffer({ status: "ordered" });
|
||||||
|
const ok = await invalidateOffer(ordered.id);
|
||||||
|
expect("error" in ok).toBe(false);
|
||||||
|
|
||||||
|
// invalidated is terminal — a second invalidate is rejected too.
|
||||||
|
const again = await invalidateOffer(ordered.id);
|
||||||
|
expect("error" in again && again.error).toBe("invalid_transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOffer exposes valid_transitions computed from the current status", async () => {
|
||||||
|
const draft = await mkOffer({});
|
||||||
|
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
|
||||||
|
|
||||||
|
const active = await mkOffer({ status: "active" });
|
||||||
|
expect((await getOffer(active.id))?.valid_transitions).toEqual([
|
||||||
|
"ordered",
|
||||||
|
"invalidated",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offers — editable-state guard (service) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("offer editable-state guard (service)", () => {
|
||||||
|
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
|
||||||
|
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
|
||||||
|
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
|
||||||
|
expect("error" in res && res.error).toBe("not_editable");
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||||
|
expect(row!.project_code).toBe("PŮVODNÍ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a field edit on an invalidated offer", async () => {
|
||||||
|
const offer = await mkOffer({ status: "invalidated" });
|
||||||
|
const res = await updateOffer(offer.id, { scope_title: "X" });
|
||||||
|
expect("error" in res && res.error).toBe("not_editable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows the status-only ordered -> invalidated payload", async () => {
|
||||||
|
const offer = await mkOffer({ status: "ordered" });
|
||||||
|
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||||
|
expect(row!.status).toBe("invalidated");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offers — customer FK handling (service) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("offer customer FK handling (service)", () => {
|
||||||
|
async function mkCustomer() {
|
||||||
|
const c = await prisma.customers.create({
|
||||||
|
data: { name: "drafts_test_zákazník" },
|
||||||
|
});
|
||||||
|
customerIds.push(c.id);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
|
||||||
|
const res = await createOffer({ customer_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("customer_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update rejects a nonexistent customer with a clean token", async () => {
|
||||||
|
const offer = await mkOffer({});
|
||||||
|
const res = await updateOffer(offer.id, { customer_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("customer_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
|
||||||
|
const customer = await mkCustomer();
|
||||||
|
const offer = await mkOffer({ customer_id: customer.id });
|
||||||
|
expect(offer.customer_id).toBe(customer.id);
|
||||||
|
|
||||||
|
const res = await updateOffer(offer.id, { customer_id: null });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||||
|
expect(row!.customer_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offers — deterministic list ordering */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("listOffers deterministic ordering", () => {
|
||||||
|
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
|
||||||
|
const MARK = `TIEBREAK-${Date.now()}`;
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const o = await mkOffer({ project_code: MARK });
|
||||||
|
ids.push(o.id);
|
||||||
|
}
|
||||||
|
// created_at is second-precision; pin all three to the SAME timestamp so
|
||||||
|
// only the id tiebreak can order them.
|
||||||
|
await prisma.quotations.updateMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
data: { created_at: new Date("2026-01-15T10:00:00") },
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = { page: 1, limit: 10, skip: 0, search: MARK };
|
||||||
|
const desc = await listOffers({
|
||||||
|
...base,
|
||||||
|
sort: "created_at",
|
||||||
|
order: "desc",
|
||||||
|
});
|
||||||
|
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||||
|
[...ids].sort((a, b) => b - a),
|
||||||
|
);
|
||||||
|
|
||||||
|
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
|
||||||
|
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||||
|
[...ids].sort((a, b) => a - b),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offers — route-level hardening (Czech messages + status codes) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offers routes — hardening (HTTP)", () => {
|
||||||
|
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||||
|
|
||||||
|
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
|
||||||
|
const offer = await mkOffer({ status: "ordered" });
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/offers/${offer.id}`,
|
||||||
|
headers: auth(),
|
||||||
|
payload: { project_code: "ZMĚNA" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT rejects draft -> ordered with the transition message", async () => {
|
||||||
|
const draft = await mkOffer({});
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/offers/${draft.id}`,
|
||||||
|
headers: auth(),
|
||||||
|
payload: { status: "ordered" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toBe(
|
||||||
|
'Neplatný přechod stavu z "draft" na "ordered"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/offers",
|
||||||
|
headers: auth(),
|
||||||
|
payload: { items: [{ description: "x".repeat(501) }] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/offers",
|
||||||
|
headers: auth(),
|
||||||
|
payload: { customer_id: 99999999 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toBe("Zákazník nenalezen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
|
||||||
|
const offer = await mkOffer({ status: "active" });
|
||||||
|
const order = await prisma.orders.create({
|
||||||
|
data: {
|
||||||
|
order_number: `DRAFTSLINK-${Date.now()}`,
|
||||||
|
quotation_id: offer.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
linkedOrderIds.push(order.id);
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/admin/offers/${offer.id}`,
|
||||||
|
headers: auth(),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json().error).toBe(
|
||||||
|
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||||
|
);
|
||||||
|
// The offer survived the rejected delete.
|
||||||
|
expect(
|
||||||
|
await prisma.quotations.findUnique({ where: { id: offer.id } }),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
|
||||||
|
const draft = await mkOffer({ project_code: "PŘED" });
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/offers/${draft.id}`,
|
||||||
|
headers: auth(),
|
||||||
|
payload: { project_code: "PO" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirst({
|
||||||
|
where: {
|
||||||
|
entity_type: "quotation",
|
||||||
|
entity_id: draft.id,
|
||||||
|
action: "update",
|
||||||
|
},
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
});
|
||||||
|
expect(audit).not.toBeNull();
|
||||||
|
expect(audit!.old_values).toBeTruthy();
|
||||||
|
expect(audit!.new_values).toBeTruthy();
|
||||||
|
expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
|
||||||
|
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
|
||||||
|
// Unnumbered drafts read "koncept", never "null".
|
||||||
|
expect(audit!.description).toContain("koncept");
|
||||||
|
expect(audit!.description).not.toContain("null");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,10 +5,14 @@ import {
|
|||||||
invoiceTotalWithVat,
|
invoiceTotalWithVat,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
updateInvoice,
|
updateInvoice,
|
||||||
|
getInvoice,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
getInvoiceListTotals,
|
getInvoiceListTotals,
|
||||||
} from "../services/invoices.service";
|
} from "../services/invoices.service";
|
||||||
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
import {
|
||||||
|
CreateInvoiceSchema,
|
||||||
|
UpdateInvoiceSchema,
|
||||||
|
} from "../schemas/invoices.schema";
|
||||||
|
|
||||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||||
@@ -168,7 +172,10 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
|
|||||||
invoiceIds.push(draft.id);
|
invoiceIds.push(draft.id);
|
||||||
|
|
||||||
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
|
||||||
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
|
await updateInvoice(
|
||||||
|
draft.id,
|
||||||
|
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
|
||||||
|
);
|
||||||
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||||
expect(row?.billing_text).toBe("Text na PDF");
|
expect(row?.billing_text).toBe("Text na PDF");
|
||||||
|
|
||||||
@@ -182,6 +189,94 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
invoiceIds.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, returns and replaces CZ/EN sections in position order", async () => {
|
||||||
|
const parsed = CreateInvoiceSchema.parse({
|
||||||
|
billing_text: "Sekce test",
|
||||||
|
sections: [
|
||||||
|
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
|
||||||
|
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const draft = await createInvoice(parsed);
|
||||||
|
invoiceIds.push(draft.id);
|
||||||
|
|
||||||
|
const detail = await getInvoice(draft.id);
|
||||||
|
expect(detail?.sections?.length).toBe(2);
|
||||||
|
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
|
||||||
|
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
|
||||||
|
expect(detail?.sections?.[0].position).toBe(0);
|
||||||
|
expect(detail?.sections?.[1].position).toBe(1);
|
||||||
|
|
||||||
|
// Full-replace on update (same model as items).
|
||||||
|
const upd = UpdateInvoiceSchema.parse({
|
||||||
|
sections: [
|
||||||
|
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = await updateInvoice(draft.id, upd);
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
|
||||||
|
const after = await getInvoice(draft.id);
|
||||||
|
expect(after?.sections?.length).toBe(1);
|
||||||
|
expect(after?.sections?.[0].title_cz).toBe("Jediná");
|
||||||
|
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sections survive an items-only update untouched (absent = keep)", async () => {
|
||||||
|
const draft = await createInvoice(
|
||||||
|
CreateInvoiceSchema.parse({
|
||||||
|
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
invoiceIds.push(draft.id);
|
||||||
|
|
||||||
|
await updateInvoice(
|
||||||
|
draft.id,
|
||||||
|
UpdateInvoiceSchema.parse({
|
||||||
|
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const after = await getInvoice(draft.id);
|
||||||
|
expect(after?.sections?.length).toBe(1);
|
||||||
|
expect(after?.sections?.[0].title_cz).toBe("Drží");
|
||||||
|
expect(after?.items?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently strips the dropped `notes` key from legacy payloads", async () => {
|
||||||
|
// The printed-notes column was dropped (notes are internal-only now) —
|
||||||
|
// a legacy client still sending `notes` must not 500 or write anything.
|
||||||
|
const parsedCreate = CreateInvoiceSchema.parse({
|
||||||
|
notes: "<p>stará poznámka</p>",
|
||||||
|
internal_notes: "<p>interní</p>",
|
||||||
|
});
|
||||||
|
expect("notes" in parsedCreate).toBe(false);
|
||||||
|
|
||||||
|
const draft = await createInvoice(parsedCreate);
|
||||||
|
invoiceIds.push(draft.id);
|
||||||
|
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
||||||
|
expect(row?.internal_notes).toBe("<p>interní</p>");
|
||||||
|
|
||||||
|
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
|
||||||
|
expect("notes" in parsedUpdate).toBe(false);
|
||||||
|
const res = await updateInvoice(draft.id, parsedUpdate);
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Month filter boundaries — issue_date is @db.Date */
|
/* Month filter boundaries — issue_date is @db.Date */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,8 +47,6 @@ const baseOrder = {
|
|||||||
status: "prijata" as const,
|
status: "prijata" as const,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
|||||||
status: "active",
|
status: "active",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
createdQuotationIds.push(quotation.id);
|
createdQuotationIds.push(quotation.id);
|
||||||
@@ -146,6 +142,84 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
|||||||
expect(project?.project_number).not.toBe(order?.order_number);
|
expect(project?.project_number).not.toBe(order?.order_number);
|
||||||
expect(project?.order_id).toBe(orderId);
|
expect(project?.order_id).toBe(orderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
||||||
|
const draft = await prisma.quotations.create({
|
||||||
|
data: { status: "draft", currency: "CZK", language: "cs" },
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(draft.id);
|
||||||
|
|
||||||
|
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
if ("error" in res) {
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.error).toContain('ve stavu "draft"');
|
||||||
|
}
|
||||||
|
// The offer is untouched — still a numberless draft.
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||||
|
expect(row!.status).toBe("draft");
|
||||||
|
expect(row!.quotation_number).toBeNull();
|
||||||
|
expect(row!.order_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
||||||
|
const invalidated = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-INVAL-${Date.now()}`,
|
||||||
|
status: "invalidated",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(invalidated.id);
|
||||||
|
|
||||||
|
const res = await createOrderFromQuotation({
|
||||||
|
quotationId: invalidated.id,
|
||||||
|
});
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
if ("error" in res) {
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.error).toContain('ve stavu "invalidated"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteOrder reverts the source offer (stuck-ordered regression)", () => {
|
||||||
|
it("returns the linked quotation to active with order_id cleared", async () => {
|
||||||
|
const quotation = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-REVERT-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(quotation.id);
|
||||||
|
|
||||||
|
const res = await createOrderFromQuotation({ quotationId: quotation.id });
|
||||||
|
if (!("data" in res) || !res.data) failResult(res);
|
||||||
|
const project = await prisma.projects.findFirst({
|
||||||
|
where: { order_id: res.data.order_id },
|
||||||
|
});
|
||||||
|
if (project) createdProjectIds.push(project.id);
|
||||||
|
|
||||||
|
const afterCreate = await prisma.quotations.findUnique({
|
||||||
|
where: { id: quotation.id },
|
||||||
|
});
|
||||||
|
expect(afterCreate!.status).toBe("ordered");
|
||||||
|
expect(afterCreate!.order_id).toBe(res.data.order_id);
|
||||||
|
|
||||||
|
const del = await deleteOrder(res.data.order_id);
|
||||||
|
if ("error" in del) failResult(del);
|
||||||
|
|
||||||
|
// The offer must be orderable again — `ordered` with no order_id is a
|
||||||
|
// dead end (the transition table only allows ordered -> invalidated).
|
||||||
|
const afterDelete = await prisma.quotations.findUnique({
|
||||||
|
where: { id: quotation.id },
|
||||||
|
});
|
||||||
|
expect(afterDelete!.order_id).toBeNull();
|
||||||
|
expect(afterDelete!.status).toBe("active");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteOrder releases the project number (release regression)", () => {
|
describe("deleteOrder releases the project number (release regression)", () => {
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,4 +53,29 @@ describe("NasDocumentManager issued-PDF round-trip", () => {
|
|||||||
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-saving the same number OVERWRITES in place — never a _1 duplicate", () => {
|
||||||
|
const first = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v1"));
|
||||||
|
const second = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v2"));
|
||||||
|
|
||||||
|
// Same canonical path both times, newest content wins.
|
||||||
|
expect(second).toBe(first);
|
||||||
|
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||||
|
expect(fs.readdirSync(dir)).toEqual(["25P0004.pdf"]);
|
||||||
|
expect(nas.readIssued(second!)!.data.toString()).toBe("v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleanIssued also sweeps stale _N duplicates left by the old racy save", () => {
|
||||||
|
nas.saveIssuedPdf("25P0005", 2026, 6, Buffer.from("canonical"));
|
||||||
|
// Simulate duplicates the pre-fix uniquePath save created during races.
|
||||||
|
const dir = path.join(tmpBase, "Vydané", "2026", "06");
|
||||||
|
fs.writeFileSync(path.join(dir, "25P0005_1.pdf"), "stale");
|
||||||
|
fs.writeFileSync(path.join(dir, "25P0005_2.pdf"), "stale");
|
||||||
|
// A DIFFERENT number must not be touched by the suffix match.
|
||||||
|
nas.saveIssuedPdf("25P0006", 2026, 6, Buffer.from("other"));
|
||||||
|
|
||||||
|
nas.cleanIssued("25P0005");
|
||||||
|
|
||||||
|
expect(fs.readdirSync(dir).sort()).toEqual(["25P0006.pdf"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,18 +54,16 @@ function amountFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getOfferTotals per-currency aggregation", () => {
|
describe("getOfferTotals per-currency aggregation", () => {
|
||||||
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums offer NET total per currency over the full filtered set", async () => {
|
||||||
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
|
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
|
||||||
// (enrichQuotation math).
|
// not tax documents, no VAT anywhere).
|
||||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const res = await createOffer({
|
const res = await createOffer({
|
||||||
status: "draft", // draft so no offer number is consumed
|
status: "draft", // draft so no offer number is consumed
|
||||||
currency,
|
currency,
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
project_code: OFFER_MARKER,
|
project_code: OFFER_MARKER,
|
||||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
});
|
});
|
||||||
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
await mk("EUR", 3, 100);
|
await mk("EUR", 3, 100);
|
||||||
|
|
||||||
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a status filter narrows the result", async () => {
|
it("respects the where: a status filter narrows the result", async () => {
|
||||||
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
const res = await createOffer({
|
const res = await createOffer({
|
||||||
status,
|
status,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
project_code: OFFER_MARKER,
|
project_code: OFFER_MARKER,
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
});
|
});
|
||||||
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
|
|||||||
await mk("draft");
|
await mk("draft");
|
||||||
await mk("invalidated");
|
await mk("invalidated");
|
||||||
|
|
||||||
// Marker only: all three count -> 3 x 1210 = 3630.
|
// Marker only: all three count -> 3 x 1000 = 3000 (net).
|
||||||
const all = await getOfferTotals({ search: OFFER_MARKER });
|
const all = await getOfferTotals({ search: OFFER_MARKER });
|
||||||
expect(amountFor(all.totals, "CZK")).toBe(3630);
|
expect(amountFor(all.totals, "CZK")).toBe(3000);
|
||||||
|
|
||||||
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
|
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
|
||||||
const onlyDraft = await getOfferTotals({
|
const onlyDraft = await getOfferTotals({
|
||||||
search: OFFER_MARKER,
|
search: OFFER_MARKER,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
|
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Invalidated alone -> 1210.
|
// Invalidated alone -> 1000.
|
||||||
const onlyInvalid = await getOfferTotals({
|
const onlyInvalid = await getOfferTotals({
|
||||||
search: OFFER_MARKER,
|
search: OFFER_MARKER,
|
||||||
status: "invalidated",
|
status: "invalidated",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
|
|
||||||
// Per-currency total aggregation for both order lists. These hit the real
|
// Per-currency total aggregation for both order lists. These hit the real
|
||||||
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
// `app_test` DB via the service layer (suite convention) and prove the /stats
|
||||||
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
|
// endpoints sum order NET total per currency across the WHOLE filtered set
|
||||||
// set, and that the where (month/year + status) is respected.
|
// (orders are not tax documents — no VAT anywhere), and that the where
|
||||||
|
// (month/year + status) is respected.
|
||||||
//
|
//
|
||||||
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
// A far-future month/year is used so seeded/other-test rows can't fall in the
|
||||||
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
|
||||||
@@ -46,19 +47,17 @@ function amountFor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
||||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums NET total per currency over the full filtered set", async () => {
|
||||||
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
|
// Two CZK orders + one EUR, all in the same far-future month. NET only
|
||||||
// applied on the whole subtotal (enrichOrder math).
|
// (enrichOrder math — orders carry no VAT).
|
||||||
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const res = await createOrder({
|
const res = await createOrder({
|
||||||
status: "prijata",
|
status: "prijata",
|
||||||
currency,
|
currency,
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
create_project: false,
|
create_project: false,
|
||||||
items: [{ description: "X", quantity: qty, unit_price: price }],
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
});
|
});
|
||||||
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a different month and a status filter change the result", async () => {
|
it("respects the where: a different month and a status filter change the result", async () => {
|
||||||
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
status,
|
status,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
create_project: false,
|
create_project: false,
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
});
|
});
|
||||||
@@ -122,48 +119,43 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
|
|||||||
await mk("stornovana", 0);
|
await mk("stornovana", 0);
|
||||||
await mk("prijata", 1);
|
await mk("prijata", 1);
|
||||||
|
|
||||||
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
|
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
|
||||||
const whole = await getOrderTotals({
|
const whole = await getOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Status filter narrows to the single 'prijata' in the target month -> 1210.
|
// Status filter narrows to the single 'prijata' in the target month -> 1000.
|
||||||
const onlyPrijata = await getOrderTotals({
|
const onlyPrijata = await getOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
status: "prijata",
|
status: "prijata",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
|
||||||
|
|
||||||
// Next month sees only the one 'prijata' there -> 1210.
|
// Next month sees only the one 'prijata' there -> 1000.
|
||||||
const nextMonth = await getOrderTotals({
|
const nextMonth = await getOrderTotals({
|
||||||
month: STATS_MONTH + 1,
|
month: STATS_MONTH + 1,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
|
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
|
||||||
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
|
it("sums NET total per currency over the full filtered set", async () => {
|
||||||
// order_date is settable at create, so no post-create pin needed. VAT is
|
// order_date is settable at create, so no post-create pin needed. NET only
|
||||||
// applied per-line (computeIssuedOrderTotals) but with a single line per
|
// (computeIssuedOrderTotals — issued orders carry no VAT).
|
||||||
// order here the result matches the on-the-whole subtotal math.
|
// CZK #1: 2 x 1000 = 2000
|
||||||
// CZK #1: 2 x 1000 @21% = 2420
|
// CZK #2: 1 x 500 = 500 => CZK total 2500
|
||||||
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
|
// EUR : 3 x 100 = 300 => EUR total 300
|
||||||
// EUR : 3 x 100 @21% = 363 => EUR total 363
|
|
||||||
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||||
const mk = async (currency: string, qty: number, price: number) => {
|
const mk = async (currency: string, qty: number, price: number) => {
|
||||||
const o = await createIssuedOrder({
|
const o = await createIssuedOrder({
|
||||||
currency,
|
currency,
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: dateStr,
|
order_date: dateStr,
|
||||||
items: [
|
items: [{ description: "X", quantity: qty, unit_price: price }],
|
||||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||||
createdIssuedIds.push(o.id);
|
createdIssuedIds.push(o.id);
|
||||||
@@ -178,23 +170,19 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(totals, "CZK")).toBe(3025);
|
expect(amountFor(totals, "CZK")).toBe(2500);
|
||||||
expect(amountFor(totals, "EUR")).toBe(363);
|
expect(amountFor(totals, "EUR")).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects the where: a different month and a status filter change the result", async () => {
|
it("respects the where: a different month and a status filter change the result", async () => {
|
||||||
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
|
||||||
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
|
||||||
|
|
||||||
// Target month: one draft, one already-sent (both 1210). Next month: one.
|
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
|
||||||
const draft = await createIssuedOrder({
|
const draft = await createIssuedOrder({
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: inMonth,
|
order_date: inMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
@@ -203,12 +191,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
const sent = await createIssuedOrder({
|
const sent = await createIssuedOrder({
|
||||||
status: "sent",
|
status: "sent",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: inMonth,
|
order_date: inMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in sent)
|
if ("error" in sent)
|
||||||
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||||
@@ -216,37 +200,33 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
|
|
||||||
const next = await createIssuedOrder({
|
const next = await createIssuedOrder({
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
order_date: nextMonth,
|
order_date: nextMonth,
|
||||||
items: [
|
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
|
||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
if ("error" in next)
|
if ("error" in next)
|
||||||
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||||
createdIssuedIds.push(next.id);
|
createdIssuedIds.push(next.id);
|
||||||
|
|
||||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
// Whole target month: both count -> 2 x 1000 = 2000 (net).
|
||||||
const whole = await getIssuedOrderTotals({
|
const whole = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(whole.totals, "CZK")).toBe(2420);
|
expect(amountFor(whole.totals, "CZK")).toBe(2000);
|
||||||
|
|
||||||
// Status filter narrows to the single 'sent' in the target month -> 1210.
|
// Status filter narrows to the single 'sent' in the target month -> 1000.
|
||||||
const onlySent = await getIssuedOrderTotals({
|
const onlySent = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH,
|
month: STATS_MONTH,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
status: "sent",
|
status: "sent",
|
||||||
});
|
});
|
||||||
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
|
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
|
||||||
|
|
||||||
// Next month sees only the one order there -> 1210.
|
// Next month sees only the one order there -> 1000.
|
||||||
const nextStats = await getIssuedOrderTotals({
|
const nextStats = await getIssuedOrderTotals({
|
||||||
month: STATS_MONTH + 1,
|
month: STATS_MONTH + 1,
|
||||||
year: STATS_YEAR,
|
year: STATS_YEAR,
|
||||||
});
|
});
|
||||||
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
|
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ const baseOrder = {
|
|||||||
status: "prijata" as const,
|
status: "prijata" as const,
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
language: "cs",
|
language: "cs",
|
||||||
vat_rate: 21,
|
|
||||||
apply_vat: true,
|
|
||||||
exchange_rate: 1,
|
exchange_rate: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
247
src/__tests__/pdf-no-vat.test.ts
Normal file
247
src/__tests__/pdf-no-vat.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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 { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||||
|
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||||
|
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||||
|
import { createInvoice } from "../services/invoices.service";
|
||||||
|
|
||||||
|
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||||
|
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||||
|
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
|
||||||
|
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
|
||||||
|
// is covered in issued-orders.test.ts.)
|
||||||
|
|
||||||
|
// The explanatory prices-excl.-VAT notice was removed at user request — the
|
||||||
|
// total label alone carries the information. Pin its absence too.
|
||||||
|
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
|
||||||
|
const EN_NOTE = "Prices are exclusive of VAT";
|
||||||
|
|
||||||
|
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||||
|
const order = {
|
||||||
|
order_number: "26730042",
|
||||||
|
customer_order_number: "PO-XYZ",
|
||||||
|
created_at: new Date("2026-06-09T12:00:00"),
|
||||||
|
currency: "CZK",
|
||||||
|
notes: null,
|
||||||
|
customers: null,
|
||||||
|
};
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
description: "Materiál",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 100,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain(">DPH<");
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
// Line total = netto (2 × 100), no VAT-on-top anywhere.
|
||||||
|
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
expect(html).not.toContain(CS_NOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
|
||||||
|
expect(html).not.toContain("VAT%");
|
||||||
|
expect(html).toContain("Total excl. VAT");
|
||||||
|
expect(html).not.toContain(EN_NOTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes not-included lines from the NET total", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(
|
||||||
|
order,
|
||||||
|
[
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
description: "Mimo cenu",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 999,
|
||||||
|
is_included_in_total: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
"Jan",
|
||||||
|
);
|
||||||
|
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||||
|
expect(html).toContain("200,00 CZK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||||||
|
const html = renderOrderConfirmationHtml(
|
||||||
|
order,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
description: "Velká položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
"Jan",
|
||||||
|
);
|
||||||
|
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||||||
|
expect(html).toContain("2 000,00 CZK");
|
||||||
|
expect(html).not.toContain("1 199,00 CZK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Offer PDF route (returns the HTML for the print view) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
const createdQuotationIds: number[] = [];
|
||||||
|
const createdInvoiceIds: 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 createdQuotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of createdInvoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||||
|
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
|
||||||
|
// Direct prisma insert (explicit number → no sequence consumed).
|
||||||
|
const quotation = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-VATNOTE-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(quotation.id);
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const html = res.body;
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
expect(html).not.toContain(CS_NOTE);
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
expect(html).not.toContain("Celkem k úhradě");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||||||
|
const quotation = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Půldenní sazba",
|
||||||
|
quantity: 1.5,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 1000,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdQuotationIds.push(quotation.id);
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const html = res.body;
|
||||||
|
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||||||
|
// decimals elsewhere (covered by the previous test's qty 2).
|
||||||
|
expect(html).toContain("1,50 / ks");
|
||||||
|
expect(html).not.toContain(">2 / ks<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||||||
|
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||||||
|
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||||||
|
const invoice = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
apply_vat: true,
|
||||||
|
vat_rate: 21,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "PDF-SHARED-MARKER",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1000,
|
||||||
|
vat_rate: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
createdInvoiceIds.push(invoice.id);
|
||||||
|
|
||||||
|
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("FAKTURA - DAŇOVÝ DOKLAD");
|
||||||
|
expect(html).toContain("PDF-SHARED-MARKER");
|
||||||
|
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||||||
|
expect(html).toContain("2 000,00");
|
||||||
|
expect(html).toContain("Celkem k úhradě");
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CreateQuotationSchema", () => {
|
describe("CreateQuotationSchema", () => {
|
||||||
it("rejects NaN string in top-level vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: "bad",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid vat_rate", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: 21,
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coerces a valid numeric STRING vat_rate to a number", () => {
|
|
||||||
const result = CreateQuotationSchema.safeParse({
|
|
||||||
customer_id: 1,
|
|
||||||
vat_rate: "21",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.data.vat_rate).toBe(21);
|
|
||||||
expect(typeof result.data.vat_rate).toBe("number");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects NaN string in item quantity", () => {
|
it("rejects NaN string in item quantity", () => {
|
||||||
const result = CreateQuotationSchema.safeParse({
|
const result = CreateQuotationSchema.safeParse({
|
||||||
customer_id: 1,
|
customer_id: 1,
|
||||||
@@ -113,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",
|
||||||
});
|
});
|
||||||
@@ -125,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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -268,18 +242,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
|
it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
|
||||||
expect(
|
// email/phone/contact_person/notes/address columns were replaced by the
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
|
// customers-model custom_fields blob (2026-06); legacy clients still
|
||||||
).toBe(true);
|
// sending them must not 400 — z.object strips unknown keys.
|
||||||
expect(
|
const parsed = CreateSupplierSchema.safeParse({
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
|
name: "Dodavatel",
|
||||||
.success,
|
email: "x@y.cz",
|
||||||
).toBe(true);
|
phone: "123",
|
||||||
expect(
|
contact_person: "Jan",
|
||||||
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
|
notes: "n",
|
||||||
.success,
|
address: "Ulice 1",
|
||||||
).toBe(false);
|
custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
if (parsed.success) {
|
||||||
|
expect("email" in parsed.data).toBe(false);
|
||||||
|
expect("address" in parsed.data).toBe(false);
|
||||||
|
expect(parsed.data.custom_fields).toHaveLength(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
|
||||||
@@ -300,8 +281,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
|
|||||||
expect(b.success).toBe(true);
|
expect(b.success).toBe(true);
|
||||||
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
|
||||||
// Genuinely malformed dates are still rejected.
|
// Genuinely malformed dates are still rejected.
|
||||||
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
|
expect(
|
||||||
false,
|
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
|
||||||
);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
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";
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
|
||||||
const Odin = lazy(() => import("./pages/Odin"));
|
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
||||||
const Users = lazy(() => import("./pages/Users"));
|
const Users = lazyWithReload(() => import("./pages/Users"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
||||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
const AttendanceHistory = lazyWithReload(
|
||||||
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin"));
|
() => import("./pages/AttendanceHistory"),
|
||||||
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances"));
|
);
|
||||||
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate"));
|
const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
|
||||||
const LeaveRequests = lazy(() => import("./pages/LeaveRequests"));
|
const AttendanceBalances = lazyWithReload(
|
||||||
const LeaveApproval = lazy(() => import("./pages/LeaveApproval"));
|
() => import("./pages/AttendanceBalances"),
|
||||||
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));
|
);
|
||||||
const PlanWork = lazy(() => import("./pages/PlanWork"));
|
const AttendanceCreate = lazyWithReload(
|
||||||
const Trips = lazy(() => import("./pages/Trips"));
|
() => import("./pages/AttendanceCreate"),
|
||||||
const TripsHistory = lazy(() => import("./pages/TripsHistory"));
|
);
|
||||||
const TripsAdmin = lazy(() => import("./pages/TripsAdmin"));
|
const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
|
||||||
const Vehicles = lazy(() => import("./pages/Vehicles"));
|
const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
|
||||||
const Offers = lazy(() => import("./pages/Offers"));
|
const AttendanceLocation = lazyWithReload(
|
||||||
const OfferDetail = lazy(() => import("./pages/OfferDetail"));
|
() => import("./pages/AttendanceLocation"),
|
||||||
const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
|
);
|
||||||
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
|
const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
|
||||||
const Orders = lazy(() => import("./pages/Orders"));
|
const Trips = lazyWithReload(() => import("./pages/Trips"));
|
||||||
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
|
const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
|
||||||
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
|
const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
|
||||||
const Projects = lazy(() => import("./pages/Projects"));
|
const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
|
||||||
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
|
const Offers = lazyWithReload(() => import("./pages/Offers"));
|
||||||
const Invoices = lazy(() => import("./pages/Invoices"));
|
const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
|
||||||
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail"));
|
const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
|
||||||
const Settings = lazy(() => import("./pages/Settings"));
|
const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
|
||||||
const AuditLog = lazy(() => import("./pages/AuditLog"));
|
const Orders = lazyWithReload(() => import("./pages/Orders"));
|
||||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
|
||||||
const Warehouse = lazy(() => import("./pages/Warehouse"));
|
const IssuedOrderDetail = lazyWithReload(
|
||||||
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
|
() => import("./pages/IssuedOrderDetail"),
|
||||||
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
|
);
|
||||||
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
|
const Projects = lazyWithReload(() => import("./pages/Projects"));
|
||||||
const WarehouseReceiptDetail = lazy(
|
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>
|
||||||
|
|||||||
153
src/admin/components/AresLookup.tsx
Normal file
153
src/admin/components/AresLookup.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useRef } from "react";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
|
/** Normalized company record returned by the /api/admin/ares proxy. */
|
||||||
|
export interface AresCompany {
|
||||||
|
ico: string;
|
||||||
|
dic: string | null;
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postal_code: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AresAdornmentProps {
|
||||||
|
/** "ico" looks the query up directly; "name" searches and offers a picker. */
|
||||||
|
mode: "ico" | "name";
|
||||||
|
/** Current value of the host field (IČO or name fragment). */
|
||||||
|
query: string;
|
||||||
|
/** Called with the chosen company — the modal prefills its form from it. */
|
||||||
|
onFill: (company: AresCompany) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "ARES" end-adornment button for the Název/IČO fields of the customer and
|
||||||
|
* supplier modals. IČO mode fetches the subject directly; name mode searches
|
||||||
|
* ARES and shows a result picker (filling straight away on a single hit).
|
||||||
|
* Pass via the TextField's `InputProps.endAdornment`.
|
||||||
|
*/
|
||||||
|
export default function AresAdornment({
|
||||||
|
mode,
|
||||||
|
query,
|
||||||
|
onFill,
|
||||||
|
}: AresAdornmentProps) {
|
||||||
|
const alert = useAlert();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<AresCompany[]>([]);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const enabled =
|
||||||
|
mode === "ico"
|
||||||
|
? /^\d{8}$/.test(trimmed.replace(/\s+/g, ""))
|
||||||
|
: trimmed.length >= 2;
|
||||||
|
const tooltip =
|
||||||
|
mode === "ico"
|
||||||
|
? enabled
|
||||||
|
? "Načíst údaje z ARES podle IČO"
|
||||||
|
: "Vyplňte 8místné IČO"
|
||||||
|
: enabled
|
||||||
|
? "Vyhledat firmu v ARES podle názvu"
|
||||||
|
: "Vyplňte alespoň 2 znaky názvu";
|
||||||
|
|
||||||
|
const lookup = async () => {
|
||||||
|
if (!enabled || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (mode === "ico") {
|
||||||
|
const company = await jsonQuery<AresCompany>(
|
||||||
|
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
|
||||||
|
);
|
||||||
|
onFill(company);
|
||||||
|
} else {
|
||||||
|
const found = await jsonQuery<AresCompany[]>(
|
||||||
|
`/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`,
|
||||||
|
);
|
||||||
|
if (found.length === 0) {
|
||||||
|
alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena");
|
||||||
|
} else if (found.length === 1) {
|
||||||
|
onFill(found[0]);
|
||||||
|
} else {
|
||||||
|
setResults(found);
|
||||||
|
setMenuOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(
|
||||||
|
e instanceof Error ? e.message : "Registr ARES je nedostupný",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
size="small"
|
||||||
|
onClick={lookup}
|
||||||
|
disabled={!enabled || loading}
|
||||||
|
aria-label="Načíst z ARES"
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
color: enabled ? "primary.main" : "text.disabled",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ARES
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorRef.current}
|
||||||
|
open={menuOpen}
|
||||||
|
onClose={() => setMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{results.map((c) => (
|
||||||
|
<MenuItem
|
||||||
|
key={c.ico}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
onFill(c);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{c.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
IČO {c.ico}
|
||||||
|
{c.city ? ` · ${c.city}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</InputAdornment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles";
|
|||||||
import { Modal, Button, TextField, Field } from "../ui";
|
import { Modal, Button, TextField, Field } from "../ui";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
|
// Editable line-item. quantity/unit_price are held as the raw typed string
|
||||||
// string while editing (so a field can be cleared — empty renders fine in a
|
// while editing (so a field can be cleared — empty renders fine in a
|
||||||
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
// type="number" input) and are coerced to numbers in handleEditGenerate before
|
||||||
// being handed to onGenerate (the parent posts them verbatim).
|
// being handed to onGenerate (the parent posts them verbatim).
|
||||||
interface ConfirmationItem {
|
interface ConfirmationItem {
|
||||||
@@ -17,7 +17,6 @@ interface ConfirmationItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
vat_rate: string | number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
// Numeric shape handed to the parent (the raw editing strings are coerced in
|
||||||
@@ -28,21 +27,14 @@ interface GeneratedItem {
|
|||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
vat_rate: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrderConfirmationModalProps {
|
interface OrderConfirmationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGenerate: (
|
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
|
||||||
lang: string,
|
|
||||||
applyVat: boolean,
|
|
||||||
items?: GeneratedItem[],
|
|
||||||
) => Promise<void>;
|
|
||||||
initialItems: ConfirmationItem[];
|
initialItems: ConfirmationItem[];
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
defaultVatRate: number;
|
|
||||||
applyVat: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrderConfirmationModal({
|
export default function OrderConfirmationModal({
|
||||||
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
|
|||||||
onGenerate,
|
onGenerate,
|
||||||
initialItems,
|
initialItems,
|
||||||
orderNumber,
|
orderNumber,
|
||||||
defaultVatRate,
|
|
||||||
applyVat,
|
|
||||||
}: OrderConfirmationModalProps) {
|
}: OrderConfirmationModalProps) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||||
const [lang, setLang] = useState<string>("cs");
|
const [lang, setLang] = useState<string>("cs");
|
||||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
|
||||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
setLang("cs");
|
setLang("cs");
|
||||||
setApplyVatState(applyVat);
|
|
||||||
setItems(initialItems);
|
setItems(initialItems);
|
||||||
// initialItems/applyVat are captured at open time; intentionally not in the
|
// initialItems are captured at open time; intentionally not in the dep
|
||||||
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
// array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleUseExisting = async () => {
|
const handleUseExisting = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onGenerate(lang, applyVatState, undefined);
|
await onGenerate(lang, undefined);
|
||||||
// Only close on success — a generation error must keep the modal open.
|
// Only close on success — a generation error must keep the modal open.
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
|
|||||||
unit: it.unit,
|
unit: it.unit,
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
is_included_in_total: it.is_included_in_total,
|
is_included_in_total: it.is_included_in_total,
|
||||||
vat_rate: Number(it.vat_rate) || 0,
|
|
||||||
}));
|
}));
|
||||||
await onGenerate(lang, applyVatState, coercedItems);
|
await onGenerate(lang, coercedItems);
|
||||||
// Only close on success — on error keep the user's edited items intact.
|
// Only close on success — on error keep the user's edited items intact.
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
|
|||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
vat_rate: defaultVatRate,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [defaultVatRate]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
|
|||||||
</Box>
|
</Box>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="DPH">
|
|
||||||
<Box sx={{ display: "flex", gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={applyVatState ? "contained" : "outlined"}
|
|
||||||
color={applyVatState ? "primary" : "inherit"}
|
|
||||||
onClick={() => setApplyVatState(true)}
|
|
||||||
>
|
|
||||||
S DPH
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={!applyVatState ? "contained" : "outlined"}
|
|
||||||
color={!applyVatState ? "primary" : "inherit"}
|
|
||||||
onClick={() => setApplyVatState(false)}
|
|
||||||
>
|
|
||||||
Bez DPH
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Obsah potvrzení">
|
<Field label="Obsah potvrzení">
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
Jak chcete připravit potvrzení objednávky?
|
Jak chcete připravit potvrzení objednávky?
|
||||||
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
|
|||||||
}
|
}
|
||||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
|
||||||
label="%DPH"
|
|
||||||
type="number"
|
|
||||||
value={item.vat_rate ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "vat_rate", e.target.value)
|
|
||||||
}
|
|
||||||
slotProps={{ htmlInput: { step: "1" } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
|
|||||||
<th>Mn.</th>
|
<th>Mn.</th>
|
||||||
<th>Jedn.</th>
|
<th>Jedn.</th>
|
||||||
<th>Cena</th>
|
<th>Cena</th>
|
||||||
<th>%DPH</th>
|
|
||||||
<th style={{ width: "40px" }} />
|
<th style={{ width: "40px" }} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
|
|||||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
value={item.vat_rate ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateItem(i, "vat_rate", e.target.value)
|
|
||||||
}
|
|
||||||
sx={{ width: 80 }}
|
|
||||||
slotProps={{ htmlInput: { step: "1" } }}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled, useTheme } from "@mui/material/styles";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import {
|
import {
|
||||||
GridData,
|
GridData,
|
||||||
@@ -11,7 +12,8 @@ import {
|
|||||||
} from "../lib/queries/plan";
|
} from "../lib/queries/plan";
|
||||||
import type { Project } from "../lib/queries/projects";
|
import type { Project } from "../lib/queries/projects";
|
||||||
import PlanRangeChips from "./PlanRangeChips";
|
import PlanRangeChips from "./PlanRangeChips";
|
||||||
import { LoadingState } from "../ui";
|
import { LoadingState, Select, Field } from "../ui";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { fonts } from "../theme";
|
import { fonts } from "../theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
|
|||||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
boxShadow: theme.shadows[2],
|
boxShadow: theme.shadows[2],
|
||||||
maxHeight: "calc(100dvh - 240px)",
|
// Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
|
||||||
|
// ~360-400px, so the bare calc left <160px and the cells disappeared
|
||||||
|
// under the sticky header. The grid scrolls internally either way.
|
||||||
|
maxHeight: "max(calc(100dvh - 240px), 360px)",
|
||||||
isolation: "isolate",
|
isolation: "isolate",
|
||||||
|
|
||||||
"& .plan-grid": {
|
"& .plan-grid": {
|
||||||
@@ -464,6 +469,15 @@ export default function PlanGrid({
|
|||||||
pulseKey,
|
pulseKey,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
|
||||||
|
// a person picker shows ONE column at a time (Datum + selected person).
|
||||||
|
// Desktop/tablet keeps all columns.
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
const { user: authUser } = useAuth();
|
||||||
|
// null = "no explicit choice yet" → defaults to the logged-in user when
|
||||||
|
// they are in the plan, else the first person.
|
||||||
|
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
|
||||||
const today = useMemo(() => todayIso(), []);
|
const today = useMemo(() => todayIso(), []);
|
||||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||||
// Index projects by id once per projects change instead of a linear
|
// Index projects by id once per projects change instead of a linear
|
||||||
@@ -477,13 +491,23 @@ 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 />;
|
||||||
const users = data.users;
|
const allUsers = data.users;
|
||||||
|
// Mobile: narrow to the picked person (derived, not stored — a stale pick
|
||||||
|
// after the user list changes falls back gracefully).
|
||||||
|
const mobileUser =
|
||||||
|
allUsers.find((u) => u.id === mobileUserId) ??
|
||||||
|
allUsers.find((u) => u.id === authUser?.id) ??
|
||||||
|
allUsers[0];
|
||||||
|
const users =
|
||||||
|
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
|
||||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||||
// mutation on the same cell re-triggers the animation (CSS animations
|
// mutation on the same cell re-triggers the animation (CSS animations
|
||||||
// don't restart unless the keyframe applies to a fresh element/class
|
// don't restart unless the keyframe applies to a fresh element/class
|
||||||
@@ -493,6 +517,21 @@ export default function PlanGrid({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && allUsers.length > 1 && (
|
||||||
|
<Box sx={{ mb: 1.5 }}>
|
||||||
|
<Field label="Osoba">
|
||||||
|
<Select
|
||||||
|
value={String(mobileUser?.id ?? "")}
|
||||||
|
onChange={(value) => setMobileUserId(Number(value))}
|
||||||
|
options={allUsers.map((u) => ({
|
||||||
|
value: String(u.id),
|
||||||
|
label: u.full_name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<PlanGridRoot data-pulse={pulseAttr}>
|
<PlanGridRoot data-pulse={pulseAttr}>
|
||||||
<table className="plan-grid">
|
<table className="plan-grid">
|
||||||
{/* The colgroup is what actually controls column width in a
|
{/* The colgroup is what actually controls column width in a
|
||||||
@@ -644,5 +683,6 @@ export default function PlanGrid({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</PlanGridRoot>
|
</PlanGridRoot>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -262,8 +262,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
723
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
723
src/admin/components/document/DocumentItemsEditor.tsx
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import {
|
||||||
|
restrictToVerticalAxis,
|
||||||
|
restrictToParentElement,
|
||||||
|
} from "@dnd-kit/modifiers";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Button, Card, TextField } from "../../ui";
|
||||||
|
import { formatCurrency } from "../../utils/formatters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sortable line item of a document (offer / issued order). The numeric
|
||||||
|
* fields are held as the raw typed string while editing so the input can be
|
||||||
|
* cleared (empty renders fine in a type="number" input); coerce via
|
||||||
|
* `Number(x) || 0` only where used (live totals + save payload).
|
||||||
|
*/
|
||||||
|
export interface DocumentItem {
|
||||||
|
_key: string;
|
||||||
|
id?: number;
|
||||||
|
description: string;
|
||||||
|
item_description: string;
|
||||||
|
quantity: string | number;
|
||||||
|
unit: string;
|
||||||
|
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. */
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||||
|
export const nextDocumentItemKey = (): string =>
|
||||||
|
`item-${++documentItemKeyCounter}`;
|
||||||
|
|
||||||
|
export const emptyDocumentItem = (): DocumentItem => ({
|
||||||
|
_key: nextDocumentItemKey(),
|
||||||
|
description: "",
|
||||||
|
item_description: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
|
is_included_in_total: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** NET total over the included items (documents here carry no VAT). */
|
||||||
|
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, it) =>
|
||||||
|
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const DragIcon = (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="9" cy="5" r="1.5" />
|
||||||
|
<circle cx="15" cy="5" r="1.5" />
|
||||||
|
<circle cx="9" cy="12" r="1.5" />
|
||||||
|
<circle cx="15" cy="12" r="1.5" />
|
||||||
|
<circle cx="9" cy="19" r="1.5" />
|
||||||
|
<circle cx="15" cy="19" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function SortableItemRow({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
currency,
|
||||||
|
readOnly,
|
||||||
|
canDelete,
|
||||||
|
showIncludedInTotal,
|
||||||
|
showDiscount,
|
||||||
|
itemDescriptionMaxLength,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
item: DocumentItem;
|
||||||
|
index: number;
|
||||||
|
currency: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
showIncludedInTotal: boolean;
|
||||||
|
showDiscount: boolean;
|
||||||
|
itemDescriptionMaxLength: number;
|
||||||
|
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: item._key, disabled: readOnly });
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
background: isDragging ? "var(--mui-palette-action-hover)" : undefined,
|
||||||
|
position: "relative" as const,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
const lineTotal = discountedLineNet(item);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1.25,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{!readOnly && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
title="Přetáhnout"
|
||||||
|
aria-label="Přetáhnout"
|
||||||
|
sx={{
|
||||||
|
cursor: "grab",
|
||||||
|
color: "text.secondary",
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DragIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
Položka {index + 1}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
{!readOnly && (
|
||||||
|
<IconButton
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Odebrat"
|
||||||
|
aria-label="Odebrat"
|
||||||
|
disabled={!canDelete}
|
||||||
|
>
|
||||||
|
{RemoveIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Popis"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => onUpdate("description", e.target.value)}
|
||||||
|
placeholder="Popis položky…"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Podrobný popis"
|
||||||
|
value={item.item_description}
|
||||||
|
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||||
|
placeholder="Volitelný"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
|
sx={{ "& textarea": { resize: "vertical" } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Množství"
|
||||||
|
type="number"
|
||||||
|
value={item.quantity ?? ""}
|
||||||
|
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={{ "& input": { textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Jednotka"
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||||
|
placeholder="ks"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||||
|
sx={{ "& input": { textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Jedn. cena"
|
||||||
|
type="number"
|
||||||
|
value={item.unit_price ?? ""}
|
||||||
|
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
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
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: showIncludedInTotal ? "space-between" : "flex-end",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 1,
|
||||||
|
pt: 0.5,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showIncludedInTotal && (
|
||||||
|
<Box
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
cursor: readOnly ? "default" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.is_included_in_total !== false}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate("is_included_in_total", e.target.checked)
|
||||||
|
}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">V ceně</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Celkem
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(lineTotal, currency)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow ref={setNodeRef} sx={style}>
|
||||||
|
{/* Stable column layout: every cell renders in read-only mode too —
|
||||||
|
only the controls inside are hidden. */}
|
||||||
|
<TableCell sx={{ width: "2rem", px: 0.5 }}>
|
||||||
|
{!readOnly && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
title="Přetáhnout"
|
||||||
|
aria-label="Přetáhnout"
|
||||||
|
sx={{
|
||||||
|
cursor: "grab",
|
||||||
|
color: "text.secondary",
|
||||||
|
touchAction: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DragIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
align="center"
|
||||||
|
sx={{ color: "text.secondary", fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ verticalAlign: "top" }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => onUpdate("description", e.target.value)}
|
||||||
|
placeholder="Popis položky…"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||||
|
sx={{ "& input": { fontWeight: 500 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
value={item.item_description}
|
||||||
|
onChange={(e) => onUpdate("item_description", e.target.value)}
|
||||||
|
placeholder="Podrobný popis (volitelný)"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={16}
|
||||||
|
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||||
|
sx={{
|
||||||
|
"& textarea": {
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
resize: "vertical",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.quantity ?? ""}
|
||||||
|
onChange={(e) => onUpdate("quantity", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={{ "& input": { textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
value={item.unit}
|
||||||
|
onChange={(e) => onUpdate("unit", e.target.value)}
|
||||||
|
placeholder="ks"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||||
|
sx={{ "& input": { textAlign: "center" } }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.unit_price ?? ""}
|
||||||
|
onChange={(e) => onUpdate("unit_price", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
|
/>
|
||||||
|
</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 && (
|
||||||
|
<TableCell align="center">
|
||||||
|
<Checkbox
|
||||||
|
checked={item.is_included_in_total !== false}
|
||||||
|
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell
|
||||||
|
align="right"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(lineTotal, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "2.5rem" }}>
|
||||||
|
{!readOnly && (
|
||||||
|
<IconButton
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Odebrat"
|
||||||
|
aria-label="Odebrat"
|
||||||
|
disabled={!canDelete}
|
||||||
|
>
|
||||||
|
{RemoveIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentItemsEditorProps {
|
||||||
|
items: DocumentItem[];
|
||||||
|
onChange: (items: DocumentItem[]) => void;
|
||||||
|
currency: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
/** Validation error shown under the section title. */
|
||||||
|
error?: string;
|
||||||
|
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||||
|
showIncludedInTotal?: boolean;
|
||||||
|
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||||
|
showDiscount?: boolean;
|
||||||
|
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||||
|
itemDescriptionMaxLength?: number;
|
||||||
|
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||||
|
templatesSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sortable line-items table shared by the document detail pages (offers,
|
||||||
|
* issued orders): drag-and-drop reorder, mobile card layout, live NET totals
|
||||||
|
* ("Celkem bez DPH" — these documents are not tax documents).
|
||||||
|
*/
|
||||||
|
export default function DocumentItemsEditor({
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
currency,
|
||||||
|
readOnly,
|
||||||
|
error,
|
||||||
|
showIncludedInTotal = false,
|
||||||
|
showDiscount = false,
|
||||||
|
itemDescriptionMaxLength = 5000,
|
||||||
|
templatesSlot,
|
||||||
|
}: DocumentItemsEditorProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
|
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
|
||||||
|
// pointer events and its 5px distance constraint fires before the
|
||||||
|
// TouchSensor's long-press delay — the browser then claims the gesture for
|
||||||
|
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
|
||||||
|
// TouchSensor handles touch via long-press; the drag handles additionally
|
||||||
|
// carry touch-action: none so the browser never starts a scroll from them.
|
||||||
|
const dndSensors = useSensors(
|
||||||
|
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: { delay: 300, tolerance: 8 },
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateItem = (
|
||||||
|
index: number,
|
||||||
|
field: keyof DocumentItem,
|
||||||
|
value: string | boolean,
|
||||||
|
) =>
|
||||||
|
onChange(
|
||||||
|
items.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = items.findIndex((i) => i._key === String(active.id));
|
||||||
|
const newIndex = items.findIndex((i) => i._key === String(over.id));
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
onChange(arrayMove(items, oldIndex, newIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = documentItemsTotal(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
Položky
|
||||||
|
</Typography>
|
||||||
|
{error && (
|
||||||
|
<Typography variant="caption" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{!readOnly && (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||||
|
{templatesSlot}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
+ Přidat položku
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={dndSensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={items.map((i) => i._key)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<SortableItemRow
|
||||||
|
key={item._key}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
currency={currency}
|
||||||
|
readOnly={readOnly}
|
||||||
|
canDelete={items.length > 1}
|
||||||
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer sx={{ overflowX: "auto" }}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
minWidth: 720,
|
||||||
|
"& td, & th": { borderColor: "divider" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: "2rem" }} />
|
||||||
|
<TableCell sx={{ width: "2rem" }} align="center">
|
||||||
|
#
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>Popis</TableCell>
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Množství
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "5.5rem" }} align="center">
|
||||||
|
Jednotka
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
|
Jedn. cena
|
||||||
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{showIncludedInTotal && (
|
||||||
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
|
V ceně
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell sx={{ width: "8rem" }} align="right">
|
||||||
|
Celkem
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "2.5rem" }} />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<SortableItemRow
|
||||||
|
key={item._key}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
currency={currency}
|
||||||
|
readOnly={readOnly}
|
||||||
|
canDelete={items.length > 1}
|
||||||
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
|
showDiscount={showDiscount}
|
||||||
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
|
onUpdate={(field, value) =>
|
||||||
|
updateItem(index, field, value)
|
||||||
|
}
|
||||||
|
onRemove={() => removeItem(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Totals (NET only — offers/issued orders are not tax documents) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
ml: "auto",
|
||||||
|
maxWidth: 360,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
pt: 0.5,
|
||||||
|
mt: 0.5,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
Celkem bez DPH:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCurrency(total, currency)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/admin/components/document/LockBanner.tsx
Normal file
55
src/admin/components/document/LockBanner.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import type { DocumentLockInfo } from "../../hooks/useDocumentLock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warning banner shown when another user holds the document edit lock
|
||||||
|
* (extracted from OfferDetail; shared by the document detail pages).
|
||||||
|
* Renders nothing when the lock is free / held by the current user.
|
||||||
|
*
|
||||||
|
* `entityWord` is the Czech accusative for the document ("Nabídku" /
|
||||||
|
* "Objednávku") — both are feminine, so the rest of the sentence is shared.
|
||||||
|
*/
|
||||||
|
export default function LockBanner({
|
||||||
|
lockedBy,
|
||||||
|
entityWord,
|
||||||
|
}: {
|
||||||
|
lockedBy: DocumentLockInfo | null;
|
||||||
|
entityWord: string;
|
||||||
|
}) {
|
||||||
|
if (!lockedBy) return null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor:
|
||||||
|
"color-mix(in srgb, var(--mui-palette-warning-main) 15%, transparent)",
|
||||||
|
border: 1,
|
||||||
|
borderColor: "warning.main",
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
mb: 2,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "warning.main",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{entityWord} právě upravuje <strong>{lockedBy.full_name}</strong>.
|
||||||
|
Můžete ji pouze prohlížet.
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/admin/components/document/SectionsEditor.tsx
Normal file
315
src/admin/components/document/SectionsEditor.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import RichEditor from "../RichEditor";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Field,
|
||||||
|
StatusChip,
|
||||||
|
EmptyState,
|
||||||
|
} from "../../ui";
|
||||||
|
|
||||||
|
/** One bilingual rich-text PDF section (offers' scope ≡ issued orders' sections). */
|
||||||
|
export interface DocumentSection {
|
||||||
|
title: string;
|
||||||
|
title_cz: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyDocumentSection = (): DocumentSection => ({
|
||||||
|
title: "",
|
||||||
|
title_cz: "",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UpIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DownIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SectionsEditorProps {
|
||||||
|
sections: DocumentSection[];
|
||||||
|
onChange: (sections: DocumentSection[]) => void;
|
||||||
|
readOnly: boolean;
|
||||||
|
/** Card heading. */
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* Document language — "CZ"/"cs" prefers the Czech section title in the
|
||||||
|
* "Sekce N — …" header line, anything else the English one.
|
||||||
|
*/
|
||||||
|
language?: string;
|
||||||
|
/** Optional page-specific control (e.g. scope-template Select) next to the add button. */
|
||||||
|
templatesSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bilingual rich-text sections editor shared by the document detail pages
|
||||||
|
* (lifted from OfferDetail's "Rozsah projektu" card): bordered section boxes
|
||||||
|
* with EN/CZ title fields (StatusChip badges), Quill content, up/down
|
||||||
|
* reorder and add/remove. Offers pass their template Select via
|
||||||
|
* `templatesSlot`; issued orders don't (no templates on POs).
|
||||||
|
*/
|
||||||
|
export default function SectionsEditor({
|
||||||
|
sections,
|
||||||
|
onChange,
|
||||||
|
readOnly,
|
||||||
|
title = "Rozsah projektu",
|
||||||
|
language,
|
||||||
|
templatesSlot,
|
||||||
|
}: SectionsEditorProps) {
|
||||||
|
const preferCz =
|
||||||
|
(language ?? "").toLowerCase().startsWith("cs") ||
|
||||||
|
(language ?? "").toUpperCase() === "CZ";
|
||||||
|
|
||||||
|
const updateSection = (
|
||||||
|
index: number,
|
||||||
|
field: keyof DocumentSection,
|
||||||
|
value: string,
|
||||||
|
) =>
|
||||||
|
onChange(
|
||||||
|
sections.map((s, i) => (i === index ? { ...s, [field]: value } : s)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveSection = (index: number, delta: -1 | 1) => {
|
||||||
|
const target = index + delta;
|
||||||
|
if (target < 0 || target >= sections.length) return;
|
||||||
|
const arr = [...sections];
|
||||||
|
[arr[index], arr[target]] = [arr[target], arr[index]];
|
||||||
|
onChange(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{!readOnly && (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||||
|
{templatesSlot}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([...sections, emptyDocumentSection()])}
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
+ Přidat sekci
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{sections.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
templatesSlot
|
||||||
|
? 'Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.'
|
||||||
|
: 'Žádné sekce rozsahu. Klikněte na "Přidat sekci".'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
mt: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sections.map((section, idx) => (
|
||||||
|
<Box
|
||||||
|
key={idx}
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Sekce {idx + 1}
|
||||||
|
{(preferCz ? section.title_cz : section.title) && (
|
||||||
|
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
|
||||||
|
—{" "}
|
||||||
|
{preferCz
|
||||||
|
? section.title_cz || section.title
|
||||||
|
: section.title}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{!readOnly && (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.25 }}>
|
||||||
|
{idx > 0 && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => moveSection(idx, -1)}
|
||||||
|
title="Posunout nahoru"
|
||||||
|
aria-label="Posunout nahoru"
|
||||||
|
>
|
||||||
|
{UpIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{idx < sections.length - 1 && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => moveSection(idx, 1)}
|
||||||
|
title="Posunout dolů"
|
||||||
|
aria-label="Posunout dolů"
|
||||||
|
>
|
||||||
|
{DownIcon}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() =>
|
||||||
|
onChange(sections.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
title="Odebrat sekci"
|
||||||
|
aria-label="Odebrat sekci"
|
||||||
|
>
|
||||||
|
{RemoveIcon}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusChip label="EN" color="info" />
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Název sekce
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
value={section.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSection(idx, "title", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Název sekce (anglicky)"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusChip label="CZ" color="success" />
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Název sekce
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
value={section.title_cz}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSection(idx, "title_cz", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Název sekce (česky)"
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 500 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Obsah">
|
||||||
|
<RichEditor
|
||||||
|
value={section.content}
|
||||||
|
onChange={(val) => updateSection(idx, "content", val)}
|
||||||
|
placeholder="Obsah sekce..."
|
||||||
|
minHeight="120px"
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } 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";
|
||||||
@@ -109,6 +110,17 @@ export default function OdinChat() {
|
|||||||
});
|
});
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Mobile is immersive (no AppShell header on /odin) — this is the only way
|
||||||
|
// back. A deep link straight to /odin has no in-app history → go home.
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const goBack = () => {
|
||||||
|
if (((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
|
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
|
||||||
// null) and the sidebar lists existing conversations to open. A conversation
|
// null) and the sidebar lists existing conversations to open. A conversation
|
||||||
// row in the DB is created lazily on the first message (see submit), so
|
// row in the DB is created lazily on the first message (see submit), so
|
||||||
@@ -122,6 +134,7 @@ export default function OdinChat() {
|
|||||||
messagesData.messages.map((m) => ({
|
messagesData.messages.map((m) => ({
|
||||||
role: m.role as "user" | "assistant",
|
role: m.role as "user" | "assistant",
|
||||||
content: m.content,
|
content: m.content,
|
||||||
|
tools: m.meta?.tools,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setReview([]);
|
setReview([]);
|
||||||
@@ -148,6 +161,9 @@ export default function OdinChat() {
|
|||||||
messages: msgs.map((m) => ({
|
messages: msgs.map((m) => ({
|
||||||
role: m.role,
|
role: m.role,
|
||||||
content: m.content.slice(0, MAX_STORE),
|
content: m.content.slice(0, MAX_STORE),
|
||||||
|
...(m.tools && m.tools.length > 0
|
||||||
|
? { meta: { tools: m.tools } }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -208,26 +224,12 @@ export default function OdinChat() {
|
|||||||
const files = attachments.map((a) => a.file);
|
const files = attachments.map((a) => a.file);
|
||||||
if (!text && files.length === 0) return;
|
if (!text && files.length === 0) return;
|
||||||
|
|
||||||
// Guard: Odin is scoped to invoice processing only (for now). A text-only
|
// Phase 2a: text messages go to the agentic /chat endpoint (read-only
|
||||||
// message makes NO AI call — it gets a canned reply locally, so open-ended
|
// tools over system data); attachments keep the invoice-extract path.
|
||||||
// chat can't burn API credits. The invoice path (attachments) runs normally.
|
|
||||||
// (Removing this guard re-enables the general /chat path below for Phase 2.)
|
|
||||||
if (files.length === 0) {
|
|
||||||
setTurns((t) => [
|
|
||||||
...t,
|
|
||||||
{ role: "user", content: text },
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content:
|
|
||||||
"Momentálně umím zpracovat pouze přijaté faktury. Přiložte prosím fakturu (PDF) a já z ní načtu údaje k uložení.",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setInput("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const prevTurns = turns;
|
const prevTurns = turns;
|
||||||
|
const prevInput = input;
|
||||||
|
const prevAttachments = attachments;
|
||||||
|
|
||||||
const userContent = [
|
const userContent = [
|
||||||
text,
|
text,
|
||||||
@@ -240,6 +242,10 @@ export default function OdinChat() {
|
|||||||
{ role: "user", content: userContent },
|
{ role: "user", content: userContent },
|
||||||
];
|
];
|
||||||
setTurns(optimistic);
|
setTurns(optimistic);
|
||||||
|
// Clear the composer immediately — the message now lives in the thread
|
||||||
|
// as the optimistic bubble; a failure restores both below.
|
||||||
|
setInput("");
|
||||||
|
setAttachments([]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -280,8 +286,6 @@ export default function OdinChat() {
|
|||||||
setReview((prev) => [...prev, ...reviews]);
|
setReview((prev) => [...prev, ...reviews]);
|
||||||
const note = summaryNote(reviews);
|
const note = summaryNote(reviews);
|
||||||
setTurns((t) => [...t, { role: "assistant", content: note }]);
|
setTurns((t) => [...t, { role: "assistant", content: note }]);
|
||||||
setInput("");
|
|
||||||
setAttachments([]);
|
|
||||||
// Create the conversation now (first successful message) and persist.
|
// Create the conversation now (first successful message) and persist.
|
||||||
const convId = await ensureActive();
|
const convId = await ensureActive();
|
||||||
if (convId != null)
|
if (convId != null)
|
||||||
@@ -301,19 +305,24 @@ export default function OdinChat() {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) throw new Error(body?.error || "Chyba AI");
|
if (!res.ok) throw new Error(body?.error || "Chyba AI");
|
||||||
const reply: string = body.data.reply;
|
const reply: string = body.data.reply;
|
||||||
setTurns((t) => [...t, { role: "assistant", content: reply }]);
|
const tools: ChatTurn["tools"] = Array.isArray(body.data.tool_trace)
|
||||||
setInput("");
|
? body.data.tool_trace
|
||||||
|
: undefined;
|
||||||
|
setTurns((t) => [...t, { role: "assistant", content: reply, tools }]);
|
||||||
// Create the conversation now (first successful message) and persist.
|
// Create the conversation now (first successful message) and persist.
|
||||||
const convId = await ensureActive();
|
const convId = await ensureActive();
|
||||||
if (convId != null)
|
if (convId != null)
|
||||||
persist(convId, [
|
persist(convId, [
|
||||||
{ role: "user", content: userContent },
|
{ role: "user", content: userContent },
|
||||||
{ role: "assistant", content: reply },
|
{ role: "assistant", content: reply, tools },
|
||||||
]);
|
]);
|
||||||
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setTurns(prevTurns);
|
setTurns(prevTurns);
|
||||||
|
// Give the user their message back to retry/edit.
|
||||||
|
setInput(prevInput);
|
||||||
|
setAttachments(prevAttachments);
|
||||||
const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI";
|
const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI";
|
||||||
alert.error(e instanceof Error ? e.message : fallback);
|
alert.error(e instanceof Error ? e.message : fallback);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -422,11 +431,26 @@ export default function OdinChat() {
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: "calc(100dvh - 100px)",
|
// svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
|
||||||
|
// dvh-sized full-height layout always lets the page scroll by the
|
||||||
|
// browser-chrome height. svh sizes for the bar-visible viewport — the
|
||||||
|
// page never scrolls and the chat's message list scrolls internally.
|
||||||
|
// Desktop: svh === vh.
|
||||||
|
// Mobile is immersive (AppShell hides its header and main padding on
|
||||||
|
// /odin): the chat owns the whole viewport, full-bleed. --app-height
|
||||||
|
// is AppShell's live window.innerHeight measurement — standalone-PWA
|
||||||
|
// viewports misreport svh/dvh (MIUI), only the measured value fits.
|
||||||
|
height: {
|
||||||
|
xs: "var(--app-height, 100svh)",
|
||||||
|
md: "calc(100svh - 100px)",
|
||||||
|
},
|
||||||
display: "flex",
|
display: "flex",
|
||||||
border: 1,
|
// Longhand on purpose: a responsive `border` shorthand lands in a
|
||||||
|
// media query AFTER borderColor and resets the color to black.
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: { xs: 0, md: 1 },
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
borderRadius: 3,
|
borderRadius: { xs: 0, md: 3 },
|
||||||
bgcolor: "background.paper",
|
bgcolor: "background.paper",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -452,15 +476,41 @@ export default function OdinChat() {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
p: 2,
|
p: 2,
|
||||||
|
// Immersive mobile: respect the notch / home-indicator insets
|
||||||
|
// (env() is 0 outside standalone mode, so max() keeps the 16px).
|
||||||
|
pt: { xs: "max(16px, env(safe-area-inset-top))", md: 2 },
|
||||||
|
pb: { xs: "max(16px, env(safe-area-inset-bottom))", md: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label="Zpět"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: -0.5, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 22, height: 22 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12" />
|
||||||
|
<polyline points="12 19 5 12 12 5" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
aria-label="Konverzace"
|
aria-label="Konverzace"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ ml: -0.5, flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
component="svg"
|
component="svg"
|
||||||
@@ -490,8 +540,7 @@ export default function OdinChat() {
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
||||||
>
|
>
|
||||||
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
Utraceno: ${usage.month_spend_usd.toFixed(2)}
|
||||||
{usage.budget_usd.toFixed(2)}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user