Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -204,8 +204,11 @@ descriptions for unnumbered drafts. Audit failures are non-fatal.
|
||||
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) — Chromium has no
|
||||
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
|
||||
("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` /
|
||||
@@ -291,15 +294,28 @@ CSP blocks it and it would leak the secret).
|
||||
|
||||
## AI Assistant — "Odin"
|
||||
|
||||
Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
|
||||
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
|
||||
(text-only messages get a canned reply, no API call). Backend
|
||||
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger
|
||||
in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
|
||||
PDF → `extract-invoices` (structured output) → review cards → existing
|
||||
`POST /received-invoices`. **`received_invoices.amount` is GROSS
|
||||
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
|
||||
notes live in `docs/superpowers/specs/`.
|
||||
`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).
|
||||
|
||||
**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).
|
||||
|
||||
Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when
|
||||
exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured
|
||||
output) → review cards → existing `POST /received-invoices`.
|
||||
**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is
|
||||
back-calculated (`vatFromGross`). Phase-2 design notes live in
|
||||
`docs/superpowers/specs/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
237
REVIEW.md
237
REVIEW.md
@@ -1,81 +1,102 @@
|
||||
# Codebase Audit — REVIEW.md
|
||||
# Codebase Audit Checklist
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**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.
|
||||
**Date:** 2026-06-09 | **Total files:** 304 | Scope: full project (source + config). Excluded: docs/*.md, prisma/migrations/*.sql (generated SQL), package-lock.json, binaries.
|
||||
|
||||
**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/hooks/
|
||||
## .claude
|
||||
|
||||
- [x] .claude/hooks/block-env.js
|
||||
- [x] .claude/hooks/format-on-save.js
|
||||
|
||||
## .claude/
|
||||
|
||||
- [x] .claude/settings.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/schema.prisma
|
||||
|
||||
## scripts/
|
||||
## scripts
|
||||
|
||||
- [x] scripts/check-roles.mjs
|
||||
- [x] scripts/migrate-received-invoices-to-nas.ts
|
||||
- [x] scripts/rotate-totp-key.ts
|
||||
- [x] scripts/seed-test-users.mjs
|
||||
|
||||
## src/
|
||||
|
||||
- [x] src/App.tsx
|
||||
|
||||
## src/__tests__/
|
||||
## src/__tests__
|
||||
|
||||
- [x] src/__tests__/ai.test.ts
|
||||
- [x] src/__tests__/auth.service.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__/drafts-aggregation.test.ts
|
||||
- [x] src/__tests__/drafts-deferred-numbering.test.ts
|
||||
- [x] src/__tests__/env.test.ts
|
||||
- [x] src/__tests__/exchange-rates.test.ts
|
||||
- [x] src/__tests__/helpers.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__/nas-document-manager.test.ts
|
||||
- [x] src/__tests__/nas-file-manager.test.ts
|
||||
- [x] src/__tests__/nas-project-folder.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__/planAuditDescription.test.ts
|
||||
- [x] src/__tests__/planCategory.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__/schema-nan.test.ts
|
||||
- [x] src/__tests__/warehouse.test.ts
|
||||
|
||||
## src/admin/
|
||||
## 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/AttendanceShiftTable.tsx
|
||||
- [x] src/admin/components/BulkAttendanceModal.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/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/PlanCategoriesModal.tsx
|
||||
- [x] src/admin/components/PlanCellModal.tsx
|
||||
@@ -85,38 +106,19 @@
|
||||
- [x] src/admin/components/RichEditor.tsx
|
||||
- [x] src/admin/components/ShiftFormModal.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/ReservationPicker.tsx
|
||||
|
||||
## src/admin/context/
|
||||
## src/admin (context)
|
||||
|
||||
- [x] src/admin/context/AlertContext.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/useDebounce.ts
|
||||
@@ -126,19 +128,18 @@
|
||||
- [x] src/admin/hooks/useReducedMotion.ts
|
||||
- [x] src/admin/hooks/useTableSort.ts
|
||||
|
||||
## src/admin/lib/
|
||||
## src/admin (lib)
|
||||
|
||||
- [x] src/admin/lib/apiAdapter.ts
|
||||
- [x] src/admin/lib/documentStatus.ts
|
||||
- [x] src/admin/lib/entityTypeLabels.ts
|
||||
|
||||
## src/admin/lib/queries/
|
||||
|
||||
- [x] src/admin/lib/queries/ai.ts
|
||||
- [x] src/admin/lib/queries/attendance.ts
|
||||
- [x] src/admin/lib/queries/auditLog.ts
|
||||
- [x] src/admin/lib/queries/common.ts
|
||||
- [x] src/admin/lib/queries/dashboard.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/mutations.ts
|
||||
- [x] src/admin/lib/queries/offers.ts
|
||||
@@ -150,12 +151,9 @@
|
||||
- [x] src/admin/lib/queries/users.ts
|
||||
- [x] src/admin/lib/queries/vehicles.ts
|
||||
- [x] src/admin/lib/queries/warehouse.ts
|
||||
|
||||
## src/admin/lib/
|
||||
|
||||
- [x] src/admin/lib/queryClient.ts
|
||||
|
||||
## src/admin/pages/
|
||||
## src/admin (pages)
|
||||
|
||||
- [x] src/admin/pages/Attendance.tsx
|
||||
- [x] src/admin/pages/AttendanceAdmin.tsx
|
||||
@@ -168,6 +166,8 @@
|
||||
- [x] src/admin/pages/Dashboard.tsx
|
||||
- [x] src/admin/pages/InvoiceDetail.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/LeaveRequests.tsx
|
||||
- [x] src/admin/pages/Login.tsx
|
||||
@@ -183,6 +183,7 @@
|
||||
- [x] src/admin/pages/ProjectDetail.tsx
|
||||
- [x] src/admin/pages/Projects.tsx
|
||||
- [x] src/admin/pages/ReceivedInvoices.tsx
|
||||
- [x] src/admin/pages/ReceivedOrders.tsx
|
||||
- [x] src/admin/pages/Settings.tsx
|
||||
- [x] src/admin/pages/Trips.tsx
|
||||
- [x] src/admin/pages/TripsAdmin.tsx
|
||||
@@ -208,29 +209,35 @@
|
||||
- [x] src/admin/pages/WarehouseReservations.tsx
|
||||
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
||||
|
||||
## src/admin/
|
||||
## src/admin (theme.test.ts)
|
||||
|
||||
- [x] src/admin/theme.test.ts
|
||||
|
||||
## 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/AppShell.tsx
|
||||
- [x] src/admin/ui/Button.tsx
|
||||
- [x] src/admin/ui/Card.tsx
|
||||
- [x] src/admin/ui/Checkbox.tsx
|
||||
- [x] src/admin/ui/ConfirmDialog.tsx
|
||||
- [x] src/admin/ui/CustomerPicker.tsx
|
||||
- [x] src/admin/ui/DataTable.tsx
|
||||
- [x] src/admin/ui/DateField.tsx
|
||||
- [x] src/admin/ui/EmptyState.tsx
|
||||
- [x] src/admin/ui/Field.tsx
|
||||
- [x] src/admin/ui/FileUpload.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/Modal.tsx
|
||||
- [x] src/admin/ui/MonthField.tsx
|
||||
- [x] src/admin/ui/MuiProvider.tsx
|
||||
- [x] src/admin/ui/navData.tsx
|
||||
- [x] src/admin/ui/PageEnter.tsx
|
||||
- [x] src/admin/ui/PageHeader.tsx
|
||||
- [x] src/admin/ui/Pagination.tsx
|
||||
@@ -244,36 +251,38 @@
|
||||
- [x] src/admin/ui/TextField.tsx
|
||||
- [x] src/admin/ui/ThemeToggle.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
|
||||
|
||||
## src/admin/utils/
|
||||
## src/admin (utils)
|
||||
|
||||
- [x] src/admin/utils/api.ts
|
||||
- [x] src/admin/utils/attendanceHelpers.ts
|
||||
- [x] src/admin/utils/dashboardHelpers.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/env.ts
|
||||
|
||||
## src/context/
|
||||
## src/context
|
||||
|
||||
- [x] src/context/ThemeContext.tsx
|
||||
|
||||
## src/
|
||||
## src/main.tsx
|
||||
|
||||
- [x] src/main.tsx
|
||||
|
||||
## src/middleware/
|
||||
## src/middleware
|
||||
|
||||
- [x] src/middleware/auth.ts
|
||||
- [x] src/middleware/security.ts
|
||||
|
||||
## src/routes/admin/
|
||||
## src/routes
|
||||
|
||||
- [x] src/routes/admin/ai.ts
|
||||
- [x] src/routes/admin/attendance.ts
|
||||
@@ -283,12 +292,14 @@
|
||||
- [x] src/routes/admin/company-settings.ts
|
||||
- [x] src/routes/admin/customers.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-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/offers-pdf.ts
|
||||
- [x] src/routes/admin/orders-pdf.ts
|
||||
- [x] src/routes/admin/orders.ts
|
||||
- [x] src/routes/admin/orders-pdf.ts
|
||||
- [x] src/routes/admin/plan.ts
|
||||
- [x] src/routes/admin/profile.ts
|
||||
- [x] src/routes/admin/project-files.ts
|
||||
@@ -304,7 +315,36 @@
|
||||
- [x] src/routes/admin/vehicles.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/attendance.schema.ts
|
||||
@@ -313,6 +353,7 @@
|
||||
- [x] src/schemas/common.ts
|
||||
- [x] src/schemas/customers.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/offers.schema.ts
|
||||
- [x] src/schemas/orders.schema.ts
|
||||
@@ -329,40 +370,12 @@
|
||||
- [x] src/schemas/vehicles.schema.ts
|
||||
- [x] src/schemas/warehouse.schema.ts
|
||||
|
||||
## src/
|
||||
|
||||
- [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/
|
||||
## src/types
|
||||
|
||||
- [x] src/types/fastify.d.ts
|
||||
- [x] src/types/index.ts
|
||||
|
||||
## src/utils/
|
||||
## src/utils
|
||||
|
||||
- [x] src/utils/czech-holidays.ts
|
||||
- [x] src/utils/date.ts
|
||||
@@ -373,14 +386,8 @@
|
||||
- [x] src/utils/response.ts
|
||||
- [x] src/utils/totp.ts
|
||||
|
||||
## src/
|
||||
## 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) |
|
||||
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
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.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.31",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.8",
|
||||
"version": "2.4.31",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -104,6 +104,7 @@ model ai_chat_messages {
|
||||
conversation_id Int
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
content_json String? @db.LongText
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -456,6 +457,7 @@ model project_notes {
|
||||
user_name String? @db.VarChar(100)
|
||||
content String? @db.Text
|
||||
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")
|
||||
|
||||
@@index([project_id], map: "project_id")
|
||||
|
||||
@@ -295,6 +295,16 @@ const PERMISSIONS: {
|
||||
module: "warehouse",
|
||||
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() {
|
||||
|
||||
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
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));
|
||||
});
|
||||
});
|
||||
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
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -539,6 +539,40 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => {
|
||||
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||
// full payload + status in ONE PUT while the order is still editable
|
||||
// (sent). The server must apply the items AND the transition together.
|
||||
const order = await mkIssued({});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/issued-orders/${order.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
payload: {
|
||||
status: "confirmed",
|
||||
items: [
|
||||
{
|
||||
description: "Flushnutá položka",
|
||||
quantity: 2,
|
||||
unit_price: 50,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.issued_orders.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { issued_order_items: true },
|
||||
});
|
||||
expect(row!.status).toBe("confirmed");
|
||||
expect(row!.issued_order_items.map((i) => i.description)).toContain(
|
||||
"Flushnutá položka",
|
||||
);
|
||||
});
|
||||
|
||||
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
||||
const order = await mkIssued({});
|
||||
await updateIssuedOrder(order.id, { status: "sent" });
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
126
src/__tests__/pagination-tiebreak.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import customersRoutes from "../routes/admin/customers";
|
||||
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding L3: offset pagination over a non-unique
|
||||
* sort column needs an `{ id }` tiebreak, or rows sharing the sort value can
|
||||
* reorder between page queries — duplicating one row and skipping another.
|
||||
* (Documented determinism convention; siblings issued-orders/trips already
|
||||
* use compound orderings.)
|
||||
*
|
||||
* NOTE: without the tiebreak the misbehavior is probabilistic (MySQL may
|
||||
* return a stable order by luck), so the paging assertion is primarily a
|
||||
* REGRESSION GUARD pinning the exactly-once contract.
|
||||
*/
|
||||
|
||||
const N = "tiebreak_test_";
|
||||
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let adminToken: string;
|
||||
let customerIds: number[] = [];
|
||||
let invoiceIds: number[] = [];
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.customers.deleteMany({ where: { name: { startsWith: N } } });
|
||||
await prisma.received_invoices.deleteMany({
|
||||
where: { supplier_name: { startsWith: N } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(customersRoutes, { prefix: "/api/admin/customers" });
|
||||
await app.register(receivedInvoicesRoutes, {
|
||||
prefix: "/api/admin/received-invoices",
|
||||
});
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
|
||||
// 5 customers sharing one city (the sort key) so every page boundary
|
||||
// falls inside a duplicate-key run.
|
||||
customerIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: `${N}c${i}`, city: "Tiebreakov" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
}
|
||||
|
||||
// 5 received invoices sharing supplier_name in one far-future month.
|
||||
invoiceIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const inv = await prisma.received_invoices.create({
|
||||
data: {
|
||||
supplier_name: `${N}supplier`,
|
||||
month: 7,
|
||||
year: 2098,
|
||||
amount: 100 + i,
|
||||
currency: "CZK",
|
||||
vat_rate: 21,
|
||||
vat_amount: 17.36,
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
invoiceIds.push(inv.id);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function collectPages(urlBase: string, pages: number) {
|
||||
const seen: number[] = [];
|
||||
for (let page = 1; page <= pages; page++) {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `${urlBase}&page=${page}&limit=2`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
for (const row of res.json().data as Array<{ id: number }>) {
|
||||
seen.push(row.id);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("offset pagination exactly-once (audit L3)", () => {
|
||||
it("customers sorted by a duplicate city appear exactly once across pages", async () => {
|
||||
const seen = (
|
||||
await collectPages("/api/admin/customers?sort=city&search=" + N, 3)
|
||||
).filter((id) => customerIds.includes(id));
|
||||
|
||||
expect([...new Set(seen)].sort()).toEqual([...customerIds].sort());
|
||||
expect(seen.length).toBe(customerIds.length);
|
||||
});
|
||||
|
||||
it("received invoices sorted by a duplicate supplier appear exactly once across pages", async () => {
|
||||
const seen = (
|
||||
await collectPages(
|
||||
"/api/admin/received-invoices?sort=supplier_name&month=7&year=2098",
|
||||
3,
|
||||
)
|
||||
).filter((id) => invoiceIds.includes(id));
|
||||
|
||||
expect([...new Set(seen)].sort()).toEqual([...invoiceIds].sort());
|
||||
expect(seen.length).toBe(invoiceIds.length);
|
||||
});
|
||||
});
|
||||
55
src/__tests__/pdf-shared.test.ts
Normal file
55
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||
|
||||
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||
it("keeps Quill text and background colors", () => {
|
||||
const html =
|
||||
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||
'<span style="color: #06c;">modře</span></p>';
|
||||
const out = cleanQuillHtml(html);
|
||||
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||
expect(out).toContain('style="background-color: #ffff00"');
|
||||
expect(out).toContain('style="color: #06c"');
|
||||
});
|
||||
|
||||
it("drops every non-color style declaration", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||
);
|
||||
expect(out).toBe('<span style="color: red">x</span>');
|
||||
});
|
||||
|
||||
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||
"<span>x</span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
|
||||
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
|
||||
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
|
||||
"<p>A</p><p><br></p><p>B</p>",
|
||||
);
|
||||
// Attribute-carrying and whitespace-only empties too.
|
||||
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
|
||||
'<p class="ql-align-center"><br></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||
'<a href="javascript:alert(1)">x</a></p>',
|
||||
);
|
||||
expect(out).not.toContain("script");
|
||||
expect(out).not.toContain("onclick");
|
||||
expect(out).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
106
src/__tests__/plan-update-cap.test.ts
Normal file
106
src/__tests__/plan-update-cap.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { updateEntry } from "../services/plan.service";
|
||||
|
||||
/**
|
||||
* Pinning tests for audit finding M9: updateEntry must re-check the per-cell
|
||||
* cap (MAX_RECORDS_PER_CELL = 3) when the date range changes — expanding an
|
||||
* entry onto a day that already holds 3 active entries used to succeed,
|
||||
* producing a 4th that the grid (capped at 3) silently hides.
|
||||
*
|
||||
* The re-check must EXCLUDE the updated entry itself, so editing an at-cap
|
||||
* entry without moving it stays allowed.
|
||||
*/
|
||||
|
||||
const NOTE = "plan_updcap_test";
|
||||
|
||||
let userId: number;
|
||||
let cappedEntryIds: number[] = [];
|
||||
let fourthId: number;
|
||||
|
||||
const D = (day: number) => new Date(Date.UTC(2099, 6, day)); // July 2099, UTC
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.work_plan_entries.deleteMany({ where: { note: NOTE } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const user = await prisma.users.findFirst({ select: { id: true } });
|
||||
if (!user) throw new Error("Test setup: no users — seed the database");
|
||||
userId = user.id;
|
||||
|
||||
cappedEntryIds = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
date_from: D(1),
|
||||
date_to: D(1),
|
||||
category: "work",
|
||||
note: NOTE,
|
||||
created_by: userId,
|
||||
},
|
||||
});
|
||||
cappedEntryIds.push(entry.id);
|
||||
}
|
||||
const fourth = await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
date_from: D(2),
|
||||
date_to: D(2),
|
||||
category: "work",
|
||||
note: NOTE,
|
||||
created_by: userId,
|
||||
},
|
||||
});
|
||||
fourthId = fourth.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("updateEntry per-cell cap (audit M9)", () => {
|
||||
it("rejects expanding an entry onto a day already at the cap", async () => {
|
||||
const result = await updateEntry(
|
||||
fourthId,
|
||||
{ date_from: "2099-07-01", date_to: "2099-07-01" },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(400);
|
||||
|
||||
// The entry must not have moved.
|
||||
const fresh = await prisma.work_plan_entries.findUnique({
|
||||
where: { id: fourthId },
|
||||
});
|
||||
expect(fresh?.date_from.toISOString().slice(0, 10)).toBe("2099-07-02");
|
||||
});
|
||||
|
||||
it("still allows editing an at-cap entry without moving it (self-exclusion)", async () => {
|
||||
const result = await updateEntry(
|
||||
cappedEntryIds[0],
|
||||
{ date_from: "2099-07-01", date_to: "2099-07-01", note: NOTE },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("still allows a note-only edit and a move to a free day", async () => {
|
||||
const noteOnly = await updateEntry(fourthId, { note: NOTE }, userId, false);
|
||||
expect("error" in noteOnly).toBe(false);
|
||||
|
||||
const move = await updateEntry(
|
||||
fourthId,
|
||||
{ date_from: "2099-07-03", date_to: "2099-07-03" },
|
||||
userId,
|
||||
false,
|
||||
);
|
||||
expect("error" in move).toBe(false);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -85,11 +85,13 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
|
||||
describe("CreateTripSchema", () => {
|
||||
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({
|
||||
vehicle_id: "5",
|
||||
trip_date: "2026-01-15",
|
||||
start_km: "100",
|
||||
end_km: "150.5",
|
||||
end_km: "150",
|
||||
route_from: "Praha",
|
||||
route_to: "Brno",
|
||||
});
|
||||
@@ -97,7 +99,7 @@ describe("Zod form coercion + NaN rejection", () => {
|
||||
if (result.success) {
|
||||
expect(result.data.vehicle_id).toBe(5);
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ export default function BulkPlanModal({
|
||||
value={form.project_id}
|
||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...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>
|
||||
);
|
||||
}
|
||||
@@ -392,7 +392,7 @@ function EditForm(props: Props) {
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } 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 {
|
||||
GridData,
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
import { LoadingState } from "../ui";
|
||||
import { LoadingState, Select, Field } from "../ui";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { fonts } from "../theme";
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
|
||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderRadius: 14,
|
||||
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",
|
||||
|
||||
"& .plan-grid": {
|
||||
@@ -464,6 +469,15 @@ export default function PlanGrid({
|
||||
pulseKey,
|
||||
onCellClick,
|
||||
}: 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 catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
// Index projects by id once per projects change instead of a linear
|
||||
@@ -483,7 +497,15 @@ export default function PlanGrid({
|
||||
);
|
||||
|
||||
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
|
||||
// mutation on the same cell re-triggers the animation (CSS animations
|
||||
// don't restart unless the keyframe applies to a fresh element/class
|
||||
@@ -493,156 +515,172 @@ export default function PlanGrid({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<PlanGridRoot data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
<>
|
||||
{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}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||
`table-layout: auto` happily blows past. The first column
|
||||
gets a fixed width via the col element so the date stamp
|
||||
doesn't get stretched to share space with person columns. */}
|
||||
<colgroup>
|
||||
<col className="plan-grid-date-col" />
|
||||
{users.map((u) => (
|
||||
<col key={u.id} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="plan-grid-date-col">Datum</th>
|
||||
{users.map((u) => {
|
||||
const { first, last } = splitName(u.full_name);
|
||||
return (
|
||||
<th key={u.id}>
|
||||
<span className="plan-person-head">
|
||||
<span className="plan-person-dot" aria-hidden />
|
||||
<span className="plan-person-name">
|
||||
<strong title={u.full_name}>
|
||||
{first}
|
||||
{last ? ` ${last}` : ""}
|
||||
</strong>
|
||||
{shortRole(u.role_name) && (
|
||||
<small>{shortRole(u.role_name)}</small>
|
||||
)}
|
||||
<colgroup>
|
||||
<col className="plan-grid-date-col" />
|
||||
{users.map((u) => (
|
||||
<col key={u.id} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="plan-grid-date-col">Datum</th>
|
||||
{users.map((u) => {
|
||||
const { first, last } = splitName(u.full_name);
|
||||
return (
|
||||
<th key={u.id}>
|
||||
<span className="plan-person-head">
|
||||
<span className="plan-person-dot" aria-hidden />
|
||||
<span className="plan-person-name">
|
||||
<strong title={u.full_name}>
|
||||
{first}
|
||||
{last ? ` ${last}` : ""}
|
||||
</strong>
|
||||
{shortRole(u.role_name) && (
|
||||
<small>{shortRole(u.role_name)}</small>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((date) => {
|
||||
const dow = czechWeekday(date);
|
||||
const dayNum = date.slice(8, 10);
|
||||
const isToday = date === today;
|
||||
const trCls = [
|
||||
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||
isToday ? "is-today" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<tr key={date} className={trCls}>
|
||||
<td className="plan-grid-date-col">
|
||||
<span className="plan-date-stamp">
|
||||
<span className="plan-date-daynum">{dayNum}</span>
|
||||
<span className="plan-date-dow">{dow}</span>
|
||||
</span>
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cellArr = data.cells[u.id]?.[date] ?? [];
|
||||
const cell = cellArr[0] ?? null;
|
||||
const past = isPastDate(date, today);
|
||||
// Past-day cells are read-only in the UI: an empty past
|
||||
// cell is non-interactive (no create modal, no "+" hint),
|
||||
// a past cell with data opens in view mode only. The
|
||||
// server still enforces the past-date rule with a 403
|
||||
// (defense in depth), but the click path here never
|
||||
// reaches a create/edit submission for a past date.
|
||||
const isLocked = !canEdit || past;
|
||||
// `isPulsing` is true for the single (user, date) cell
|
||||
// that the most recent successful mutation touched.
|
||||
// CSS restarts the keyframe animation whenever the
|
||||
// `nonce` changes (we embed it in data-pulse on the
|
||||
// wrapper, see above), so back-to-back mutations on the
|
||||
// same cell re-trigger the pulse.
|
||||
const isPulsing =
|
||||
!!pulseKey &&
|
||||
pulseKey.userId === u.id &&
|
||||
pulseKey.date === date;
|
||||
let cls: string;
|
||||
if (cell) {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell";
|
||||
} else {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell plan-cell--empty";
|
||||
}
|
||||
if (isPulsing) cls += " plan-cell--pulse";
|
||||
return (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={
|
||||
cell
|
||||
? ({
|
||||
"--cat-color": catMap[cell.category]?.color,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onCellClick(u.id, date, cellArr)}
|
||||
aria-label={
|
||||
cell
|
||||
? cellArr.length === 1
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
>
|
||||
{cellArr.map((c, i) => (
|
||||
<Box
|
||||
key={
|
||||
c.entryId != null
|
||||
? c.entryId
|
||||
: c.overrideId != null
|
||||
? `o${c.overrideId}`
|
||||
: i
|
||||
}
|
||||
className="plan-cell-record"
|
||||
style={
|
||||
{
|
||||
"--cat-color": catMap[c.category]?.color,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={c}
|
||||
project={
|
||||
c.project_id
|
||||
? (projectMap.get(c.project_id) ?? null)
|
||||
: null
|
||||
}
|
||||
categoryLabel={planCategoryLabel(
|
||||
c.category,
|
||||
catMap,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{days.map((date) => {
|
||||
const dow = czechWeekday(date);
|
||||
const dayNum = date.slice(8, 10);
|
||||
const isToday = date === today;
|
||||
const trCls = [
|
||||
isWeekend(date) ? "plan-grid-weekend" : "",
|
||||
isToday ? "is-today" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<tr key={date} className={trCls}>
|
||||
<td className="plan-grid-date-col">
|
||||
<span className="plan-date-stamp">
|
||||
<span className="plan-date-daynum">{dayNum}</span>
|
||||
<span className="plan-date-dow">{dow}</span>
|
||||
</span>
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cellArr = data.cells[u.id]?.[date] ?? [];
|
||||
const cell = cellArr[0] ?? null;
|
||||
const past = isPastDate(date, today);
|
||||
// Past-day cells are read-only in the UI: an empty past
|
||||
// cell is non-interactive (no create modal, no "+" hint),
|
||||
// a past cell with data opens in view mode only. The
|
||||
// server still enforces the past-date rule with a 403
|
||||
// (defense in depth), but the click path here never
|
||||
// reaches a create/edit submission for a past date.
|
||||
const isLocked = !canEdit || past;
|
||||
// `isPulsing` is true for the single (user, date) cell
|
||||
// that the most recent successful mutation touched.
|
||||
// CSS restarts the keyframe animation whenever the
|
||||
// `nonce` changes (we embed it in data-pulse on the
|
||||
// wrapper, see above), so back-to-back mutations on the
|
||||
// same cell re-trigger the pulse.
|
||||
const isPulsing =
|
||||
!!pulseKey &&
|
||||
pulseKey.userId === u.id &&
|
||||
pulseKey.date === date;
|
||||
let cls: string;
|
||||
if (cell) {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell";
|
||||
} else {
|
||||
cls = isLocked
|
||||
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
|
||||
: "plan-cell plan-cell--empty";
|
||||
}
|
||||
if (isPulsing) cls += " plan-cell--pulse";
|
||||
return (
|
||||
<td key={u.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
style={
|
||||
cell
|
||||
? ({
|
||||
"--cat-color": catMap[cell.category]?.color,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onCellClick(u.id, date, cellArr)}
|
||||
aria-label={
|
||||
cell
|
||||
? cellArr.length === 1
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
>
|
||||
{cellArr.map((c, i) => (
|
||||
<Box
|
||||
key={
|
||||
c.entryId != null
|
||||
? c.entryId
|
||||
: c.overrideId != null
|
||||
? `o${c.overrideId}`
|
||||
: i
|
||||
}
|
||||
className="plan-cell-record"
|
||||
style={
|
||||
{
|
||||
"--cat-color": catMap[c.category]?.color,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={c}
|
||||
project={
|
||||
c.project_id
|
||||
? (projectMap.get(c.project_id) ?? null)
|
||||
: null
|
||||
}
|
||||
categoryLabel={planCategoryLabel(
|
||||
c.category,
|
||||
catMap,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</PlanGridRoot>
|
||||
</tbody>
|
||||
</table>
|
||||
</PlanGridRoot>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ function ProjectLogRow({
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
@@ -179,10 +179,12 @@ export default function DashQuickActions({
|
||||
});
|
||||
const result: ApiResult<unknown> = await response.json();
|
||||
if (result.success) {
|
||||
// A new trip changes the trip list and the dashboard vehicle widgets —
|
||||
// invalidate the broad domains (prefix-matching covers sub-queries).
|
||||
// A new trip changes the trip list, the dashboard vehicle widgets AND
|
||||
// the vehicles domain (actual_km moves) — invalidate all three broad
|
||||
// domains (prefix-matching covers sub-queries).
|
||||
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
setShowTripModal(false);
|
||||
alert.success(result.message ?? "Jízda uložena");
|
||||
} else {
|
||||
|
||||
@@ -214,7 +214,8 @@ function SortableItemRow({
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
@@ -352,7 +353,8 @@ function SortableItemRow({
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
@@ -109,6 +110,17 @@ export default function OdinChat() {
|
||||
});
|
||||
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
|
||||
// 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
|
||||
@@ -122,6 +134,7 @@ export default function OdinChat() {
|
||||
messagesData.messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
tools: m.meta?.tools,
|
||||
})),
|
||||
);
|
||||
setReview([]);
|
||||
@@ -148,6 +161,9 @@ export default function OdinChat() {
|
||||
messages: msgs.map((m) => ({
|
||||
role: m.role,
|
||||
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);
|
||||
if (!text && files.length === 0) return;
|
||||
|
||||
// Guard: Odin is scoped to invoice processing only (for now). A text-only
|
||||
// message makes NO AI call — it gets a canned reply locally, so open-ended
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Phase 2a: text messages go to the agentic /chat endpoint (read-only
|
||||
// tools over system data); attachments keep the invoice-extract path.
|
||||
setBusy(true);
|
||||
const prevTurns = turns;
|
||||
const prevInput = input;
|
||||
const prevAttachments = attachments;
|
||||
|
||||
const userContent = [
|
||||
text,
|
||||
@@ -240,6 +242,10 @@ export default function OdinChat() {
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
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 {
|
||||
if (files.length > 0) {
|
||||
@@ -280,8 +286,6 @@ export default function OdinChat() {
|
||||
setReview((prev) => [...prev, ...reviews]);
|
||||
const note = summaryNote(reviews);
|
||||
setTurns((t) => [...t, { role: "assistant", content: note }]);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
// Create the conversation now (first successful message) and persist.
|
||||
const convId = await ensureActive();
|
||||
if (convId != null)
|
||||
@@ -301,19 +305,24 @@ export default function OdinChat() {
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body?.error || "Chyba AI");
|
||||
const reply: string = body.data.reply;
|
||||
setTurns((t) => [...t, { role: "assistant", content: reply }]);
|
||||
setInput("");
|
||||
const tools: ChatTurn["tools"] = Array.isArray(body.data.tool_trace)
|
||||
? body.data.tool_trace
|
||||
: undefined;
|
||||
setTurns((t) => [...t, { role: "assistant", content: reply, tools }]);
|
||||
// Create the conversation now (first successful message) and persist.
|
||||
const convId = await ensureActive();
|
||||
if (convId != null)
|
||||
persist(convId, [
|
||||
{ role: "user", content: userContent },
|
||||
{ role: "assistant", content: reply },
|
||||
{ role: "assistant", content: reply, tools },
|
||||
]);
|
||||
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||||
}
|
||||
} catch (e) {
|
||||
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";
|
||||
alert.error(e instanceof Error ? e.message : fallback);
|
||||
} finally {
|
||||
@@ -422,11 +431,26 @@ export default function OdinChat() {
|
||||
return (
|
||||
<Box
|
||||
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",
|
||||
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",
|
||||
borderRadius: 3,
|
||||
borderRadius: { xs: 0, md: 3 },
|
||||
bgcolor: "background.paper",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -452,15 +476,41 @@ export default function OdinChat() {
|
||||
flexDirection: "column",
|
||||
gap: 1.5,
|
||||
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 }}>
|
||||
{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 && (
|
||||
<IconButton
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Konverzace"
|
||||
size="small"
|
||||
sx={{ ml: -0.5, flexShrink: 0 }}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<Box
|
||||
component="svg"
|
||||
@@ -490,8 +540,7 @@ export default function OdinChat() {
|
||||
color="text.secondary"
|
||||
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
||||
>
|
||||
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
||||
{usage.budget_usd.toFixed(2)}
|
||||
Utraceno: ${usage.month_spend_usd.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useReducedMotion,
|
||||
type Variants,
|
||||
} from "framer-motion";
|
||||
import type { ChatTurn } from "./types";
|
||||
import OdinMark from "./OdinMark";
|
||||
|
||||
@@ -29,29 +35,6 @@ interface OdinThreadProps {
|
||||
threadRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
/** Small Odin avatar shown to the left of assistant bubbles. */
|
||||
function OdinAvatar({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "primary.main",
|
||||
color: "common.white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: size * 0.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
O
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OdinThread({
|
||||
turns,
|
||||
busy,
|
||||
@@ -172,12 +155,17 @@ export default function OdinThread({
|
||||
return (
|
||||
<MotionBox
|
||||
key={i}
|
||||
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: reduce ? 0.3 : 0.34,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
initial={
|
||||
reduce
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.3 }
|
||||
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||
}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
@@ -185,9 +173,12 @@ export default function OdinThread({
|
||||
gap: 1,
|
||||
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||
maxWidth: "80%",
|
||||
// The pop grows out of the corner where the bubble is anchored
|
||||
// (next to the avatar / the composer side), not from its centre.
|
||||
transformOrigin: isUser ? "bottom right" : "bottom left",
|
||||
}}
|
||||
>
|
||||
{!isUser && <OdinAvatar size={28} />}
|
||||
{!isUser && <OdinMark size={28} />}
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
@@ -195,8 +186,34 @@ export default function OdinThread({
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
bgcolor: isUser ? "primary.main" : "background.paper",
|
||||
// The global ::selection is primary-on-white — invisible on
|
||||
// this primary-filled bubble; invert it so selected/copied
|
||||
// text stays readable.
|
||||
...(isUser && {
|
||||
"& ::selection": {
|
||||
backgroundColor: "common.white",
|
||||
color: "primary.main",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Tools the assistant consulted for this turn (Phase 2a). */}
|
||||
{!isUser && t.tools && t.tools.length > 0 && (
|
||||
<Box
|
||||
sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 0.75 }}
|
||||
>
|
||||
{t.tools.map((tool, j) => (
|
||||
<Chip
|
||||
key={`${tool.name}-${j}`}
|
||||
size="small"
|
||||
label={`🔍 ${tool.label}`}
|
||||
color={tool.ok ? "default" : "warning"}
|
||||
variant="outlined"
|
||||
sx={{ fontSize: "0.7rem", height: 22 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{/* Color MUST sit on the Typography: GlobalStyles pins `p` to
|
||||
text.secondary, which beats a color merely inherited from the
|
||||
Box. An sx class on the element wins over that element rule. */}
|
||||
@@ -214,25 +231,79 @@ export default function OdinThread({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Busy indicator */}
|
||||
{busy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<OdinMark size={22} state="thinking" />
|
||||
<Typography variant="caption" sx={{ color: "inherit" }}>
|
||||
Pracuji…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
|
||||
the avatar column, springing in/out where the reply will land. */}
|
||||
<AnimatePresence>
|
||||
{busy && (
|
||||
<MotionBox
|
||||
key="busy"
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
reduce
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.85,
|
||||
transition: { duration: 0.16, ease: "easeIn" },
|
||||
}
|
||||
}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.3 }
|
||||
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||
}
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 1,
|
||||
transformOrigin: "bottom left",
|
||||
}}
|
||||
>
|
||||
<OdinMark size={28} state="thinking" />
|
||||
<Box
|
||||
role="status"
|
||||
aria-label="Odin pracuje"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
bgcolor: "background.paper",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.6,
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<MotionBox
|
||||
key={i}
|
||||
animate={
|
||||
reduce
|
||||
? { opacity: [0.35, 1, 0.35] }
|
||||
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "text.secondary",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</MotionBox>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export interface ToolTraceEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export interface ChatTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
/** Read-only tools the assistant called while producing this turn. */
|
||||
tools?: ToolTraceEntry[];
|
||||
}
|
||||
|
||||
export interface ReviewInvoice {
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface StoredChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at: string;
|
||||
/** Parsed content_json — the assistant turn's tool trace, when present. */
|
||||
meta?: {
|
||||
tools?: { name: string; label: string; ok: boolean }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const aiUsageOptions = () =>
|
||||
|
||||
@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
|
||||
export interface ProjectNote {
|
||||
id: number;
|
||||
content: string;
|
||||
user_id: number | null;
|
||||
user_name: string;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
|
||||
@@ -835,7 +835,7 @@ export default function Attendance() {
|
||||
onChange={(val) => handleSwitchProject(val || null)}
|
||||
disabled={switchingProject}
|
||||
options={[
|
||||
{ value: "", label: "— Bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
numberOr,
|
||||
restoreQuillBlankLines,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
@@ -317,7 +318,8 @@ function SortableInvoiceRow({
|
||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
@@ -418,7 +420,8 @@ function SortableInvoiceRow({
|
||||
}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
@@ -1668,7 +1671,9 @@ export default function InvoiceDetail() {
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(s.content),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1688,7 +1693,9 @@ export default function InvoiceDetail() {
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(invoice.internal_notes),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -1795,23 +1802,28 @@ export default function InvoiceDetail() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={headerActionsSx}>
|
||||
{isEdit && invoice && hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={pdfLoading}
|
||||
startIcon={
|
||||
pdfLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
FileIcon
|
||||
)
|
||||
}
|
||||
>
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
{/* Drafts have no number → /file 404s; hide the button like
|
||||
OfferDetail/IssuedOrderDetail do. */}
|
||||
{isEdit &&
|
||||
invoice &&
|
||||
invoice.invoice_number &&
|
||||
hasPermission("invoices.view") && (
|
||||
<Button
|
||||
onClick={() => handleViewPdf(invoice.language || "cs")}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={pdfLoading}
|
||||
startIcon={
|
||||
pdfLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
FileIcon
|
||||
)
|
||||
}
|
||||
>
|
||||
Zobrazit fakturu
|
||||
</Button>
|
||||
)}
|
||||
{/* ── Create mode: two-button save ── */}
|
||||
{!isEdit && (
|
||||
<>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
LoadingState,
|
||||
FilterBar,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
@@ -247,6 +248,9 @@ export default function Invoices() {
|
||||
} else {
|
||||
setStatsMonth((m) => m - 1);
|
||||
}
|
||||
// Back to page 1 — the server never clamps, so keeping e.g. page 3 in a
|
||||
// sparser month shows an empty table (same reset as the filter handlers).
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
@@ -257,6 +261,7 @@ export default function Invoices() {
|
||||
} else {
|
||||
setStatsMonth((m) => m + 1);
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
@@ -706,7 +711,7 @@ export default function Invoices() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{activeTab === "received" ? (
|
||||
<TabPanel value="received" current={activeTab} sx={{ pt: 0 }}>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<ReceivedInvoices
|
||||
statsMonth={statsMonth}
|
||||
@@ -715,7 +720,8 @@ export default function Invoices() {
|
||||
setUploadOpen={setReceivedUploadOpen}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
</TabPanel>
|
||||
<TabPanel value="issued" current={activeTab} sx={{ pt: 0 }}>
|
||||
<>
|
||||
{renderKpi()}
|
||||
|
||||
@@ -808,7 +814,7 @@ export default function Invoices() {
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export default function IssuedOrderDetail() {
|
||||
const editable = !readOnly;
|
||||
const canExport = hasPermission("orders.view");
|
||||
|
||||
const { markClean } = useUnsavedChangesGuard(
|
||||
const { isDirty, markClean } = useUnsavedChangesGuard(
|
||||
{ form, items, sections },
|
||||
!isEdit || dataReady,
|
||||
);
|
||||
@@ -506,11 +506,23 @@ export default function IssuedOrderDetail() {
|
||||
else void handleSubmit();
|
||||
};
|
||||
|
||||
// ─── Status transition (status-ONLY payload — a full-form resubmit on a
|
||||
// non-editable order now 400s server-side) ───
|
||||
// ─── Status transition. With unsaved edits on an editable (sent) order
|
||||
// the transition routes through handleSubmit so the edits are PERSISTED
|
||||
// with the status — a status-only payload would silently drop them, and
|
||||
// the markClean below would re-baseline the guard so even the beforeunload
|
||||
// warning disappears (the order is then read-only: edits unrecoverable).
|
||||
// Clean (or non-editable) orders keep the status-only payload — a
|
||||
// full-form resubmit on a non-editable order 400s server-side. ───
|
||||
const handleStatusChange = async () => {
|
||||
if (!statusConfirm.status || statusChanging) return;
|
||||
const newStatus = statusConfirm.status;
|
||||
if (editable && isDirty) {
|
||||
setStatusConfirm({ show: false, status: null });
|
||||
// handleSubmit validates, sends the full payload + status, archives
|
||||
// the PDF and re-baselines the guard with what was actually persisted.
|
||||
await handleSubmit(newStatus);
|
||||
return;
|
||||
}
|
||||
setStatusChanging(newStatus);
|
||||
try {
|
||||
const result = await statusMutation.mutateAsync({ status: newStatus });
|
||||
|
||||
@@ -133,6 +133,15 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
// Reset to page 1 when the parent moves to another month — the kept page
|
||||
// index would otherwise request e.g. page 2 of a sparser month and render
|
||||
// an empty list. Adjusted during render (adjust-state-on-prop-change
|
||||
// pattern); a remount via `key` would also clear search/filters.
|
||||
const [prevMonthYear, setPrevMonthYear] = useState(`${month}-${year}`);
|
||||
if (`${month}-${year}` !== prevMonthYear) {
|
||||
setPrevMonthYear(`${month}-${year}`);
|
||||
setPage(1);
|
||||
}
|
||||
// Track first successful load so later refetches (filter/status/page change)
|
||||
// keep the table visible instead of flashing the full-page skeleton.
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
|
||||
import OrderConfirmationModal from "../components/OrderConfirmationModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
restoreQuillBlankLines,
|
||||
} from "../utils/formatters";
|
||||
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
||||
import {
|
||||
Button,
|
||||
@@ -713,7 +717,9 @@ export default function OrderDetail() {
|
||||
<RichTextView
|
||||
sx={{ p: 2 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(section.content),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(section.content),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,14 @@ import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { Button, Tabs, PageHeader, PageEnter, LoadingState } from "../ui";
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
LoadingState,
|
||||
} from "../ui";
|
||||
import OrdersReceived from "./ReceivedOrders";
|
||||
|
||||
const IssuedOrders = lazy(() => import("./IssuedOrders"));
|
||||
@@ -170,18 +177,19 @@ export default function Orders() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{activeTab === "vydane" ? (
|
||||
<TabPanel value="vydane" current={activeTab} sx={{ pt: 0 }}>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<IssuedOrders month={statsMonth} year={statsYear} />
|
||||
</Suspense>
|
||||
) : (
|
||||
</TabPanel>
|
||||
<TabPanel value="prijate" current={activeTab} sx={{ pt: 0 }}>
|
||||
<OrdersReceived
|
||||
month={statsMonth}
|
||||
year={statsYear}
|
||||
createOpen={createOpen}
|
||||
setCreateOpen={setCreateOpen}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -667,19 +667,29 @@ export default function PlanWork() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
|
||||
the detail pages); sm+ keeps the single wrapping toolbar row. The
|
||||
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
|
||||
full-width row each. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
flexDirection: { xs: "column", sm: "row" },
|
||||
flexWrap: { sm: "wrap" },
|
||||
alignItems: { xs: "stretch", sm: "center" },
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||
wraps on mobile. */}
|
||||
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
||||
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={goToToday}
|
||||
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
|
||||
>
|
||||
Dnes
|
||||
</Button>
|
||||
<Button
|
||||
@@ -701,8 +711,8 @@ export default function PlanWork() {
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
flex: { sm: 1 },
|
||||
minWidth: { sm: 220 },
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
@@ -727,7 +737,11 @@ export default function PlanWork() {
|
||||
<Box
|
||||
role="group"
|
||||
aria-label="Měřítko zobrazení"
|
||||
sx={{ display: "inline-flex", gap: 1 }}
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={view === "week" ? "contained" : "outlined"}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import ProjectFileManager from "../components/ProjectFileManager";
|
||||
import NoteCard from "../components/NoteCard";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -49,17 +50,6 @@ const STATUS_COLORS: Record<
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
function formatNoteDate(dateStr: string) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -86,28 +76,12 @@ const BackIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
const { hasPermission, isAdmin } = useAuth();
|
||||
const { hasPermission, isAdmin, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<ProjectForm>({
|
||||
@@ -278,6 +252,30 @@ export default function ProjectDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// Throws on failure so NoteCard keeps its editor open with the draft.
|
||||
const saveNoteEdit = async (noteId: number, content: string) => {
|
||||
// Raw apiFetch: the body must carry ONLY content (strict schema), the
|
||||
// note id lives in the URL — useApiMutation sends its whole input.
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
throw new Error("network");
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
alert.error(body?.error || "Uložení selhalo");
|
||||
throw new Error(body?.error || "Uložení selhalo");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
alert.success("Poznámka upravena");
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
@@ -493,61 +491,18 @@ export default function ProjectDetail() {
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{notes.map((note) => (
|
||||
<Box
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{note.user_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(note.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{note.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{TrashIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
authorName={note.user_name}
|
||||
createdAt={note.created_at}
|
||||
editedAt={note.edited_at}
|
||||
content={note.content || ""}
|
||||
canEdit={user?.id === note.user_id}
|
||||
canDelete={isAdmin}
|
||||
deleting={deletingNoteId === note.id}
|
||||
onSave={(content) => saveNoteEdit(note.id, content)}
|
||||
onDelete={() => handleDeleteNote(note.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -554,7 +554,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákaznika" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
@@ -571,7 +571,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, responsible_user_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— nepřiřazeno —" },
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...(usersQuery.data ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
type CompanySettingsData,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
StatCard,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
Pagination,
|
||||
type DataColumn,
|
||||
type StatCardColor,
|
||||
} from "../ui";
|
||||
@@ -262,14 +263,33 @@ export default function ReceivedInvoices({
|
||||
const { data: supplierNames = [] } = useQuery(supplierListOptions());
|
||||
const companySettings = useQuery(companySettingsOptions()).data;
|
||||
|
||||
// List query — auto-refetches when filters change
|
||||
const listQuery = useQuery(
|
||||
const [page, setPage] = useState(1);
|
||||
// Reset to page 1 when the parent moves to another month — the kept page
|
||||
// index would otherwise request e.g. page 2 of a sparser month and render
|
||||
// an empty list. Adjusted during render (adjust-state-on-prop-change
|
||||
// pattern); a remount via `key` would also clear the search field.
|
||||
const [prevMonthYear, setPrevMonthYear] = useState(
|
||||
`${statsMonth}-${statsYear}`,
|
||||
);
|
||||
if (`${statsMonth}-${statsYear}` !== prevMonthYear) {
|
||||
setPrevMonthYear(`${statsMonth}-${statsYear}`);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
// List query — auto-refetches when filters change. Paginated: the server
|
||||
// caps at 25/page, so without the page param rows 26+ were unreachable.
|
||||
const {
|
||||
items: invoices,
|
||||
pagination,
|
||||
isPending: listPending,
|
||||
} = usePaginatedQuery<ReceivedInvoice>(
|
||||
receivedInvoiceListOptions({
|
||||
month: statsMonth,
|
||||
year: statsYear,
|
||||
search,
|
||||
sort,
|
||||
order,
|
||||
page,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -290,13 +310,10 @@ export default function ReceivedInvoices({
|
||||
}),
|
||||
);
|
||||
|
||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||
const invoices = listQuery.data?.data ?? [];
|
||||
|
||||
// Track first successful load (used to suppress the skeleton on later
|
||||
// refetches). Set in an effect rather than during render to avoid a
|
||||
// render-phase ref mutation.
|
||||
const hasData = !!(listQuery.data || statsQuery.data);
|
||||
const hasData = !!(pagination || statsQuery.data);
|
||||
useEffect(() => {
|
||||
if (hasData) hasLoadedOnce.current = true;
|
||||
}, [hasData]);
|
||||
@@ -337,7 +354,7 @@ export default function ReceivedInvoices({
|
||||
},
|
||||
});
|
||||
|
||||
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
|
||||
const showListSkeleton = listPending && !hasLoadedOnce.current;
|
||||
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
|
||||
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
|
||||
@@ -810,73 +827,75 @@ export default function ReceivedInvoices({
|
||||
<>
|
||||
{renderKpi()}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<Card>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Hledat podle dodavatele nebo čísla faktury..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{showListSkeleton ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<ReceivedInvoice>
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(inv) => inv.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
<Card>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Hledat podle dodavatele nebo čísla faktury..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{showListSkeleton ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<ReceivedInvoice>
|
||||
columns={columns}
|
||||
rows={invoices}
|
||||
rowKey={(inv) => inv.id}
|
||||
rowSx={rowSx}
|
||||
sortBy={sort}
|
||||
sortDir={order}
|
||||
onSort={handleSort}
|
||||
empty={
|
||||
search ? (
|
||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Žádné přijaté faktury v tomto měsíci."
|
||||
description={
|
||||
hasPermission("invoices.create")
|
||||
? "Nahrajte faktury tlačítkem výše."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(listTotalsQuery.data?.length ?? 0) > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
mt: 1.5,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
<span>Celkem:</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, color: "text.primary" }}
|
||||
>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
{formatMultiCurrency(listTotalsQuery.data ?? [])}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<Modal
|
||||
|
||||
@@ -625,7 +625,7 @@ export default function OrdersReceived({
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákazníka" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
|
||||
@@ -117,8 +117,29 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
customers: "Zákazníci",
|
||||
users: "Uživatelé",
|
||||
settings: "Nastavení",
|
||||
ai: "AI asistent",
|
||||
};
|
||||
|
||||
// Display order of the permission-module groups in the role modal. Before
|
||||
// this list the order fell out of DB insertion ids, which differ between dev
|
||||
// (reseeded) and prod (migration order). Reorder by rearranging this array;
|
||||
// modules missing from it (e.g. added by a future migration before this list
|
||||
// is updated) fall to the very end so they stay visible.
|
||||
const MODULE_ORDER = [
|
||||
"ai",
|
||||
"attendance",
|
||||
"trips",
|
||||
"vehicles",
|
||||
"offers",
|
||||
"orders",
|
||||
"projects",
|
||||
"invoices",
|
||||
"warehouse",
|
||||
"customers",
|
||||
"users",
|
||||
"settings",
|
||||
];
|
||||
|
||||
interface Permission {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -313,7 +334,7 @@ export default function Settings() {
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
const saveSystemSettingsMutation = useApiMutation<
|
||||
typeof sysForm,
|
||||
Partial<typeof sysForm>,
|
||||
{ message?: string; error?: string }
|
||||
>({
|
||||
url: () => `${API_BASE}/company-settings`,
|
||||
@@ -403,7 +424,24 @@ export default function Settings() {
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
try {
|
||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||
// Send ONLY the fields this tab edits. sysForm also carries
|
||||
// numbering/currency fields captured once at init (sysFormInitialized
|
||||
// never resets), and the backend applies every defined field — sending
|
||||
// the whole form would silently revert a Firma-tab numbering edit made
|
||||
// in the meantime with stale values.
|
||||
await saveSystemSettingsMutation.mutateAsync({
|
||||
break_threshold_hours: sysForm.break_threshold_hours,
|
||||
break_duration_short: sysForm.break_duration_short,
|
||||
break_duration_long: sysForm.break_duration_long,
|
||||
clock_rounding_minutes: sysForm.clock_rounding_minutes,
|
||||
invoice_alert_email: sysForm.invoice_alert_email,
|
||||
leave_notify_email: sysForm.leave_notify_email,
|
||||
smtp_from: sysForm.smtp_from,
|
||||
smtp_from_name: sysForm.smtp_from_name,
|
||||
max_login_attempts: sysForm.max_login_attempts,
|
||||
lockout_minutes: sysForm.lockout_minutes,
|
||||
max_requests_per_minute: sysForm.max_requests_per_minute,
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -1243,8 +1281,12 @@ export default function Settings() {
|
||||
|
||||
{Object.entries(permissionGroups)
|
||||
.sort(([a, aPerms], [b, bPerms]) => {
|
||||
if (a === "settings") return 1;
|
||||
if (b === "settings") return -1;
|
||||
const ai = MODULE_ORDER.indexOf(a);
|
||||
const bi = MODULE_ORDER.indexOf(b);
|
||||
const aKey = ai === -1 ? MODULE_ORDER.length : ai;
|
||||
const bKey = bi === -1 ? MODULE_ORDER.length : bi;
|
||||
if (aKey !== bKey) return aKey - bKey;
|
||||
// Both unknown: stable fallback by DB insertion order.
|
||||
const aMin = Math.min(...aPerms.map((p) => p.id));
|
||||
const bMin = Math.min(...bPerms.map((p) => p.id));
|
||||
return aMin - bMin;
|
||||
|
||||
@@ -23,6 +23,41 @@ export default function AppShell() {
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Chat-style pages own the full mobile viewport: under md the shell header
|
||||
// disappears and main loses its padding — the page brings its own back
|
||||
// button. Desktop keeps the normal shell.
|
||||
const immersiveOnMobile = location.pathname === "/odin";
|
||||
|
||||
// Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android
|
||||
// svh/dvh are computed against a viewport that includes system UI the real
|
||||
// layout viewport doesn't have, leaving scroll room no unit can remove
|
||||
// (and Chrome can serve a stale viewport after minimize/restore). Measure
|
||||
// the truth — window.innerHeight — into --app-height and keep it fresh;
|
||||
// immersive layouts size from it with an svh fallback.
|
||||
useEffect(() => {
|
||||
if (!immersiveOnMobile) return;
|
||||
const root = document.documentElement;
|
||||
const set = () =>
|
||||
root.style.setProperty("--app-height", `${window.innerHeight}px`);
|
||||
set();
|
||||
window.addEventListener("resize", set);
|
||||
window.visualViewport?.addEventListener("resize", set);
|
||||
// Kill pull-to-refresh: a PTR reload lands mid-viewport-settle and
|
||||
// re-races the measurement (Chromium settles without a resize event).
|
||||
// The immersive page is fixed-viewport — the gesture has no use here.
|
||||
const prevRootOverscroll = root.style.overscrollBehaviorY;
|
||||
const prevBodyOverscroll = document.body.style.overscrollBehaviorY;
|
||||
root.style.overscrollBehaviorY = "none";
|
||||
document.body.style.overscrollBehaviorY = "none";
|
||||
return () => {
|
||||
window.removeEventListener("resize", set);
|
||||
window.visualViewport?.removeEventListener("resize", set);
|
||||
root.style.removeProperty("--app-height");
|
||||
root.style.overscrollBehaviorY = prevRootOverscroll;
|
||||
document.body.style.overscrollBehaviorY = prevBodyOverscroll;
|
||||
};
|
||||
}, [immersiveOnMobile]);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setMobileOpen(false);
|
||||
@@ -74,13 +109,22 @@ export default function AppShell() {
|
||||
duration: loggingOut ? 0.4 : 0.25,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
style={{ minHeight: "100dvh" }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
minHeight: "100dvh",
|
||||
bgcolor: "background.default",
|
||||
// Immersive mobile pages must make the DOCUMENT exactly the
|
||||
// small-viewport height and clip it: MIUI/Xiaomi Chrome keeps
|
||||
// 100dvh at the large-viewport size while the URL bar overlays,
|
||||
// so a 100dvh min-height leaves the page scrollable by the bar
|
||||
// height (invisible in desktop emulation). svh + hidden overflow
|
||||
// makes body scroll impossible regardless of dvh interpretation.
|
||||
minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh",
|
||||
...(immersiveOnMobile && {
|
||||
height: { xs: "var(--app-height, 100svh)", md: "auto" },
|
||||
overflow: { xs: "hidden", md: "visible" },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
@@ -123,6 +167,7 @@ export default function AppShell() {
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
@@ -130,7 +175,9 @@ export default function AppShell() {
|
||||
<Box
|
||||
component="header"
|
||||
sx={{
|
||||
display: "flex",
|
||||
display: immersiveOnMobile
|
||||
? { xs: "none", md: "flex" }
|
||||
: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
@@ -160,7 +207,18 @@ export default function AppShell() {
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<ThemeToggle />
|
||||
</Box>
|
||||
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flex: 1,
|
||||
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
||||
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
||||
...(immersiveOnMobile && {
|
||||
minHeight: 0,
|
||||
overflow: { xs: "hidden", md: "visible" },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
|
||||
import MuiTabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Box from "@mui/material/Box";
|
||||
import { keyframes } from "@mui/system";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
|
||||
export interface TabDef {
|
||||
value: string;
|
||||
@@ -47,15 +49,26 @@ export function Tabs({
|
||||
);
|
||||
}
|
||||
|
||||
// The ONE tab-switch animation, app-wide: enter-only fade of the incoming
|
||||
// panel (M3 "clean fade" — old content drops instantly, no exit/slide; tabs
|
||||
// are too high-frequency for anything longer). Kept as a CSS animation so the
|
||||
// global prefers-reduced-motion kill switch in GlobalStyles neutralizes it.
|
||||
const panelEnter = keyframes({
|
||||
from: { opacity: 0, transform: "translateY(6px)" },
|
||||
to: { opacity: 1, transform: "none" },
|
||||
});
|
||||
|
||||
/** Renders children only when its value matches the current tab. */
|
||||
export function TabPanel({
|
||||
value,
|
||||
current,
|
||||
children,
|
||||
sx,
|
||||
}: {
|
||||
value: string;
|
||||
current: string;
|
||||
children: ReactNode;
|
||||
sx?: SxProps<Theme>;
|
||||
}) {
|
||||
if (value !== current) return null;
|
||||
return (
|
||||
@@ -63,7 +76,13 @@ export function TabPanel({
|
||||
role="tabpanel"
|
||||
id={panelId(value)}
|
||||
aria-labelledby={tabId(value)}
|
||||
sx={{ pt: 2.5 }}
|
||||
sx={[
|
||||
{
|
||||
pt: 2.5,
|
||||
animation: `${panelEnter} 0.2s cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
@@ -76,3 +76,15 @@ export function czechPlural(
|
||||
if (n >= 2 && n <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
|
||||
* collapse to zero height outside the editor — restore the <br> placeholder
|
||||
* before rendering stored rich text read-only. (The PDF path does the same
|
||||
* in cleanQuillHtml.)
|
||||
*/
|
||||
export function restoreQuillBlankLines(
|
||||
html: string | null | undefined,
|
||||
): string {
|
||||
return (html || "").replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { requirePermission } from "../../middleware/auth";
|
||||
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||||
import { success, error, parseId } from "../../utils/response";
|
||||
import { parseBody } from "../../schemas/common";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getMonthSpendUsd,
|
||||
getBudgetUsd,
|
||||
setBudgetUsd,
|
||||
chat,
|
||||
agenticChat,
|
||||
extractInvoice,
|
||||
listConversations,
|
||||
createConversation,
|
||||
@@ -54,10 +54,14 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// PUT /api/admin/ai/budget — set the monthly budget (admins have ai.use)
|
||||
// PUT /api/admin/ai/budget — set the monthly budget. A COMPANY setting
|
||||
// (budget_usd=0 is a company-wide AI DoS; a huge value removes the cost
|
||||
// cap), so it is gated like company_settings writes, NOT by ai.use.
|
||||
app.put(
|
||||
"/budget",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
{
|
||||
preHandler: requireAnyPermission("settings.company", "settings.system"),
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = parseBody(AiBudgetSchema, request.body);
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
@@ -75,7 +79,9 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/ai/chat
|
||||
// POST /api/admin/ai/chat — agentic turn with read-only tools (Phase 2a).
|
||||
// The tools execute AS this user: the authData context (permissions +
|
||||
// admin bypass) is what each tool handler checks — see ai-tools.ts.
|
||||
app.post(
|
||||
"/chat",
|
||||
{ preHandler: requirePermission("ai.use") },
|
||||
@@ -85,16 +91,24 @@ export default async function aiRoutes(app: FastifyInstance): Promise<void> {
|
||||
if ("error" in body) return error(reply, body.error, 400);
|
||||
const budgetErr = await assertBudgetAvailable();
|
||||
if (budgetErr) return error(reply, budgetErr.error, budgetErr.status);
|
||||
const { reply: text } = await chat(
|
||||
body.data.messages,
|
||||
request.authData!.userId,
|
||||
);
|
||||
const auth = request.authData!;
|
||||
const { reply: text, toolTrace } = await agenticChat(body.data.messages, {
|
||||
userId: auth.userId,
|
||||
// AuthData.roleName is nullable; a null role simply never matches the
|
||||
// admin bypass and holds only its explicit permissions.
|
||||
roleName: auth.roleName ?? "",
|
||||
permissions: auth.permissions,
|
||||
});
|
||||
const [budgetAfter, spendAfter] = await Promise.all([
|
||||
getBudgetUsd(),
|
||||
getMonthSpendUsd(),
|
||||
]);
|
||||
const remaining_usd = Math.max(0, budgetAfter - spendAfter);
|
||||
return success(reply, { reply: text, remaining_usd });
|
||||
return success(reply, {
|
||||
reply: text,
|
||||
tool_trace: toolTrace,
|
||||
remaining_usd,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ export default async function auditLogRoutes(
|
||||
if (query.date_from || query.date_to) {
|
||||
const dateFilter: Record<string, Date> = {};
|
||||
if (query.date_from) {
|
||||
const from = new Date(String(query.date_from));
|
||||
// LOCAL midnight, symmetric with the date_to side below — a bare
|
||||
// new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague) and
|
||||
// silently excluded events from the first morning hours.
|
||||
const from = new Date(String(query.date_from) + "T00:00:00");
|
||||
if (isNaN(from.getTime())) {
|
||||
return error(reply, "Neplatné datum od", 400);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ export default async function customersRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
// {id} tiebreak: the allow-listed sort columns are non-unique, and
|
||||
// offset pagination over a tied run reorders between page queries
|
||||
// (rows duplicate/vanish across pages) without a total order.
|
||||
orderBy: [{ [sortField]: order }, { id: order }],
|
||||
include: { _count: { select: { quotations: true } } },
|
||||
}),
|
||||
prisma.customers.count({ where }),
|
||||
|
||||
@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
|
||||
const t = translations[lang] || translations.cs;
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -408,7 +408,22 @@ export default async function invoicesPdfRoutes(
|
||||
const issueDateStr = invoice.issue_date
|
||||
? localDateStr(new Date(invoice.issue_date))
|
||||
: undefined;
|
||||
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
|
||||
// CNB may be unreachable (or the issue date never cached). The rate
|
||||
// only feeds the CZK recap + conversion footnote — degrade by
|
||||
// omitting them (cnbRate = null) rather than failing the whole
|
||||
// render AND the NAS archival (expected condition, logged).
|
||||
let cnbRate: number | null = 1.0;
|
||||
if (isForeign) {
|
||||
try {
|
||||
cnbRate = await getRate(currency, issueDateStr);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[invoices-pdf] Kurz ČNB nedostupný pro ${currency} — rekapitulace v Kč vynechána`,
|
||||
err,
|
||||
);
|
||||
cnbRate = null;
|
||||
}
|
||||
}
|
||||
const vatRates = [21, 12, 0];
|
||||
const vatRecap: Array<{
|
||||
rate: number;
|
||||
@@ -416,16 +431,18 @@ export default async function invoicesPdfRoutes(
|
||||
vat: number;
|
||||
total: number;
|
||||
}> = [];
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
if (cnbRate !== null) {
|
||||
for (const rate of vatRates) {
|
||||
const key = String(rate);
|
||||
const base = vatSummary[key]?.base ?? 0;
|
||||
const vat = vatSummary[key]?.vat ?? 0;
|
||||
vatRecap.push({
|
||||
rate,
|
||||
base: Math.round(base * cnbRate * 100) / 100,
|
||||
vat: Math.round(vat * cnbRate * 100) / 100,
|
||||
total: Math.round((base + vat) * cnbRate * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
@@ -503,6 +520,39 @@ export default async function invoicesPdfRoutes(
|
||||
)
|
||||
.join("");
|
||||
|
||||
// CNB rate unavailable → the CZK figures cannot be computed; omit
|
||||
// the whole recap table (the payment QR next to it stays).
|
||||
const recapTableHtml =
|
||||
cnbRate === null
|
||||
? ""
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">${escapeHtml(t.vat_recap)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${escapeHtml(t.vat_base)}</th>
|
||||
<th>${escapeHtml(t.vat_rate)}</th>
|
||||
<th>${escapeHtml(t.vat_amount)}</th>
|
||||
<th>${escapeHtml(t.vat_with_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${vatRecapHtml}
|
||||
</tbody>
|
||||
${
|
||||
isForeign
|
||||
? `<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||||
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>`
|
||||
: ""
|
||||
}
|
||||
</table>`;
|
||||
|
||||
let vatDetailHtml = "";
|
||||
if (applyVat) {
|
||||
for (const [rate, data] of Object.entries(vatSummary)) {
|
||||
@@ -898,7 +948,7 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -910,7 +960,7 @@ export default async function invoicesPdfRoutes(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
@@ -1050,33 +1100,7 @@ ${indentCSS}
|
||||
<div class="qr">
|
||||
${qrSvg}
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">${escapeHtml(t.vat_recap)}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>${escapeHtml(t.vat_base)}</th>
|
||||
<th>${escapeHtml(t.vat_rate)}</th>
|
||||
<th>${escapeHtml(t.vat_amount)}</th>
|
||||
<th>${escapeHtml(t.vat_with_total)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${vatRecapHtml}
|
||||
</tbody>
|
||||
${
|
||||
isForeign
|
||||
? `<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" style="font-size:0.7em; color:#666; padding-top:6px; text-align:left;">
|
||||
${escapeHtml(t.cnb_rate)} ${formatDate(invoice.issue_date)}: 1 ${escapeHtml(currency)} = ${cnbRate.toFixed(3).replace(".", ",")} CZK
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>`
|
||||
: ""
|
||||
}
|
||||
</table>
|
||||
${recapTableHtml}
|
||||
</div>
|
||||
|
||||
<!-- Prevzal / razitko -->
|
||||
|
||||
@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ReviewLeaveRequestSchema,
|
||||
} from "../../schemas/leave-requests.schema";
|
||||
import { notifyNewLeaveRequest } from "../../services/leave-notification";
|
||||
import { isHoliday } from "../../utils/czech-holidays";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
|
||||
const VALID_LEAVE_TYPES = ["vacation", "sick", "unpaid"] as const;
|
||||
const VALID_REVIEW_STATUSES = ["approved", "rejected"] as const;
|
||||
@@ -87,12 +89,16 @@ export default async function leaveRequestsRoutes(
|
||||
return error(reply, "Datum do musí být po datu od", 400);
|
||||
}
|
||||
|
||||
// Compute business days server-side (matching PHP logic)
|
||||
// Compute business days server-side — weekends AND Czech public
|
||||
// holidays excluded (mirrors attendance.service createLeave: a weekday
|
||||
// holiday is already paid/free, charging it would double-deduct).
|
||||
let businessDays = 0;
|
||||
const current = new Date(dateFrom);
|
||||
while (current <= dateTo) {
|
||||
const day = current.getDay();
|
||||
if (day !== 0 && day !== 6) businessDays++;
|
||||
if (day !== 0 && day !== 6 && !isHoliday(localDateStr(current))) {
|
||||
businessDays++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
@@ -185,31 +191,19 @@ export default async function leaveRequestsRoutes(
|
||||
}
|
||||
|
||||
if (status === "approved") {
|
||||
// --- APPROVAL: create attendance records + update leave balance (matching PHP) ---
|
||||
// --- APPROVAL: create attendance records + update leave balance ---
|
||||
const leaveType = existing.leave_type as string;
|
||||
const dateFrom = new Date(existing.date_from);
|
||||
const dateTo = new Date(existing.date_to);
|
||||
|
||||
// For vacation: re-check balance at approval time
|
||||
if (leaveType === "vacation") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
const totalHours = Number(existing.total_hours) || 0;
|
||||
if (totalHours > vacRemaining) {
|
||||
return error(
|
||||
reply,
|
||||
`Nedostatek dovolené. Zbývá ${vacRemaining}h, požadováno ${totalHours}h.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Walk the range once (mirrors attendance.service createLeave):
|
||||
// weekends AND Czech public holidays are skipped — a weekday holiday
|
||||
// is already paid/free, charging it would double-deduct — and hours
|
||||
// accumulate per CALENDAR YEAR so a Dec→Jan request books each day
|
||||
// against its own year's balance. The stored total_hours is NOT
|
||||
// trusted (pre-fix requests carry holiday-inflated totals).
|
||||
let totalBusinessDays = 0;
|
||||
const hoursByYear = new Map<number, number>();
|
||||
const current = new Date(dateFrom);
|
||||
const attendanceCreates: Array<{
|
||||
user_id: number;
|
||||
@@ -221,8 +215,10 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
while (current <= dateTo) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
|
||||
totalBusinessDays++;
|
||||
const dayYear = current.getFullYear();
|
||||
hoursByYear.set(dayYear, (hoursByYear.get(dayYear) ?? 0) + 8);
|
||||
attendanceCreates.push({
|
||||
user_id: existing.user_id,
|
||||
shift_date: new Date(
|
||||
@@ -245,6 +241,26 @@ export default async function leaveRequestsRoutes(
|
||||
|
||||
const totalHours = totalBusinessDays * 8;
|
||||
|
||||
// For vacation: re-check the balance at approval time — per year,
|
||||
// each against ITS OWN remaining entitlement.
|
||||
if (leaveType === "vacation") {
|
||||
for (const [year, yearHours] of hoursByYear) {
|
||||
const balance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
const vacTotal = balance ? Number(balance.vacation_total) : 160;
|
||||
const vacUsed = balance ? Number(balance.vacation_used) : 0;
|
||||
const vacRemaining = vacTotal - vacUsed;
|
||||
if (yearHours > vacRemaining) {
|
||||
return error(
|
||||
reply,
|
||||
`Nedostatek dovolené pro rok ${year}. Zbývá ${vacRemaining}h, požadováno ${yearHours}h.`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Check for duplicate attendance records inside the transaction
|
||||
@@ -264,38 +280,40 @@ export default async function leaveRequestsRoutes(
|
||||
await tx.attendance.createMany({ data: attendanceCreates });
|
||||
}
|
||||
|
||||
// 2. Update leave balance (vacation/sick only — not unpaid)
|
||||
// 2. Update leave balances (vacation/sick only — not unpaid),
|
||||
// each calendar year charged separately.
|
||||
if (leaveType === "vacation" || leaveType === "sick") {
|
||||
const year = dateFrom.getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
for (const [year, yearHours] of hoursByYear) {
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: existing.user_id, year },
|
||||
});
|
||||
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + totalHours;
|
||||
if (existingBalance) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (leaveType === "vacation") {
|
||||
updateData.vacation_used =
|
||||
Number(existingBalance.vacation_used) + yearHours;
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + yearHours;
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
updateData.sick_used =
|
||||
Number(existingBalance.sick_used) + totalHours;
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? yearHours : 0,
|
||||
sick_used: leaveType === "sick" ? yearHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: existing.user_id,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ export function renderOfferHtml(
|
||||
img, table, pre, code { max-width: 100%; }
|
||||
|
||||
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
|
||||
/* ---- Quill alignment ---- */
|
||||
.ql-align-center { text-align: center; }
|
||||
@@ -502,7 +502,7 @@ ${indentCSS}
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
|
||||
@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateProjectSchema,
|
||||
CreateProjectSchema,
|
||||
CreateProjectNoteSchema,
|
||||
UpdateProjectNoteSchema,
|
||||
} from "../../schemas/projects.schema";
|
||||
import {
|
||||
listProjects,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteProject,
|
||||
createProjectNote,
|
||||
deleteProjectNote,
|
||||
updateProjectNote,
|
||||
} from "../../services/projects.service";
|
||||
|
||||
// Body for DELETE /:id — optional delete_files flag. The body may be absent on
|
||||
@@ -174,7 +176,47 @@ export default async function projectsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId
|
||||
// PUT /api/admin/projects/:id/notes/:noteId — author-only edit; the edit
|
||||
// is visibly stamped (edited_at) so a changed note can't pose as original.
|
||||
fastify.put<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
async (request, reply) => {
|
||||
const noteId = parseId(request.params.noteId, reply);
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
const parsed = parseBody(UpdateProjectNoteSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const authData = request.authData!;
|
||||
|
||||
const result = await updateProjectNote(
|
||||
projectId,
|
||||
noteId,
|
||||
authData.userId,
|
||||
parsed.data.content,
|
||||
);
|
||||
if (!result) return error(reply, "Poznámka nenalezena", 404);
|
||||
if (result.error !== undefined) {
|
||||
return error(reply, result.error, result.status ?? 403);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
description: `Upravena poznámka projektu`,
|
||||
oldValues: { content: result.previousContent },
|
||||
newValues: { content: result.note.content },
|
||||
});
|
||||
return success(reply, { note: result.note }, 200, "Poznámka upravena");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId — admin only (user
|
||||
// decision: authors edit their notes, only an admin removes anyone's).
|
||||
fastify.delete<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
@@ -183,6 +225,9 @@ export default async function projectsRoutes(
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
if (request.authData!.roleName !== "admin") {
|
||||
return error(reply, "Poznámky může mazat pouze administrátor", 403);
|
||||
}
|
||||
|
||||
const note = await deleteProjectNote(projectId, noteId);
|
||||
if (!note) return error(reply, "Poznámka nenalezena", 404);
|
||||
|
||||
@@ -141,7 +141,9 @@ export default async function receivedInvoicesRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
// {id} tiebreak — non-unique sort keys reorder between page
|
||||
// queries without it (rows duplicate/vanish across pages).
|
||||
orderBy: [{ [sortField]: order }, { id: order }],
|
||||
}),
|
||||
prisma.received_invoices.count({ where }),
|
||||
]);
|
||||
@@ -396,6 +398,19 @@ export default async function receivedInvoicesRoutes(
|
||||
const issueDate = meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null;
|
||||
const dueDate = meta.due_date
|
||||
? new Date(String(meta.due_date))
|
||||
: null;
|
||||
// The schema regex can't catch impossible dates ("2098-99-99") —
|
||||
// an Invalid Date here would derive NaN month/year and fail at
|
||||
// Prisma only AFTER the NAS save (orphaned file). Reject before
|
||||
// any side effect.
|
||||
if (issueDate && isNaN(issueDate.getTime())) {
|
||||
return error(reply, "Neplatné datum vystavení", 400);
|
||||
}
|
||||
if (dueDate && isNaN(dueDate.getTime())) {
|
||||
return error(reply, "Neplatné datum splatnosti", 400);
|
||||
}
|
||||
const invoiceMonth = issueDate
|
||||
? issueDate.getMonth() + 1
|
||||
: Number(meta.month) || now.getMonth() + 1;
|
||||
@@ -432,10 +447,8 @@ export default async function receivedInvoicesRoutes(
|
||||
currency: meta.currency ? String(meta.currency) : "CZK",
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
issue_date: meta.issue_date
|
||||
? new Date(String(meta.issue_date))
|
||||
: null,
|
||||
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
|
||||
issue_date: issueDate,
|
||||
due_date: dueDate,
|
||||
status: "unpaid",
|
||||
notes: meta.notes ? String(meta.notes) : null,
|
||||
uploaded_by: request.authData?.userId,
|
||||
|
||||
@@ -60,6 +60,11 @@ export default async function usersRoutes(
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
// Privilege-escalation guard (mirrors PUT): role assignment is
|
||||
// admin-only. A bare users.create holder could otherwise mint an
|
||||
// account on a high-privilege role the caller itself lacks.
|
||||
const callerIsAdmin = request.authData?.roleName === "admin";
|
||||
|
||||
const result = await createUser(
|
||||
{
|
||||
username: body.username,
|
||||
@@ -67,7 +72,7 @@ export default async function usersRoutes(
|
||||
password: body.password,
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
role_id: body.role_id,
|
||||
role_id: callerIsAdmin ? body.role_id : undefined,
|
||||
is_active: body.is_active,
|
||||
},
|
||||
request.authData?.roleName ?? undefined,
|
||||
|
||||
@@ -73,6 +73,90 @@ function numFilter(raw: unknown): number | undefined {
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-day boundaries for created_at range filters. created_at holds
|
||||
* LOCAL-time instants, so the from-bound is the day's local midnight — a
|
||||
* bare new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague), which
|
||||
* silently dropped the first morning hours. The to-bound is the NEXT day's
|
||||
* local midnight, exclusive — `lte` against UTC midnight dropped the entire
|
||||
* inclusive end day.
|
||||
*/
|
||||
function localDayStart(dateStr: string): Date {
|
||||
return new Date(`${dateStr}T00:00:00`);
|
||||
}
|
||||
function localDayEndExclusive(dateStr: string): Date {
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* FK pre-validation for receipt/issue payloads (mirrors the document
|
||||
* modules): a dangling id would otherwise raise P2003 at Prisma and surface
|
||||
* as a generic 500. Returns a Czech message for a clean 400, or null when
|
||||
* all referenced rows exist.
|
||||
*/
|
||||
async function findMissingLineRef(
|
||||
items: Array<{ item_id: number; location_id?: number | null }>,
|
||||
): Promise<string | null> {
|
||||
for (const line of items) {
|
||||
const item = await prisma.sklad_items.findUnique({
|
||||
where: { id: line.item_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!item) return `Položka ID ${line.item_id} nenalezena`;
|
||||
if (line.location_id != null) {
|
||||
const location = await prisma.sklad_locations.findUnique({
|
||||
where: { id: line.location_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!location) return `Lokace ID ${line.location_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findMissingReceiptRef(body: {
|
||||
supplier_id?: number | null;
|
||||
items?: Array<{ item_id: number; location_id?: number | null }>;
|
||||
}): Promise<string | null> {
|
||||
if (body.supplier_id != null) {
|
||||
const supplier = await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: body.supplier_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!supplier) return "Dodavatel nenalezen";
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
async function findMissingIssueRef(body: {
|
||||
project_id?: number;
|
||||
items?: Array<{
|
||||
item_id: number;
|
||||
batch_id?: number | null;
|
||||
location_id?: number | null;
|
||||
}>;
|
||||
}): Promise<string | null> {
|
||||
if (body.project_id != null) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: body.project_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) return "Projekt nenalezen";
|
||||
}
|
||||
for (const line of body.items ?? []) {
|
||||
if (line.batch_id != null) {
|
||||
const batch = await prisma.sklad_batches.findUnique({
|
||||
where: { id: line.batch_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!batch) return `Šarže ID ${line.batch_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
// NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it
|
||||
// per sub-entity (items/receipts/issues/reservations/inventory/reports) is a
|
||||
// tracked refactor, deferred — see REVIEW_FINDINGS.md.
|
||||
@@ -625,13 +709,22 @@ export default async function warehouseRoutes(
|
||||
"/items",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
async (request, reply) => {
|
||||
const { page, limit, skip, search } = parsePagination(
|
||||
const { page, limit, skip, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const categoryFilter = query.category_id
|
||||
? Number(query.category_id)
|
||||
: undefined;
|
||||
// Allow-listed DB sort columns (computed columns like total_quantity
|
||||
// can't be ordered server-side); anything else falls back to name.
|
||||
const ITEM_SORT_FIELDS = ["name", "item_number", "created_at"];
|
||||
const sortField = ITEM_SORT_FIELDS.includes(String(query.sort))
|
||||
? String(query.sort)
|
||||
: "name";
|
||||
// Default stays ASC (the route always sorted name asc); parsePagination
|
||||
// defaults to desc, so only honor it when the client sent one.
|
||||
const sortOrder = query.order ? order : "asc";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (search || categoryFilter !== undefined) {
|
||||
@@ -659,7 +752,9 @@ export default async function warehouseRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { name: "asc" },
|
||||
// {id} tiebreak — name/item_number are non-unique; offset
|
||||
// pagination needs a total order or rows duplicate/vanish.
|
||||
orderBy: [{ [sortField]: sortOrder }, { id: sortOrder }],
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
item_locations: {
|
||||
@@ -1032,10 +1127,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ supplier_id: supplierId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1114,6 +1213,9 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: {
|
||||
supplier_id: body.supplier_id ?? null,
|
||||
@@ -1183,6 +1285,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.supplier_id !== undefined)
|
||||
updateData.supplier_id = body.supplier_id;
|
||||
@@ -1498,10 +1603,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ project_id: projectId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1585,6 +1694,11 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
// Before FIFO resolution, so a nonexistent item gets "Položka
|
||||
// nenalezena" instead of a misleading insufficient-stock message.
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
// Resolve FIFO batches for items without batch_id
|
||||
const resolvedItems: Array<{
|
||||
item_id: number;
|
||||
@@ -1704,6 +1818,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.project_id !== undefined)
|
||||
updateData.project_id = body.project_id;
|
||||
@@ -2400,12 +2517,12 @@ export default async function warehouseRoutes(
|
||||
}
|
||||
if (query.date_from) {
|
||||
issueWhere.push({
|
||||
created_at: { gte: new Date(String(query.date_from)) },
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
issueWhere.push({
|
||||
created_at: { lte: new Date(String(query.date_to)) },
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2491,14 +2608,14 @@ export default async function warehouseRoutes(
|
||||
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
|
||||
|
||||
if (query.date_from) {
|
||||
const dateFrom = new Date(String(query.date_from));
|
||||
const dateFrom = localDayStart(String(query.date_from));
|
||||
receiptWhere.push({ created_at: { gte: dateFrom } });
|
||||
issueWhere.push({ created_at: { gte: dateFrom } });
|
||||
}
|
||||
if (query.date_to) {
|
||||
const dateTo = new Date(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lte: dateTo } });
|
||||
issueWhere.push({ created_at: { lte: dateTo } });
|
||||
const dateTo = localDayEndExclusive(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lt: dateTo } });
|
||||
issueWhere.push({ created_at: { lt: dateTo } });
|
||||
}
|
||||
|
||||
const [receipts, issues] = await Promise.all([
|
||||
|
||||
@@ -19,6 +19,20 @@ export const AiHistoryAppendSchema = z.object({
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
// Structured per-message metadata (assistant tool trace) → content_json.
|
||||
meta: z
|
||||
.object({
|
||||
tools: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().max(100),
|
||||
label: z.string().max(100),
|
||||
ok: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.max(30),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.min(1, "Žádné zprávy")
|
||||
|
||||
@@ -27,7 +27,10 @@ export const TotpBackupSchema = z.object({
|
||||
});
|
||||
|
||||
export const TotpEnableSchema = z.object({
|
||||
secret: z.string().min(1).max(255),
|
||||
// The secret is stored AES-256-GCM encrypted (base64 of IV+ciphertext+tag)
|
||||
// in a VarChar(255) column — plaintext over ~164 chars overflows AFTER
|
||||
// encryption. Real TOTP secrets are 16-32 chars; 64 is generous.
|
||||
secret: z.string().min(1).max(64),
|
||||
code: z.string().min(1).max(64),
|
||||
password: z.string().max(200).optional(),
|
||||
current_code: z.string().max(64).optional(),
|
||||
|
||||
@@ -110,6 +110,26 @@ export const isoDateString = z.preprocess(
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nullable/optional variant of isoDateString for edit-form date fields:
|
||||
* "" and null normalize to null (the forms clear date inputs with empty
|
||||
* strings), undefined stays undefined (update semantics: field untouched).
|
||||
* Same lenient trailing-time stripping — a local-midnight datetime written
|
||||
* to a @db.Date column would land a day early (Prisma truncates to the UTC
|
||||
* date part).
|
||||
*/
|
||||
export const nullableIsoDateString = z.preprocess(
|
||||
(v) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === "") return null;
|
||||
return typeof v === "string" ? v.slice(0, 10) : v;
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD")
|
||||
.nullish(),
|
||||
);
|
||||
|
||||
/** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */
|
||||
export const timeString = z.preprocess(
|
||||
(v) =>
|
||||
|
||||
@@ -4,10 +4,10 @@ export const CreateCustomerSchema = z.object({
|
||||
name: z.string().min(1, "Název zákazníka je povinný").max(255),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(255).nullish(),
|
||||
country: z.string().max(255).nullish(),
|
||||
company_id: z.string().max(255).nullish(),
|
||||
vat_id: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
country: z.string().max(100).nullish(),
|
||||
company_id: z.string().max(50).nullish(),
|
||||
vat_id: z.string().max(50).nullish(),
|
||||
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||
customer_field_order: z.array(z.string()).optional(),
|
||||
});
|
||||
@@ -16,10 +16,10 @@ export const UpdateCustomerSchema = z.object({
|
||||
name: z.string().min(1, "Název zákazníka je povinný").max(255).optional(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(255).nullish(),
|
||||
country: z.string().max(255).nullish(),
|
||||
company_id: z.string().max(255).nullish(),
|
||||
vat_id: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
country: z.string().max(100).nullish(),
|
||||
company_id: z.string().max(50).nullish(),
|
||||
vat_id: z.string().max(50).nullish(),
|
||||
custom_fields: z.array(z.unknown()).max(100).optional(),
|
||||
customer_field_order: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -6,39 +6,40 @@ import {
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
nullableIsoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
const InvoiceItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
|
||||
export const CreateInvoiceSchema = z.object({
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
invoice_number: z.string().max(50).nullish(),
|
||||
order_id: nullableIntIdFromForm.nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
status: z.string().max(20).optional().default("draft"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
currency: z.string().max(10).optional().default("CZK"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||
apply_vat: booleanFromForm.optional().default(true),
|
||||
payment_method: z.string().max(255).nullish(),
|
||||
constant_symbol: z.string().max(255).nullish(),
|
||||
payment_method: z.string().max(50).nullish(),
|
||||
constant_symbol: z.string().max(20).nullish(),
|
||||
bank_name: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(255).nullish(),
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
issue_date: z.string().max(255).nullish(),
|
||||
due_date: z.string().max(255).nullish(),
|
||||
tax_date: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(20).nullish(),
|
||||
bank_iban: z.string().max(50).nullish(),
|
||||
bank_account: z.string().max(50).nullish(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
tax_date: nullableIsoDateString,
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
billing_text: z.string().max(500).nullish(),
|
||||
language: z.string().max(5).optional().default("cs"),
|
||||
// `notes` was dropped (printed-notes block removed — free-form PDF content
|
||||
// lives in the sections); legacy clients still sending it are silently
|
||||
// stripped by z.object. internal_notes is never printed.
|
||||
@@ -51,24 +52,24 @@ export const CreateInvoiceSchema = z.object({
|
||||
|
||||
export const UpdateInvoiceSchema = z.object({
|
||||
status: z.string().max(20).optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
payment_method: z.string().max(255).nullish(),
|
||||
constant_symbol: z.string().max(255).nullish(),
|
||||
currency: z.string().max(10).optional(),
|
||||
language: z.string().max(5).optional(),
|
||||
payment_method: z.string().max(50).nullish(),
|
||||
constant_symbol: z.string().max(20).nullish(),
|
||||
bank_name: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(255).nullish(),
|
||||
bank_iban: z.string().max(255).nullish(),
|
||||
bank_account: z.string().max(255).nullish(),
|
||||
bank_swift: z.string().max(20).nullish(),
|
||||
bank_iban: z.string().max(50).nullish(),
|
||||
bank_account: z.string().max(50).nullish(),
|
||||
issued_by: z.string().max(255).nullish(),
|
||||
billing_text: z.string().max(8000).nullish(),
|
||||
billing_text: z.string().max(500).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
apply_vat: booleanFromForm.optional(),
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
tax_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
tax_date: nullableIsoDateString,
|
||||
internal_notes: z.string().max(8000).nullish(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
paid_date: nullableIsoDateString,
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
} from "./common";
|
||||
|
||||
const OrderItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
@@ -27,20 +27,20 @@ const OrderSectionSchema = z.object({
|
||||
|
||||
export const CreateOrderFromQuotationSchema = z.object({
|
||||
quotationId: intIdFromForm,
|
||||
customerOrderNumber: z.string().max(255).optional().default(""),
|
||||
customerOrderNumber: z.string().max(100).optional().default(""),
|
||||
});
|
||||
|
||||
export const CreateOrderSchema = z.object({
|
||||
order_number: z.string().max(255).nullish(),
|
||||
customer_order_number: z.string().max(255).nullish(),
|
||||
order_number: z.string().max(50).nullish(),
|
||||
customer_order_number: z.string().max(100).nullish(),
|
||||
quotation_id: nullableIntIdFromForm.nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
status: z
|
||||
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
|
||||
.optional()
|
||||
.default("prijata"),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
currency: z.string().max(10).optional().default("CZK"),
|
||||
language: z.string().max(5).optional().default("cs"),
|
||||
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
@@ -51,10 +51,10 @@ export const CreateOrderSchema = z.object({
|
||||
});
|
||||
|
||||
export const UpdateOrderSchema = z.object({
|
||||
customer_order_number: z.string().max(255).nullish(),
|
||||
customer_order_number: z.string().max(100).nullish(),
|
||||
status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
currency: z.string().max(10).optional(),
|
||||
language: z.string().max(5).optional(),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
|
||||
@@ -27,6 +27,14 @@ export const CreateProjectNoteSchema = z.object({
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const UpdateProjectNoteSchema = z.strictObject({
|
||||
content: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Poznámka nesmí být prázdná")
|
||||
.max(5000, "Poznámka může mít maximálně 5000 znaků"),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { numberInRange, nonNegativeNumberFromForm } from "./common";
|
||||
import {
|
||||
numberInRange,
|
||||
nonNegativeNumberFromForm,
|
||||
nullableIsoDateString,
|
||||
} from "./common";
|
||||
|
||||
export const CreateReceivedInvoiceSchema = z.object({
|
||||
supplier_name: z.string().min(1, "Název dodavatele je povinný").max(255),
|
||||
month: numberInRange(1, 12),
|
||||
year: numberInRange(2000, 2100),
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
description: z.string().max(8000).nullish(),
|
||||
invoice_number: z.string().max(100).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
amount: nonNegativeNumberFromForm.optional().default(0),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
currency: z.string().max(3).optional().default("CZK"),
|
||||
vat_rate: numberInRange(0, 100).optional().default(21),
|
||||
vat_amount: nonNegativeNumberFromForm.optional().default(0),
|
||||
issue_date: z.string().max(255).nullish(),
|
||||
due_date: z.string().max(255).nullish(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
status: z.enum(["unpaid", "paid"]).optional().default("unpaid"),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
});
|
||||
|
||||
export const UpdateReceivedInvoiceSchema = z.object({
|
||||
supplier_name: z.string().max(255).optional(),
|
||||
invoice_number: z.string().max(255).nullish(),
|
||||
description: z.string().max(8000).nullish(),
|
||||
invoice_number: z.string().max(100).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
amount: nonNegativeNumberFromForm.optional(),
|
||||
currency: z.string().max(20).optional(),
|
||||
currency: z.string().max(3).optional(),
|
||||
vat_rate: numberInRange(0, 100).optional(),
|
||||
vat_amount: nonNegativeNumberFromForm.optional(),
|
||||
issue_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
due_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
paid_date: z.union([z.string().max(255), z.null()]).optional(),
|
||||
issue_date: nullableIsoDateString,
|
||||
due_date: nullableIsoDateString,
|
||||
paid_date: nullableIsoDateString,
|
||||
status: z.enum(["unpaid", "paid"]).optional(),
|
||||
notes: z.string().max(8000).nullish(),
|
||||
month: numberInRange(1, 12).optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
intIdFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
nonNegativeIntFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
} from "./common";
|
||||
@@ -12,10 +12,12 @@ export const CreateTripSchema = z.object({
|
||||
// when the client doesn't provide it (see POST /trips handler)
|
||||
user_id: intIdFromForm.optional(),
|
||||
trip_date: isoDateString,
|
||||
start_km: nonNegativeNumberFromForm,
|
||||
end_km: nonNegativeNumberFromForm,
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(255),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(255),
|
||||
// Int columns — a decimal odometer reading would 500 at Prisma (or
|
||||
// truncate, corrupting the derived distance and vehicles.actual_km).
|
||||
start_km: nonNegativeIntFromForm,
|
||||
end_km: nonNegativeIntFromForm,
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(100),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(100),
|
||||
is_business: booleanFromForm.optional().default(false),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
});
|
||||
@@ -24,10 +26,10 @@ export const UpdateTripSchema = z.object({
|
||||
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
|
||||
vehicle_id: intIdFromForm.optional(),
|
||||
trip_date: isoDateString.optional(),
|
||||
start_km: nonNegativeNumberFromForm.optional(),
|
||||
end_km: nonNegativeNumberFromForm.optional(),
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(255).optional(),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(255).optional(),
|
||||
start_km: nonNegativeIntFromForm.optional(),
|
||||
end_km: nonNegativeIntFromForm.optional(),
|
||||
route_from: z.string().min(1, "Vyplňte odkud").max(100).optional(),
|
||||
route_to: z.string().min(1, "Vyplňte kam").max(100).optional(),
|
||||
is_business: booleanFromForm.optional(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { nonNegativeNumberFromForm } from "./common";
|
||||
import { nonNegativeIntFromForm } from "./common";
|
||||
|
||||
export const CreateVehicleSchema = z.object({
|
||||
spz: z.string().min(1, "SPZ je povinná").max(50),
|
||||
name: z.string().min(1, "Název je povinný").max(255),
|
||||
brand: z.string().nullish(),
|
||||
model: z.string().nullish(),
|
||||
initial_km: nonNegativeNumberFromForm.optional().default(0),
|
||||
actual_km: nonNegativeNumberFromForm.optional().default(0),
|
||||
// Int columns — decimals would 500 at Prisma or silently truncate.
|
||||
initial_km: nonNegativeIntFromForm.optional().default(0),
|
||||
actual_km: nonNegativeIntFromForm.optional().default(0),
|
||||
is_active: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
.optional()
|
||||
@@ -19,8 +20,8 @@ export const UpdateVehicleSchema = z.object({
|
||||
name: z.string().max(255).optional(),
|
||||
brand: z.string().nullish(),
|
||||
model: z.string().nullish(),
|
||||
initial_km: nonNegativeNumberFromForm.optional(),
|
||||
actual_km: nonNegativeNumberFromForm.optional(),
|
||||
initial_km: nonNegativeIntFromForm.optional(),
|
||||
actual_km: nonNegativeIntFromForm.optional(),
|
||||
is_active: z
|
||||
.preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
|
||||
.optional(),
|
||||
|
||||
@@ -31,8 +31,8 @@ export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
|
||||
// stripped by z.object). Limits are DB-aligned.
|
||||
export const CreateSupplierSchema = z.object({
|
||||
name: z.string().min(1, "Název je povinný").max(255),
|
||||
ico: z.string().max(255).nullish(),
|
||||
dic: z.string().max(255).nullish(),
|
||||
ico: z.string().max(20).nullish(),
|
||||
dic: z.string().max(20).nullish(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
@@ -42,8 +42,8 @@ export const CreateSupplierSchema = z.object({
|
||||
});
|
||||
export const UpdateSupplierSchema = z.object({
|
||||
name: z.string().min(1, "Název je povinný").max(255).optional(),
|
||||
ico: z.string().max(255).nullish(),
|
||||
dic: z.string().max(255).nullish(),
|
||||
ico: z.string().max(20).nullish(),
|
||||
dic: z.string().max(20).nullish(),
|
||||
street: z.string().max(255).nullish(),
|
||||
city: z.string().max(255).nullish(),
|
||||
postal_code: z.string().max(20).nullish(),
|
||||
|
||||
@@ -206,6 +206,11 @@ async function start() {
|
||||
root: path.join(__dirname, "..", "dist-client"),
|
||||
prefix: "/",
|
||||
wildcard: false,
|
||||
// The plugin's own Cache-Control management must be OFF or it
|
||||
// overwrites the setHeaders values below with "public, max-age=0"
|
||||
// (README: "To provide a custom Cache-Control header, set this option
|
||||
// to false"). ETags stay on, so no-cache still revalidates cheaply.
|
||||
cacheControl: false,
|
||||
// Deploy-skew caching contract (per the Vite deploy guide):
|
||||
// - /assets/* names are content-hashed → safe to cache forever
|
||||
// (a changed file always gets a NEW name, so "immutable" is correct).
|
||||
|
||||
2082
src/services/ai-tools.ts
Normal file
2082
src/services/ai-tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user