Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d859b5bbf7 | ||
|
|
5683912b76 | ||
|
|
3ec512faf1 | ||
|
|
9f9f359acb | ||
|
|
593dfc356d | ||
|
|
0172c9a442 | ||
|
|
81b4cb51e7 | ||
|
|
a274a49e90 | ||
|
|
9c192e79e7 | ||
|
|
4d0ec53514 | ||
|
|
87e644eef8 | ||
|
|
6428044624 | ||
|
|
67ddfb2d5d | ||
|
|
81293ae543 | ||
|
|
237ebf3ef8 | ||
|
|
983a1408f1 | ||
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -21,5 +21,14 @@ dist-client/
|
|||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local Claude tooling (personal, not shared)
|
||||||
|
.claude/commands/
|
||||||
|
.claude/skills/
|
||||||
|
.claude/workflows/
|
||||||
|
CLAUDE-FABLE-5.md
|
||||||
|
|
||||||
|
# Personal scratch (planning docs, etc.)
|
||||||
|
simon/
|
||||||
|
|
||||||
# Superpowers brainstorm mockups
|
# Superpowers brainstorm mockups
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|||||||
407
POSSIBLE_IMPROVEMENTS.md
Normal file
407
POSSIBLE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# Possible Improvements — UX & Consistency Review
|
||||||
|
|
||||||
|
_A prioritized UX review of boha-app, grounded in 104 verified, code-referenced findings._
|
||||||
|
|
||||||
|
> **How this was produced (2026-06-24):** a multi-agent audit fanned out 16 independent
|
||||||
|
> reviewers across 5 feature domains and 11 cross-cutting UX dimensions (155 raw findings),
|
||||||
|
> deduplicated them into 118 unique items, then **adversarially re-checked every finding
|
||||||
|
> against the actual code** — dropping 14 that didn't hold up or contradicted a deliberate
|
||||||
|
> documented decision (CLAUDE.md). What remains are 104 findings each confirmed in the
|
||||||
|
> source, with file references kept intact so they can be acted on directly. Read §1–§3 for
|
||||||
|
> the opinion and the plan; §4 is the full catalogue by theme; §5 is suggested sequencing.
|
||||||
|
>
|
||||||
|
> **Independently re-verified (2026-06-24):** a second adversarial pass (9 parallel
|
||||||
|
> verifiers, different model) re-checked all 104 findings against the source:
|
||||||
|
> **100 confirmed, 4 partial, 0 refuted.** The partials were factual nits (a couple of
|
||||||
|
> miscounts, one wrong fix target, one mis-cited import), corrected inline in this
|
||||||
|
> document. The pass also surfaced one new backend finding (trips create authz — see §4.9)
|
||||||
|
> and upgraded confidence on the AttendanceAdmin and warehouse-models findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
boha-app is a **mature, broad, and genuinely well-built** back-office system. The bones are excellent: there is a real component kit (`src/admin/ui/`), shared document modules, a single-source-of-truth status module (`documentStatus.ts`), a global React Query cache with disciplined invalidation conventions, timezone-safe date handling, edit-locking on documents, and an accessibility-aware theme. Most of the "hard" platform work is done and done well. This is not a rescue job — it is a **polish-and-converge job**.
|
||||||
|
|
||||||
|
That said, the owner asked the right question. The single dominant theme across these findings is **consistency debt: the app does the same thing several different ways, and the variants disagree in ways the daily user feels.** Almost every cluster below is a story of "module A got it right, module B is an older or forked copy that drifted." The codebase even documents this pattern against itself — `documentStatus.ts` exists _because_ per-page status maps drifted; `useUnsavedChangesGuard` exists _because_ the dirty-check was copy-pasted; CLAUDE.md explicitly says "extend, never fork." The findings show those rules are followed in the newest modules (offers, issued orders) and quietly violated in the older twins (invoices, orders, attendance-admin, projects, warehouse).
|
||||||
|
|
||||||
|
Why this matters: the user's mental model breaks at the seams. Marking an invoice paid from the **detail** page leaves a project's order-status label stale, but doing it from the **list** page refreshes it. The "create" button **navigates to a page** on one Orders tab and **opens a modal** on the other. A failed list fetch shows "no records yet" — which can convince a user a customer has zero invoices when the server actually errored. Validation errors appear **inline under the field** on the supplier modal but **as a 4-second toast** on its mirror-image customer modal. None of these is catastrophic alone; collectively they make a strong app feel "not quite finished" and erode trust in what's on screen.
|
||||||
|
|
||||||
|
**The five highest-leverage moves, in order:**
|
||||||
|
|
||||||
|
1. **Stop showing stale data after writes.** Define one canonical React Query invalidation set per document family and apply it to _both_ list and detail mutations. Today detail-page edits invalidate fewer domains than their list twins (`OrderDetail`, `InvoiceDetail`, `DashProfile`, leave-cancel). Small effort, removes a whole class of "why is this number wrong" confusion. _(Cluster: data-freshness)_
|
||||||
|
|
||||||
|
2. **Guard the irreversible actions that currently aren't.** Marking an invoice **"Zaplaceno"** is a single unconfirmed chip click and is _terminal_ — the one truly irreversible document transition with **no** confirm, while every reversible status change has one. Same inversion in the warehouse: **"Potvrdit"** (posts stock) has no confirm but its **undo** does. And the **Aktivní/Neaktivní** chips are secret one-click toggles that fail silently on error. _(Cluster: destructive actions)_
|
||||||
|
|
||||||
|
3. **Fix list-page failure and loading states.** No list page reads `isError`, and there is no global `QueryCache.onError`, so failed fetches fall through to the **empty state** — actively misleading. Add a global error toast plus a distinct inline error. _(Cluster: loading/empty/error)_
|
||||||
|
|
||||||
|
4. **Make the app usable on a phone.** The shared `Tabs` uses MUI's non-scrolling `variant="standard"`, so the 5 status-filter tabs on Offers/Invoices simply **clip off-screen and become unreachable** at ~360px — a one-prop fix that touches every status filter. Modals never go full-screen on phones. _(Cluster: mobile)_
|
||||||
|
|
||||||
|
5. **Converge the shared primitives the app already owns.** Route the forked dirty-guards, status maps, empty states, page headers, save buttons, and PDF openers back through their canonical implementations. This is the structural fix that prevents the other four from re-drifting. _(Cluster: forked modules)_
|
||||||
|
|
||||||
|
The rest of this report groups all findings by theme, gives a prioritized action table, and then lists every finding with its file references intact so they can be acted on directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Top Themes
|
||||||
|
|
||||||
|
### Theme A — Data-freshness drift (the trust killer)
|
||||||
|
|
||||||
|
React Query invalidation is the app's nervous system, and CLAUDE.md lays out clear rules ("invalidate every domain that embeds the data"). The newest modules obey; older and raw-`apiFetch` paths don't. **List pages and their own detail pages invalidate different domain sets** because list pages use manual `invalidate` arrays while detail pages use `useApiMutation` arrays, and the two drifted independently. The result is subtle: a value that is correct after editing it one way is stale after editing it the identical other way. Converge on **one named invalidation constant per family** and reuse it on both surfaces. The dashboard already solved the "many domains feed me" case with `refetchOnMount: "always"` — apply the same backstop to the audit log.
|
||||||
|
|
||||||
|
### Theme B — Destructive & risky actions are gated by consequence-inverted logic
|
||||||
|
|
||||||
|
The pattern across the app is sound — `ConfirmDialog` everywhere — but the gating is **inversely correlated with actual consequence** in the few places it matters most. The _irreversible_ transitions (invoice→paid, warehouse "Potvrdit" posts stock) have **no** guard, while their _reversible_ counterparts (cancel document) get a full danger confirm. Several state changes hide as **chip toggles with no affordance** and **no `onError`**, so a rejected toggle silently reverts with zero feedback. And the "wipe the entire audit log" case uses the same low-key modal as "trim 30-day rows." Converge on: irreversibility → stronger friction, never weaker; every mutating control announces itself and reports its errors.
|
||||||
|
|
||||||
|
### Theme C — Forms & save UX speak three dialects
|
||||||
|
|
||||||
|
Validation is **inline-under-field** in some forms and **transient toast** in others — and _mirrored screens disagree_ (supplier modal inline vs customer modal toast; trip editor inline vs admin-trip editor toast; received-invoice bulk-review inline vs its single-edit toast). Enter submits page forms but **never** submits a shared-`Modal` form. Save buttons **spin** in document pages but only **swap text** elsewhere, and dual-save warehouse forms can't tell you which action is running. Failed validation never **moves focus** to the bad field. Converge on: inline `<Field error>` for validation, toasts only for transport/server failures, `<form onSubmit>` in the shared Modal, one save affordance, and focus-the-first-error.
|
||||||
|
|
||||||
|
### Theme D — Unsaved-work protection is partial and fork-ridden
|
||||||
|
|
||||||
|
The shared `useUnsavedChangesGuard` is used by exactly two pages; `OrderDetail` and `InvoiceDetail` **re-implement it inline**, and `ProjectDetail`, `CompanySettings`, all three warehouse forms, and `AttendanceCreate` have **no guard at all** — a refresh discards a 15-line receipt silently. Worse, _every_ guard only catches `beforeunload`, so clicking the in-app **"Zpět"** or a sidebar link — the most common way to leave — discards edits with no prompt (this one needs a router upgrade). And `InvoiceDetail` has **no edit-lock** while its siblings do, so two users can clobber each other's invoice line items.
|
||||||
|
|
||||||
|
### Theme E — Loading, empty & error states have no single grammar
|
||||||
|
|
||||||
|
Three different loading behaviors for the _same_ "change the month" action (dim / nothing / flash). Empty states built two ways (`<EmptyState>` vs hand-rolled `Box+Typography`). 40 pages **blank the whole page to a centered spinner** (layout shift) instead of keeping chrome; there are **zero** real MUI `<Skeleton>`s despite identifiers literally named `showListSkeleton`. The dashboard hand-rolls its own spinner. And the big one: **failed fetches render as empty**, not as errors.
|
||||||
|
|
||||||
|
### Theme F — Navigation & information architecture has thin orientation
|
||||||
|
|
||||||
|
Every browser tab reads **"Admin"** (no per-page title), there are no breadcrumbs, the back control is built **four different ways**, and `Received-order` detail sends you back to the **wrong Orders tab**. Master data is scattered across four nav sections; "Zákazníci" lives at the misleading URL `/offers/customers`; `settings.templates` opens the gear then **bounces you to the dashboard**; and there are three different "access denied" experiences. None blocks work, but together they make deep pages feel un-anchored.
|
||||||
|
|
||||||
|
### Theme G — Mobile is a second-class citizen in specific, fixable spots
|
||||||
|
|
||||||
|
Status-filter tabs **clip and become unreachable** on phones. Modals never go full-screen. Long full-page editors keep **Save only at the top**, so phone users scroll all the way back up to save. Warehouse line-items lose the labeled card-per-row layout the document editor has — worst exactly where shop-floor mobile use is likely. `FilterBar` children mix fixed and growing flex bases, leaving dead space.
|
||||||
|
|
||||||
|
### Theme H — Microcopy & localization inconsistency
|
||||||
|
|
||||||
|
The goods-receipt module is named **four ways** (Příjmy / Nový příjem / Příjmové doklady / Příjemka) and "Příjmy" also means _financial income_. "Cancelled" is spelled two ways in the same status file. Create verbs split three ways. The busy label has five competing forms plus a `"Chyba pripojeni"` diacritics typo. Autocompletes leak English **"No options"** in an otherwise fully-Czech app. Several prices bypass cs-CZ formatting (period decimals, no currency).
|
||||||
|
|
||||||
|
### Theme I — Accessibility gaps in the hot paths
|
||||||
|
|
||||||
|
Clickable table rows aren't keyboard-operable (warehouse records literally can't be opened by keyboard). Search boxes are placeholder-only with no accessible name across ~13 pages. The desktop line-item grid is unlabeled while the mobile card is fully labeled. No skip-to-content link, so keyboard users tab the whole sidebar on every navigation.
|
||||||
|
|
||||||
|
### Theme J — Bundle & performance
|
||||||
|
|
||||||
|
MUI + Emotion sit in the **781 kB entry chunk**, so every patch release busts the cached UI vendor code and daily users re-download ~232 kB gzip of unchanged MUI. The Dashboard and its 7 subcomponents are bundled into the entry chunk and downloaded **on the Login screen**. No route-transition progress bar, no link prefetch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prioritized Recommendations
|
||||||
|
|
||||||
|
Effort: **S** = hours · **M** = a focused day or two · **L** = multi-day / structural.
|
||||||
|
|
||||||
|
### Quick wins — high impact, low effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 1 | Make status-filter `Tabs` scrollable so they stop clipping off-screen on phones | `src/admin/ui/Tabs.tsx:28-49` (one prop fixes all) | High | S |
|
||||||
|
| 2 | Add a confirm (or undo toast) + hover affordance to the **"mark invoice paid"** chip — the only unguarded irreversible transition | `Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680` | High | S |
|
||||||
|
| 3 | Converge detail-page invalidation sets onto the list-page (full) sets | `OrderDetail.tsx:162,168,177`; `InvoiceDetail.tsx:1054,1067,1073`; `DashProfile.tsx:225-226`; `LeaveRequests.tsx:90` | Medium | S |
|
||||||
|
| 4 | Gate the Invoices **list** PDF icon on `invoice_number` so drafts stop dead-ending in a 404 | `Invoices.tsx:616-626` | Medium | S |
|
||||||
|
| 5 | Point `Received-order` detail Zpět / redirects at `/orders?tab=prijate` | `OrderDetail.tsx:116-121,275-287,397-405` | Medium | S |
|
||||||
|
| 6 | Add a global `QueryCache.onError` toast so failed fetches stop masquerading as empty | `src/admin/lib/queryClient.ts` | High | S |
|
||||||
|
| 7 | Debounce search on Invoices / ReceivedInvoices / AuditLog (reuse `useDebounce`) | `Invoices.tsx:282`, `ReceivedInvoices.tsx:250`, `AuditLog.tsx:202` | Medium | S |
|
||||||
|
| 8 | Add global MUI `csCZ` `localeText` (kills English "No options" everywhere) | `src/admin/ui/MuiProvider.tsx:15`; pickers `CustomerPicker.tsx:49`, `SupplierPicker.tsx:49` | Medium | S |
|
||||||
|
| 9 | Add `onError` toast to Vehicles/Users active-toggle mutations (silent failure today) | `Vehicles.tsx:146-160`, `Users.tsx:162-171` | Medium | S |
|
||||||
|
| 10 | Add a per-page `document.title` (`<TitleSync>`) so tabs/bookmarks aren't all "Admin" | `index.html:22`, `AppShell`, `navData.tsx` | Medium | S |
|
||||||
|
| 11 | Use `PROJECT_STATUS`/`ORDER_STATUS` maps for project + linked-order chips (currently wrong colors / raw DB tokens) | `Projects.tsx:50`, `ProjectDetail.tsx:43,577`, `documentStatus.ts` | Medium | S |
|
||||||
|
| 12 | Fix `"Chyba pripojeni"` diacritics typo; unify cancelled-label spelling | `Dashboard.tsx:127`, `AuthContext.tsx:256,314`; `documentStatus.ts:49,58` | Low | S |
|
||||||
|
| 13 | Add a skip-to-content link + `id="main-content"` on `<main>` | `AppShell.tsx:130-145,210-223` | Medium | S |
|
||||||
|
| 14 | Lazy-load Dashboard so its 7 subcomponents leave the Login critical path | `AdminApp.tsx:14-15`, `Dashboard.tsx:16` | Medium | S |
|
||||||
|
| 15 | Relabel the two Orders-tab "Vytvořit objednávku" buttons (identical for two doc types) | `Orders.tsx:125,129` | Medium | S |
|
||||||
|
|
||||||
|
### High-impact — worth a focused effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 16 | Add unsaved-changes guards to the heavy editors that have none; de-fork `OrderDetail`/`InvoiceDetail` onto the shared hook | `ProjectDetail`, `CompanySettings`, `Warehouse*Form`, `AttendanceCreate`; `useUnsavedChangesGuard.ts` | High | M |
|
||||||
|
| 17 | Render an inline error (distinct from empty) when `isError`, with retry | `usePaginatedQuery.ts:47`, all list pages | High | M |
|
||||||
|
| 18 | Standardize validation on inline `<Field error>`; fix the mirrored screens that disagree | `WarehouseSuppliers` vs `OffersCustomers`; `Trips` vs `TripsAdmin`; `ReceivedInvoices` bulk vs single | Medium | M |
|
||||||
|
| 19 | Wrap shared `Modal` in `<form onSubmit>` so Enter submits every dialog | `src/admin/ui/Modal.tsx:84,92` | Medium | M |
|
||||||
|
| 20 | Route the three forked invoice PDF openers through `useDocumentPdf`/`useDocumentListPdf` | `Invoices.tsx:361`, `InvoiceDetail.tsx:1175`, `ReceivedInvoices.tsx:561` | Medium | M |
|
||||||
|
| 21 | Replace inline English/technical error strings with `apiErrorMessage(...)` across ~32 pages | `mutations.ts:30-36`; per-page catch blocks | Medium | M |
|
||||||
|
| 22 | Make `Modal` full-screen on phones; fix leave-modal hardcoded 2-col date grid | `Modal.tsx:67-75`, `Attendance.tsx:1195-1199` | Medium | S |
|
||||||
|
| 23 | Give warehouse line-items the labeled card-per-row mobile layout the document editor has | `WarehouseIssueForm.tsx:513-567` vs `DocumentItemsEditor.tsx:170-342` | Medium | M |
|
||||||
|
| 24 | Add keyboard semantics to `DataTable` clickable rows (role/tabindex/Enter-Space) | `DataTable.tsx:285-303,137-161` | Medium | M |
|
||||||
|
| 25 | Add a "Nová žádost" create action to "Moje žádosti" (lift the leave modal out of Attendance) | `LeaveRequests.tsx:241`, `Attendance.tsx:936,978` | Medium | M |
|
||||||
|
| 26 | Route hand-rolled headers through `PageHeader`/`headerActionsSx` (~20 pages) so mobile buttons stack | `PageHeader.tsx:19`; `Projects`, `Users`, `Vehicles`, `TripsAdmin`, `AttendanceAdmin`, `Settings`, `CompanySettings` | Medium | M |
|
||||||
|
| 27 | Confirm before warehouse **"Potvrdit"** / **"Uložit a potvrdit"** (posts stock irreversibly) | `WarehouseReceiptDetail.tsx:354-356`, `WarehouseReceiptForm.tsx:445` | Medium | S |
|
||||||
|
| 28 | Split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk | `vite.config.ts` | Medium | M |
|
||||||
|
| 29 | Give list-page search inputs an accessible name + `type="search"` (shared `SearchField`) | `Offers.tsx:787`, `Invoices.tsx:741`, +11 pages | Medium | S |
|
||||||
|
|
||||||
|
### Strategic — larger bets
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 30 | Add edit-locking (migration + route trio) to `InvoiceDetail` (and order notes) to stop multi-user clobbering | `invoices.ts`, `orders.ts`, schema; wire `useDocumentLock`/`LockBanner` | High | L |
|
||||||
|
| 31 | Migrate `InvoiceDetail` off its fork onto shared `DocumentItemsEditor`/`useDocumentPdf`/dirty-guard (add `showVat` flag) | `InvoiceDetail.tsx:241-541`, `DocumentItemsEditor.tsx` | Medium | L |
|
||||||
|
| 32 | Block dirty in-app navigation (requires `createBrowserRouter`/`RouterProvider` migration, then centralize in the guard) | `main.tsx`, `useUnsavedChangesGuard.ts` | Medium | L |
|
||||||
|
| 33 | Move `AttendanceAdmin` reads off raw `apiFetch` onto React Query (kills the `setTimeout(300)` refetch dance) | `useAttendanceAdmin.ts:823,871,1057` | Medium | L |
|
||||||
|
| 34 | Real skeleton/page-shell first paint instead of full-page spinner on 40 pages | `LoadingState.tsx`, list pages; `skeleton-boha` skill exists | Medium | L |
|
||||||
|
| 35 | Route-transition top progress bar + sidebar link prefetch | `AppShell.tsx:222`, `AdminApp.tsx` | Medium | M |
|
||||||
|
| 36 | Consolidate duplicated status/label maps into `documentStatus.ts` (warehouse/projects/leave) | `documentStatus.ts`; 6 warehouse files, `Projects`, `Leave*` | Medium | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detailed Findings by Area
|
||||||
|
|
||||||
|
### 4.1 Data-freshness — stale data after mutations
|
||||||
|
|
||||||
|
- **Detail-page mutations invalidate fewer domains than their list twins.** `OrderDetail` status/notes/delete (`OrderDetail.tsx:162,168,177`) invalidate only `[orders,invoices]` while the `ReceivedOrders` list delete (`ReceivedOrders.tsx:175`) uses `[orders,offers,projects,invoices]`. `InvoiceDetail` save/status/delete (`InvoiceDetail.tsx:1054,1067,1073`) use `[invoices,orders]` while the `Invoices` list adds `projects` (`Invoices.tsx:328,352`). **Why it matters:** marking an invoice paid or changing an order status from the _detail_ page leaves `ProjectDetail`'s `order_status` label (`ProjectDetail.tsx:571`) and the source offer stale until staleTime/refocus. **Fix:** define one canonical set per family (e.g. `[offers,orders,issued-orders,projects,invoices]`) and apply to both surfaces; `OfferDetail`/`Offers` are the consistent reference to copy.
|
||||||
|
|
||||||
|
- **Self-profile edit refreshes fewer domains than an admin editing the same user.** `DashProfile` (`DashProfile.tsx:225-226`) invalidates only `[dashboard,users]`, but admin Users edit uses `USER_INVALIDATE=[users,trips,attendance,leave-requests,leave,projects]` (`Users.tsx:117-124`). **Why:** your display name is embedded in trips/attendance/leave/projects (none keyed under `[users]`), so renaming yourself leaves it stale there. **Fix:** export `USER_INVALIDATE` from a shared module and reuse it in `DashProfile`.
|
||||||
|
|
||||||
|
- **Cancelling a leave request omits the `[users]` balance invalidation that create/approve include.** Cancel (`LeaveRequests.tsx:90`) invalidates `[leave-requests,leave,attendance]`; create (`Attendance.tsx:305`) and approve/reject (`LeaveApproval.tsx:164,177`) add `users,dashboard`. **Why:** the genuinely-stale gap is `[users]` balance/history when an approved future request is cancelled (the `[dashboard]` part self-heals via `refetchOnMount`). **Fix:** hoist a shared `LEAVE_INVALIDATE` set across all three flows.
|
||||||
|
|
||||||
|
- **Audit Log is never invalidated by the mutations that write audit rows.** `logAudit()` runs on every mutation, but `[audit-log]` is invalidated only by plan flows (`usePlanWork.ts:167`) and the page's own cleanup (`AuditLog.tsx:191`). **Why:** recent actions don't appear within the 30s default staleTime. **Fix:** add `refetchOnMount:'always'` (and `staleTime:0`) to the AuditLog page's inline `useQuery` (`AuditLog.tsx:135`), mirroring `dashboardOptions` — don't sprinkle `[audit-log]` into dozens of arrays. _(Verification note: `auditLogOptions` in `auditLog.ts` is dead code — nothing imports it; either delete it or wire the page onto it and put the option there.)_
|
||||||
|
|
||||||
|
- **Odin invoice import invalidates only `[invoices]`, not `[suppliers]`/`[company-settings]`.** `OdinChat.tsx:362` vs manual `[invoices,suppliers,company-settings]` (`ReceivedInvoices.tsx:331,341`). **Why:** an Odin import that introduces a new vendor name won't appear in the `[suppliers]`-keyed autocomplete until staleTime expires. **Fix:** use the same set on both paths (they POST to the same endpoint).
|
||||||
|
|
||||||
|
- **Received-invoice supplier picker is disjoint from the warehouse/issued-order supplier registry.** Received invoices use a free-text `string[]` under `[suppliers]` → `/received-invoices/suppliers` (`common.ts:22-28`); issued orders/warehouse use the structured `sklad_suppliers` table (`issued-orders.ts:76`, `warehouse.ts:241`). **Why:** a vendor added in Warehouse→Suppliers never shows when entering a received invoice, and vice-versa — same conceptual entity, two disjoint stores. **Fix:** don't force an FK (keep one-off vendors as quick free-text), but **union** `sklad_suppliers` names into the received-invoice autocomplete source.
|
||||||
|
|
||||||
|
### 4.2 Destructive & risky actions — confirmation and undo
|
||||||
|
|
||||||
|
- **Marking an invoice "Zaplaceno" is a single unconfirmed chip click — the only irreversible transition with no guard.** In both invoice lists the chip instantly PUTs `status=paid` with no confirm and no undo (`Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680`); for issued invoices "paid" is terminal (detail goes read-only, row undeletable, `InvoiceDetail.tsx:1220-1314`). Every _detail_ page gates status changes behind a `ConfirmDialog`, but this one doesn't, and the chip only differs from a static one by a pointer cursor. **Fix:** route the chip click through a `ConfirmDialog` ("Označit fakturu … jako zaplacenou?") or an undo toast, and add a hover/tooltip affordance.
|
||||||
|
|
||||||
|
- **Aktivní/Neaktivní chips are hidden one-click toggles with no affordance, and fail silently.** On Vehicles, Users, Warehouse Locations and Suppliers the `StatusChip` is secretly an `onClick` toggle with no tooltip/label (`Vehicles.tsx:263`, `Users.tsx:288`). Deactivation fires on one click while the less-destructive delete gets a full `ConfirmDialog`. The Vehicles/Users toggle mutations declare only `onSuccess`, **no `onError`** and no try/catch (`Vehicles.tsx:146-160`, `Users.tsx:162-171`), so a rejected toggle gives zero feedback and the chip silently reverts. **Fix:** make activation an explicit labelled control (switch/menu action) with a tooltip; add a deactivation confirm for user/vehicle; add `onError` toasts. (`WarehouseLocations`/`WarehouseSuppliers` already toast via try/catch — copy them.)
|
||||||
|
|
||||||
|
- **`ConfirmDialog` in-flight `loading` is applied inconsistently — some dialogs allow duplicate submits.** `IssuedOrderDetail` passes `loading` to both status and delete dialogs (`:958`); `OrderDetail`/`InvoiceDetail` pass it to delete but not status (`OrderDetail.tsx:780-790`, `InvoiceDetail.tsx:2453-2463`); Vehicles delete (`:417-429`), `AttendanceAdmin` delete (raw `apiFetch`, no `isPending`, `useAttendanceAdmin.ts:1293-1320`) and CompanySettings bank delete omit it entirely → a second click fires a duplicate request. **Fix:** pass `loading={mutation.isPending}` to every `ConfirmDialog`; convert `AttendanceAdmin`'s raw delete to `useApiMutation`; standardize on `IssuedOrderDetail`'s keep-open "Zpracovávám…" convention.
|
||||||
|
|
||||||
|
- **Irreversible "Potvrdit" (posts stock) has no confirm, while its reversible "Zrušit doklad" undo does.** `WarehouseReceiptDetail.tsx:354-356` commits stock movements on one click; `:508-517` requires a danger confirm to reverse it. Same single-click commit in the form's "Uložit a potvrdit" (`WarehouseReceiptForm.tsx:445`). **Why:** the gating is inverted relative to consequence; a misclick commits stock. **Fix:** add a `ConfirmDialog` before "Potvrdit" (and ideally the form's commit).
|
||||||
|
|
||||||
|
- **Cancellation confirms show two near-identical "Zrušit" buttons.** When the confirmed action is itself a cancellation, both the dismiss and the confirm start with "Zrušit" (`WarehouseReceiptDetail.tsx:508-516`, `LeaveRequests.tsx:260-268`) — same leading word, opposite meanings (the buttons do differ visually: contained vs text). **Fix:** relabel the dismiss to "Zpět"/"Ne" via `cancelText` (`ConfirmDialog.tsx:31-33`).
|
||||||
|
|
||||||
|
- **Wiping the ENTIRE audit log uses the same low-key modal as trimming old rows.** "Vše" sits in the same dropdown as "30 dní"/"90 dní" and the only warning is a 0.6-opacity caption (`AuditLog.tsx:297-319,207-218`). The backend deliberately requires a special literal to wipe everything (`routes/admin/audit-log.ts:92-109`), but the UI auto-supplies it with no extra friction. **Fix:** when "Vše" is selected, switch to danger styling, show a prominent warning, ideally require typing a confirmation word.
|
||||||
|
|
||||||
|
- **Error toasts auto-dismiss after 4s identically to successes, with no history.** `AlertContext.tsx:53-70` defaults every severity to 4000ms; a long Czech validation message vanishes as fast as "Uloženo," with no toast history (`AlertContainer.tsx:20-27`). **Fix:** key the default off severity — error/warning longer or sticky-until-dismissed, success ~4s.
|
||||||
|
|
||||||
|
### 4.3 Error & message feedback fidelity
|
||||||
|
|
||||||
|
- **Raw English/technical error strings leak into Czech toasts on ~30 pages.** A helper `apiErrorMessage(err, fallback)` converts client-generated English ("Unauthorized", "Failed to fetch", "Invalid JSON response", "Request failed (NNN)") into a Czech fallback (`mutations.ts:30-36,69-84`), but only `OfferDetail`/`IssuedOrderDetail` use it. ~30 pages do `alert.error(e instanceof Error ? e.message : "Chyba připojení")` (`Vehicles.tsx:203`, `OrderDetail.tsx:191`), passing the raw English through on dropped connections / non-JSON 500s / 401s. **Fix:** replace the inline pattern with `apiErrorMessage(e, "<Czech fallback>")` across the ~32 files. **Do not** add a blanket `useApiMutation` default `onError` — pages call `mutateAsync` inside try/catch and also reset local state there, so a default handler would double-toast.
|
||||||
|
|
||||||
|
### 4.4 Unsaved-changes & data-loss protection
|
||||||
|
|
||||||
|
- **The dirty guard is absent on heavy editors and forked twice.** Shared `useUnsavedChangesGuard` is used only by `OfferDetail`/`IssuedOrderDetail`; `OrderDetail` (`:130-143`) and `InvoiceDetail` (`:942-958`) re-implement `isDirty`+`beforeunload` inline; `ProjectDetail`, `CompanySettings`, `WarehouseIssueForm/ReceiptForm/InventoryForm` and `AttendanceCreate` register **no guard at all** — a refresh discards everything typed. **Fix:** route the two forks through the shared hook and add it to the six unguarded editors.
|
||||||
|
|
||||||
|
- **Dirty guards block only tab-close, not in-app navigation.** Every guard registers only `beforeunload` (`useUnsavedChangesGuard.ts:25-33`), which doesn't fire on React-Router navigation — so the in-app "Zpět" or a sidebar link discards edits silently. **Fix (structural):** the app uses classic `<BrowserRouter>` (react-router 6.30.3) where `useBlocker`/`usePrompt` aren't available — this needs a migration to `createBrowserRouter`/`RouterProvider` (or a custom nav-intercept context), then centralize the blocker inside the shared hook.
|
||||||
|
|
||||||
|
- **Always-editable page forms offer no explicit Discard.** Modals have "Zrušit" and `WarehouseItemDetail` has an edit/cancel toggle that restores the snapshot (`:247-269`), but `ProjectDetail` (only Uložit+Smazat, `:343`) and `CompanySettings` (only Uložit, `:647`) give no way to abandon edits — you must reload (and they don't even warn). **Fix:** add a "Zrušit změny" button that restores the loaded snapshot from query cache.
|
||||||
|
|
||||||
|
- **Invoices have no edit-lock while offers and issued orders do (multi-user clobbering).** `OfferDetail`/`IssuedOrderDetail` acquire a server lock, heartbeat, render `LockBanner` and force read-only when another user holds it; `InvoiceDetail` imports none of it (`:22-88`), so two users can clobber each other's line items (last-save-wins). Same gap for received-order notes on `OrderDetail`. **Fix (structural):** the _client_ pieces exist (`useDocumentLock`+`LockBanner`) but the _server_ trio does **not** — needs a migration adding `locked_by`/`locked_at` to invoices (and orders), a lock/heartbeat/unlock route trio + `locked_by` enrichment in the detail endpoint (mirror `issued-orders.ts:138-245`), then wire the existing hook + banner.
|
||||||
|
|
||||||
|
### 4.5 Form validation & submission
|
||||||
|
|
||||||
|
- **Validation is inline in some forms, transient toasts in others — and mirrored screens disagree.** Inline `<Field error>` camp: Trips, Users, Vehicles, WarehouseSuppliers, OfferDetail. Toast camp: ProjectDetail, AttendanceCreate, CompanySettings, OffersCustomers, TripsAdmin, ReceivedInvoices-edit. The disagreements: supplier modal inline (`WarehouseSuppliers.tsx:266-270`) vs its mirror customer modal toast (`OffersCustomers.tsx:338-341`); regular trip editor inline `end_km>start_km` (`Trips.tsx:294-300`) vs admin trip editor toast (`TripsAdmin.tsx:485-490`); received-invoice bulk-review inline per-row (`:431-446`) vs single-edit toast (`:508-519`). **Fix:** standardize on inline `<Field error>`; reserve toasts for server/transport failures.
|
||||||
|
|
||||||
|
- **Enter submits page forms but never any shared-Modal form.** `Modal` renders children in `DialogContent` with an `onClick` button (default `type=button`) and no surrounding `<form>` (`Modal.tsx:84,92`), so Enter never submits a modal. `OfferDetail` even hand-rolled an `onKeyDown` Enter workaround (`:1059-1061`). **Fix:** wrap `DialogContent`+`DialogActions` in `<form onSubmit>`, make the primary action `type=submit`, mark secondary/in-content buttons `type=button`, and remove the workaround.
|
||||||
|
|
||||||
|
- **Failed validation doesn't move focus to the first invalid field nor announce to AT.** Handlers just `setErrors` and return (`Vehicles.tsx:192-197`); the `Field` error is a plain caption wired via `aria-describedby`/`aria-invalid`, not `aria-live`/`role=alert` (`Field.tsx:81-89`). In a long modal the error can be off-screen. **Fix:** after `setErrors`, focus+`scrollIntoView` the first errored field and render the inline error with `role=alert`; centralize in `Field.tsx`/`Modal.tsx`.
|
||||||
|
|
||||||
|
- **Required-asterisk marking is inconsistent with what's validated; DateField/TimeField can't show an error border.** `Field` paints the asterisk only when `required` is passed (`Field.tsx:74`), but it's applied unevenly: `WarehouseIssueForm` validates Projekt-required with no asterisk (`:474` vs `:328-329`); `ProjectDetail` validates Název-required with no asterisk (`:377` vs `:192`); `DateField`/`TimeField` take no `error` prop so a failed required date only reddens helper text, not the border. **Fix:** make `required` the single source of truth (drives asterisk + ideally validation); thread an `error` prop through `DateField`/`TimeField`.
|
||||||
|
|
||||||
|
- **Save-button feedback differs (spinner vs text swap); dual-save warehouse forms don't show which action is running.** Document pages render a `CircularProgress` and track `savingAction` so only the clicked button spins (`OfferDetail.tsx:764`); most other forms just swap to text (`ProjectDetail.tsx:346`). On `WarehouseIssueForm` both save buttons swap to identical "Ukládání..." and disable (`:455-465`) — you can't tell which is in flight. **Fix:** adopt one affordance app-wide (spinner+disabled with per-action targeting); spin only the clicked warehouse action.
|
||||||
|
|
||||||
|
- **Note typed in the create-project modal is saved to the legacy column and shown as non-editable.** The "Přidat projekt" modal's Poznámka writes `projects.notes` (`Projects.tsx:164`, `projects.service.ts:222`), but `ProjectDetail` renders that column read-only under "Starší poznámka (před zavedením systému)" (`:477,491`). So a note typed at creation is instantly mislabeled as legacy and uneditable, while the real editable `project_notes` system lives only on the detail page. **Fix:** drop the Poznámka field from the create modal, or route the create-time note through `createProjectNote`.
|
||||||
|
|
||||||
|
### 4.6 Loading, empty & error states
|
||||||
|
|
||||||
|
- **Failed list fetches silently render the empty state.** `usePaginatedQuery` exposes `isError`/`error` (`:47-48`) but no list page reads them, and `queryClient.ts` has no `QueryCache.onError`. So a 500/network drop falls through to the `DataTable` empty slot — Projects shows "Zatím nejsou žádné projekty" (`:480`). Detail pages handle errors loudly; `WarehouseReports.tsx:350` shows an inline `<Alert severity=error>`. **Fix:** add a `QueryCache.onError` toast and/or render an inline error distinct from empty when `isError`, with retry wired to `refetch`.
|
||||||
|
|
||||||
|
- **Empty states built two ways, and clickable-CTA vs text-only split.** Shared `<EmptyState>` is used by document/warehouse lists, but Projects/Vehicles/Users hand-roll `Box(textAlign,py:6)+Typography+Button` (`Projects.tsx:471/480`, `Vehicles.tsx:323`, `Users.tsx:350`), reinventing the `action` prop. Offers renders a clickable "Vytvořit první nabídku" (`:835`) while Invoices/ReceivedInvoices only name the button in description text (`Invoices.tsx:770`). **Fix:** migrate hand-rolled empties to `<EmptyState title description action>` and standardize the clickable-create-CTA convention.
|
||||||
|
|
||||||
|
- **Empty-state microcopy is inconsistent** (fragments vs sentences, trailing-period split, three filtered-empty phrasings, 2nd person). Fragments with no period (`AuditLog.tsx:368`, `WarehouseReports.tsx:223,364`) vs full sentences (`AttendanceHistory.tsx:653`). Filtered-empty worded three ways: "neodpovídají filtru" (`Offers.tsx:827`) / "neodpovídají filtru." (`Projects.tsx:473`) / "neodpovídají hledání." (`ReceivedInvoices.tsx:859`). Nothing-yet mixes "Zatím nejsou žádné X." with 2nd-person "Zatím nemáte žádné žádosti" (`LeaveRequests.tsx:253`). **Fix:** adopt one canonical filtered-empty string, one unfiltered-empty string, one trailing-period rule.
|
||||||
|
|
||||||
|
- **Changing a month/filter gives three different loading behaviors.** Document/warehouse lists dim the card via `isFetching` opacity (`Offers.tsx:815`). `TripsHistory`/`TripsAdmin` keep stale data with NO feedback (`:304-306`/`:810-812`). `AttendanceHistory` uses plain `useQuery` with no `placeholderData`, so a month change flips `isPending` and swaps the whole table for a centered spinner (`:523,646`). **Fix:** standardize on the `isFetching` card-dim; give Trips screens the dim and `AttendanceHistory` `keepPreviousData`.
|
||||||
|
|
||||||
|
- **No real skeletons; 40 list pages blank the whole page to a centered spinner (layout shift); "skeleton" identifiers are misnamed.** The only first-paint affordance is `LoadingState` (centered spinner, `LoadingState.tsx:10-22`); 40 pages early-return it before rendering chrome (`Projects.tsx:267`, `Offers.tsx:492`), so title/tabs/filter bar vanish then snap in. There are zero MUI `<Skeleton>`s despite identifiers like `showListSkeleton` (`ReceivedInvoices.tsx:357`). **Fix:** render `PageHeader`+`FilterBar` immediately and confine the spinner (or a real DataTable skeleton — the `skeleton-boha` skill exists) to the table area; at minimum rename the misleading identifiers.
|
||||||
|
|
||||||
|
- **Redundant sequential/stacked spinners.** First visit to a lazy section shows the Suspense fallback spinner (chunk download, `AdminApp.tsx:112`), then the page's own `isPending` shows a SECOND centered spinner. Separately `ReceivedInvoices` never early-returns: `renderKpi` shows `<LoadingState/>` (`:752`) AND the list shows one (`:846`) — two stacked spinners on cold load, where sibling `Invoices` shows one. **Fix:** render a single unified loading state per page (have lazy pages supply their own shell as the Suspense fallback).
|
||||||
|
|
||||||
|
- **Dashboard hand-rolls its loading spinner and mixes empty-state styles.** `Dashboard.tsx:313-316` and `DashSessions.tsx:161-164` inline `Box+CircularProgress` (different padding/size, no aria-label) vs the shared `<LoadingState/>`. `DashTodayPlan`/`DashSessions` hand-roll Typography empties while `DashActivityFeed`/`DashAttendanceToday` use `<EmptyState>`. On the most-visited page. **Fix:** use `<LoadingState/>` and standardize card empties on `<EmptyState>`.
|
||||||
|
|
||||||
|
- **Profile, 2FA and active sessions are gated behind the heavy dashboard aggregate query.** `DashProfile` and `DashSessions` render only inside `{!dashLoading && (...)}` (`Dashboard.tsx:505`), where `dashLoading` is the multi-domain aggregate (attendance+offers+invoices+orders+projects+leave). Neither block depends on that data — `DashProfile`'s 2FA comes from `totpStatusOptions`, `DashSessions` has its own query (`:74`). **Fix:** render `DashProfile`/`DashSessions` independently; gate only the data-dependent cards.
|
||||||
|
|
||||||
|
- **Autocomplete pickers show English "No options."** `MuiProvider` sets only the date-picker locale, no global `csCZ` `localeText` (`:15-22`); `CustomerPicker.tsx:49`/`SupplierPicker.tsx:49` omit `noOptionsText`, so empty search shows MUI's built-in English. `ItemPicker.tsx:61` correctly sets "Žádné položky." **Fix:** add MUI `csCZ` component `localeText` (fixes every Autocomplete/pagination at once) or at minimum `noOptionsText` on the two pickers.
|
||||||
|
|
||||||
|
### 4.7 List-page consistency
|
||||||
|
|
||||||
|
- **Invoices/ReceivedInvoices/AuditLog search fires a query per keystroke (no debounce).** Offers/IssuedOrders/ReceivedOrders/Projects/Warehouse use `useDebounce(value,300)`, but `Invoices.tsx:282`, `ReceivedInvoices.tsx:250` and `AuditLog.tsx:202` feed the raw value into the query key — laggier and hammering the API on the busiest financial screens. **Fix:** wrap in `useDebounce` (or a shared `SearchField`).
|
||||||
|
|
||||||
|
- **DataTable `onRowClick` rows aren't keyboard-operable; row-click only exists on warehouse lists.** Warehouse Items/Issues/Inventory/Receipts open via clicking the row (their _only_ entry), but Offers/Invoices/Orders/Projects/Users/Vehicles don't set `onRowClick`. Worse, the clickable `TableRow` has no `tabIndex`/`role`/`onKeyDown` (`DataTable.tsx:285-303,137-161`), so a keyboard user **cannot** open a warehouse record. `OdinSidebar` already has the correct pattern (`:126-140`). **Fix:** add `role="button"`, `tabIndex=0`, Enter/Space handling when `onRowClick` is set; then pick one row convention.
|
||||||
|
|
||||||
|
- **Column sorting absent on warehouse-document, audit-log and trips tables that look sortable.** `DataTable` renders sort headers only when columns carry `sortKey` and the page wires `onSort`. Offers/Invoices/Orders/Projects/WarehouseItems do; `WarehouseIssues.tsx:258`, `AuditLog.tsx:364`, `TripsAdmin.tsx:815` and WarehouseReceipts don't — you can't reorder warehouse docs by date/status nor the audit log by user, with no cue distinguishing a sortable header from a dead one. **Fix:** wire `useTableSort`+`sortKey`+`onSort` into the large paginated tables.
|
||||||
|
|
||||||
|
- **Sibling Order tabs disagree on the "all" status label.** `IssuedOrders` uses "Všechny" (`:60`), `ReceivedOrders` "Všechny stavy" (`:54`) under the same parent Orders tabs. **Fix:** unify the label (don't force the Tabs-vs-Select control change — IssuedOrders deliberately keeps a Select).
|
||||||
|
|
||||||
|
- **Filter baseline diverges: search/date/reset missing where needed.** WarehouseIssues/Receipts have search+status+date-range+reset; `WarehouseInventory.tsx:149` has only a status dropdown (no search despite `session_number`); `WarehouseReservations.tsx:354` has no text search or date filter; `AuditLog.tsx:321` (5 filters) and `TripsAdmin.tsx:734` (3 filters) have **no reset**. **Fix:** standardize a list-filter baseline; add the missing search/date/reset.
|
||||||
|
|
||||||
|
- **Offers/IssuedOrders filter by customer/supplier but the Invoices list cannot.** Offers has a customer Select (`:797-812`), IssuedOrders a supplier Select (`:400-415`), but the equally customer-centric Invoices list offers only free-text search (`:739-751`). **Fix:** add a customer Select (load via `offerCustomersOptions`, wire `customer_id` into list+totals; may need a small backend add).
|
||||||
|
|
||||||
|
- **Count subtitle missing on Projects/Users/Vehicles/Customers and the Přijaté tab; count copy splits `czechPlural` vs hand-rolled ternary.** Projects/Users/Vehicles show a bare title (`Projects.tsx:427`, `Users.tsx:338`, `Vehicles.tsx:311`), Customers a static description (`:492`), and Vydané shows a count line but Přijaté doesn't. The plural is hand-rolled five times (`WarehouseIssues.tsx:112-114`) where `czechPlural()` exists (`formatters.ts:69`). **Fix:** add a result-count subtitle via `PageHeader`; replace the hand-rolled ternaries with `czechPlural()`.
|
||||||
|
|
||||||
|
- **Many pages hand-roll the title bar instead of `PageHeader`, so multi-button headers wrap raggedly on mobile.** `PageHeader`'s `headerActionsSx` makes header buttons stack full-width on phones (`PageHeader.tsx:19-30`), but ~20 pages hand-roll `Box+Typography h4 + raw action Box` (Projects, Users, Vehicles, TripsAdmin, Attendance, AttendanceAdmin, Settings, CompanySettings). The multi-button ones (AttendanceAdmin Tisk/Vyplnit/Přidat `:179-198`; TripsAdmin Tisk/Vozidla `:699-731`) wrap into a ragged half-width grid on phones. **Fix:** route through `<PageHeader>` or wrap the action Box in `headerActionsSx`.
|
||||||
|
|
||||||
|
- **Three forked invoice PDF openers → inconsistent feedback and different error copy.** Shared `useDocumentListPdf`/`useDocumentPdf` pre-opens the tab, shows a per-row spinner, guards double-clicks, handles 401 and revokes the blob URL (`useDocumentPdf.ts:37-86`); Offers/IssuedOrders use it. But Invoices reimplements it inline (`:361`), ReceivedInvoices reimplements with **no** loading state (`:561`), and InvoiceDetail forks its own (`:1175`). Same failure yields three different sentences. **Fix:** route all three through the shared hook (make the error string a parameter — received-invoice `openFile` streams a stored upload, not a generated PDF).
|
||||||
|
|
||||||
|
- **Invoices LIST shows a PDF button on draft invoices, dead-ending in a 404.** Offers/IssuedOrders hide the PDF icon for drafts and InvoiceDetail's own header hides it on drafts (`:1890`), but the Invoices list renders it for every invoice gated only on permission (`:616-626`). **Fix:** gate on `inv.invoice_number` too, matching `Offers.tsx:660`/`IssuedOrders.tsx:303`.
|
||||||
|
|
||||||
|
- **Customers and Suppliers directories use opposite data/search/pagination models.** Built as mirrors, but `OffersCustomers` fetches the entire list once and filters client-side with no debounce/pagination (`:159,380`), while `WarehouseSuppliers` is server-paginated, debounced, page-sized with real Pagination (`:139,449`). **Fix:** align Customers on the Suppliers model (server pagination/search), preserving instant responsiveness via `keepPreviousData`/quick debounce.
|
||||||
|
|
||||||
|
### 4.8 Navigation & information architecture
|
||||||
|
|
||||||
|
- **Received-order detail returns you to the wrong Orders tab.** Received orders live under Orders→"Přijaté", but `OrderDetail`'s Zpět/post-delete/fetch-error redirects all go to `/orders` with no tab, and Orders defaults to "vydane" (`Orders.tsx:78-79`) — so you land on the issued-orders list (a different doc type). IssuedOrderDetail does it right with `/orders?tab=vydane`. **Fix:** point `OrderDetail`'s links (`:116,280,399`) at `/orders?tab=prijate`.
|
||||||
|
|
||||||
|
- **The "Zpět" back control is built four different ways.** No shared back primitive: document detail puts a labeled outlined Zpět on the LEFT (`OfferDetail.tsx:643`); warehouse detail puts it on the RIGHT inside PageHeader (`WarehouseItemDetail.tsx:372`); warehouse forms use an icon-only IconButton on the left (`WarehouseReceiptForm.tsx:423`); `AttendanceCreate` uses a text "← Zpět na správu" on the right (`:173`). All hardcode the list route. **Fix:** add an optional `back={{to,label}}`/`onBack` prop to `PageHeader` rendering one canonical left-aligned icon+label control.
|
||||||
|
|
||||||
|
- **No per-page browser title — every tab/bookmark/history entry reads "Admin."** `index.html:22` hardcodes `<title>Admin</title>` and nothing updates it (zero `document.title` refs). With multiple tabs open (common here), the tab strip/history/bookmarks all read "Admin." **Fix:** add a `<TitleSync>` setting `document.title` per route from `navData.tsx` labels (`+ " · BOHA"`).
|
||||||
|
|
||||||
|
- **No route breadcrumbs on deep detail/form pages.** Deep destinations (`/warehouse/items/:id`, `/warehouse/receipts/:id/edit`, `/orders/issued/:id`) give only one back link to a single hardcoded list, with no "Sklad › Položky › <item>" trail. Combined with the missing title, deep-page orientation is thin. **Fix:** add a lightweight section › list › record breadcrumb derived from route + `navData` metadata.
|
||||||
|
|
||||||
|
- **`settings.templates` gates the Nastavení nav item but Settings bounces such users to the dashboard.** The gear shows if the user holds any of five permissions including `settings.templates`, but `canAccessSettings` checks only roles/company/system/banking and `Navigate`-to-`/` otherwise (`Settings.tsx:199-200,421-423`). So a templates-only user sees the gear, clicks, and is teleported away. Meanwhile the offer-templates editor (`/offers/templates`, `OffersTemplates.tsx:137`) has **no** nav entry — only a "Šablony" button buried in the Offers header (`Offers.tsx:750`). **Fix:** drop `settings.templates` from the Nastavení gate (`navData.tsx:486-492`) and give the templates editor a discoverable home.
|
||||||
|
|
||||||
|
- **Three different "access denied" experiences.** ~40 pages render the polished `<Forbidden/>` (403 + explanation + "Zpět na Dashboard"); Settings silently `Navigate`-to-`/` (`:421-423`); Odin shows a bare unstyled line of text (`Odin.tsx:9-17`). **Fix:** standardize on `<Forbidden/>` everywhere.
|
||||||
|
|
||||||
|
- **"Zákazníci" master data sits under the misleading URL `/offers/customers`.** Customers are a top-level entity referenced by offers, orders and invoices, yet the route implies a sub-resource of offers — visible in code as the `matchExclude:['/offers/customers','/offers/templates']` hack the Nabídky nav item needs (`navData.tsx:250`). **Fix:** move customers to a top-level `/customers` (redirect from the old URL), removing the hack.
|
||||||
|
|
||||||
|
- **Master-data lists are scattered across four nav sections with no cross-links.** Zákazníci under Administrativa, Dodavatelé under Sklad, Vozidla under Kniha jízd, Uživatelé under Systém (`navData.tsx:226,297,448,470`). Salient consequence: issued orders (Administrativa) pull suppliers from `sklad_suppliers` (Sklad), so fixing a supplier means jumping to Sklad. **Fix:** at minimum add cross-links (e.g. "spravovat dodavatele" from the issued-order supplier picker → `/warehouse/suppliers`).
|
||||||
|
|
||||||
|
- **Sklad nav is a flat 10-item list mixing daily ops with one-time config.** Ten ungrouped items (`navData.tsx:314-463`) intermix `warehouse.operate` daily drivers with rarely-touched `warehouse.manage` config (Kategorie/Lokace/Dodavatelé). **Fix:** split into operations vs "nastavení skladu" subgroups (or move the three config screens behind one entry).
|
||||||
|
|
||||||
|
- **One "Vytvořit objednávku" button navigates to a page on one tab and opens a modal on the other.** On Objednávky: Vydané routes to `/orders/issued/new`, Přijaté opens a modal (`Orders.tsx:119-132`, `ReceivedOrders.tsx:606`). Across modules, offers/invoices/issued orders use routed full-page forms while received orders create in a modal. **Fix:** pick one pattern per tier or split into two distinct labelled actions.
|
||||||
|
|
||||||
|
- **No skip-to-content link — keyboard users tab the whole sidebar on every page.** A permanent 248px sidebar with many links renders before `<main>` (`AppShell.tsx:130-145,210-223`), with no skip link and no id on `<main>`. **Fix:** add a visually-hidden "Přeskočit na obsah" link (visible on focus) targeting `id="main-content"`.
|
||||||
|
|
||||||
|
### 4.9 Discoverability & workflow friction
|
||||||
|
|
||||||
|
- **"Moje žádosti" has no create action.** Its header has no actions and its empty state literally instructs filing on the Docházka page (`LeaveRequests.tsx:241,254`); the only way to open the leave form is a button buried on the punch screen (`Attendance.tsx:936,978`). **Fix:** add a "Nová žádost" button to `LeaveRequests` opening the same modal; lift the modal into a shared component.
|
||||||
|
|
||||||
|
- **Dashboard "Vaše dnešní zařazení" card shows the project as plain text — every other dash card links onward.** `DashTodayPlan` renders `projectLabel` as plain text (`:69`) while other cards link "Vše →"/"Detail →." **Fix:** wrap `projectLabel` in a `RouterLink` to `/projects/:id` (id is already in `TodayPlan`); optionally add a "Plán prací →" header button.
|
||||||
|
|
||||||
|
- **Issue detail shows the consumed reservation as a raw "ID: n" database identifier** (`WarehouseIssueDetail.tsx:204`), unlike the project reference beside it (a real link). **Fix:** at minimum render "R{id}" to match `ReservationPicker`'s own label (`:65`).
|
||||||
|
|
||||||
|
- **`ReservationPicker` is dead code — a manual Výdejka can't draw down an existing reservation.** A ready-made picker exists (`ReservationPicker.tsx:14`) but is imported nowhere; the issue form only sets `reservation_id` from router prefill (`WarehouseIssueForm.tsx:183`). **Fix:** render it in the issue-form line (gated on item_id+project_id, mirroring `BatchSelect`) — or delete the unused component.
|
||||||
|
|
||||||
|
- **Audit log surfaces only the one-line description — the stored old/new values are on the wire but never shown.** `audit_logs` stores `old_values`/`new_values` and the route returns full rows (`routes/admin/audit-log.ts:64-74`), but the page's row type omits them and rows aren't expandable (`AuditLog.tsx:97-105,229-275`). The most valuable part of an audit trail is invisible. **Fix:** add the fields to the type and open a detail modal rendering old→new.
|
||||||
|
|
||||||
|
- **Odin's empty state never hints it can query real system data.** The landing hero only says "Zeptejte se na cokoli, nebo přiložte fakturu…" (`OdinThread.tsx:82-149`), giving no hint Odin can query invoices/offers/orders/projects/warehouse via its read-only tools. **Fix:** add 3-4 clickable example-prompt chips that prefill the composer (`OdinChat.tsx:218-219`).
|
||||||
|
|
||||||
|
- **Keyboard shortcuts ship undiscoverable; `ShortcutsHelp` is a permanent no-op.** `ShortcutsHelp` is mounted but returns null (`:1-7`, `AppShell.tsx:226`), while Ctrl+Enter (note save), Enter (Odin send), Enter (plan category) ship with no help. **Fix:** build the "?"-opens-cheat-sheet overlay, or as a cheap first step add `title` hints (copy `OdinComposer`'s `title="Odeslat (Enter)"` onto `NoteCard`'s Ctrl+Enter).
|
||||||
|
|
||||||
|
- **Plan grid resets to the current week on every remount.** `usePlanWork` inits view/anchor to `new Date()` with no persistence (`:112-113`), so leaving and returning to `/plan` loses your scroll position. **Fix:** persist view+anchor (URL query or localStorage).
|
||||||
|
|
||||||
|
- **"Zpět na správu" / post-save links pass `?month=YYYY-MM` but AttendanceAdmin ignores it.** `AttendanceCreate` (`:141`) and `AttendanceLocation` (`:211`) link to `/attendance/admin?month=…`, but `useAttendanceAdmin` never reads the URL (hard-inits month to current). **Fix:** seed the initial month from `useSearchParams()` and keep it synced.
|
||||||
|
|
||||||
|
- **Receipt/Issue details never show a document-level total.** They compute per-line "Celkem" but the `DataTable` has no footer/sum (`WarehouseReceiptDetail.tsx:250`, `WarehouseIssueDetail.tsx:182`); forms show no running total. Every other money-bearing document leads with a prominent total. **Fix:** add a summary line (sum of qty×unit_price via `formatCurrency`) under the Položky table and a live running total in the forms.
|
||||||
|
|
||||||
|
- **Inventory form only creates a draft; Receipt/Issue forms offer "Uložit a potvrdit" in one action.** `WarehouseInventoryForm` offers only "Vytvořit inventuru" (`:204`); to post you must go to the detail and click "Potvrdit inventuru" (`:209`). **Fix:** add a "Vytvořit a potvrdit" button (the form already collects `actual_qty`), OR surface a hint explaining the deliberate two-step variance-review flow.
|
||||||
|
|
||||||
|
- **Trips admin can't enter a trip on behalf of a driver, though attendance admin can create for any employee.** `TripsAdmin` offers only Tisk + a Vozidla link (`:710`), no "add trip," and its edit modal has no driver picker. The `/trips` POST already accepts `body.user_id` (`trips.ts:341`). **Fix:** add an "Přidat jízdu" action with a driver selector, mirroring `AttendanceAdmin`.
|
||||||
|
|
||||||
|
- **⚠ Found during verification (backend, not UX): `POST /trips` accepts `body.user_id` without a `trips.manage` check.** Any user holding only `trips.record` can create a trip attributed to another user's id (`src/routes/admin/trips.ts:341`, `trips.schema.ts:13`) — reads are correctly scoped (`buildTripsWhere` limits non-managers to their own trips) but the create path is not. Minor impersonation-on-create authz gap. **Fix:** ignore `body.user_id` (force `authData.userId`) unless the caller holds `trips.manage`.
|
||||||
|
|
||||||
|
- **Personal Trips "Záznam" shows company-wide trips/stats for managers.** Unlike "Moje historie" (filters by `userId`), the personal Trips page calls `tripListOptions`/`tripStatsOptions` with no `userId` and renders a "Řidič" column (`Trips.tsx:166,359`) — surfacing all drivers' trips and company-wide totals, unlike the strictly-personal attendance punch screen. **Fix:** pass `userId` (matching `TripsHistory`), or relabel/drop the Řidič column. Keep list and stats on one scope.
|
||||||
|
|
||||||
|
### 4.10 Forked/duplicated shared modules & code drift
|
||||||
|
|
||||||
|
- **`InvoiceDetail` forks the shared document editor (and drops the item-description maxLength cap).** Offers/issued orders compose shared `DocumentItemsEditor`/`useDocumentPdf`/`useUnsavedChangesGuard`; `InvoiceDetail` reimplements all three (`:241-541,1175-1196,942-958`). Divergence: the invoice item description textarea has **no maxLength** while the shared editor caps it (`DocumentItemsEditor.tsx:390`) — an over-long description 500s at Prisma instead of capping client-side. **Fix:** add a `showVat`/`vatOptions` flag to `DocumentItemsEditor` (respecting "VAT only on invoices") and migrate `InvoiceDetail` onto it plus the shared hooks.
|
||||||
|
|
||||||
|
- **Status label/color maps duplicated across warehouse, projects and leave.** `documentStatus.ts` exists specifically to kill per-page maps, yet DRAFT/CONFIRMED/CANCELLED recur across 6 warehouse files (`WarehouseIssues.tsx:35-48`), `Projects.tsx:44-57`/`ProjectDetail.tsx:37-50` are byte-identical, and `LeaveRequests.tsx:25-55`/`LeaveApproval.tsx:36-66` are byte-identical. **Fix:** add `WAREHOUSE_DOC_STATUS`, `MOVEMENT_TYPE`, `RESERVATION_STATUS`, `PROJECT_STATUS`, `LEAVE_STATUS`/`LEAVE_TYPE` to `documentStatus.ts` and import them.
|
||||||
|
|
||||||
|
- **`AttendanceAdmin` fetches outside React Query (raw `apiFetch` in `useEffect` + manual refetch dance).** `useAttendanceAdmin` loads projects/users/records via raw `apiFetch` with its own `setData`/`setLoading` (`:823,871`), so it doesn't participate in `['attendance']` invalidations — each mutation must invalidate AND manually re-run `fetchData` with a `setTimeout(300)` (`:1057-1063`). The single biggest structural inconsistency and a latent stale-data risk. **Fix:** migrate reads to React Query options.
|
||||||
|
|
||||||
|
- **Orphaned standalone attendance-create page diverges from the admin modal (no project logs).** Adding a record exists twice: a standalone full-page form at `/attendance/create` AND `ShiftFormModal` (`AttendanceAdmin.tsx:194`). The modal supports per-shift project-time logs; the standalone page can't (`AttendanceCreate.tsx:63-147`), and nothing navigates to it. **Fix:** delete `AttendanceCreate.tsx` and its route — the modal is the live superset.
|
||||||
|
|
||||||
|
- **"Vytvořit objednávku" modal uses two different file pickers.** The Offers-list version uses the shared `FileUpload`; the Offer-detail version reimplements it with a raw `<input type=file>` + manual readout + custom remove button (`OfferDetail.tsx:1066-1112` vs `Offers.tsx:894-929`). Same dialog, two attachment UIs. **Fix:** replace the raw input with `FileUpload` (gains drag-drop/validation).
|
||||||
|
|
||||||
|
- **Warehouse entities use three different create/edit interaction models.** Items edit inline on the detail page via a toggle (`WarehouseItemDetail.tsx:112`); Receipts/Issues use a full-page Form + read-only Detail; Inventory has a Form for creation only; Reservations/Categories/Locations/Suppliers use a list-page Modal. Items (a flat record like Suppliers) using inline edit is the main outlier. **Fix:** document one convention (line-item records = Form+Detail; flat lookups = list modal) and reconcile the Items outlier — treat as a documented rule, not a forced risky migration.
|
||||||
|
|
||||||
|
- **Inconsistent "record not found": Item detail redirects away, siblings keep a stable URL.** `WarehouseItemDetail` toasts + `navigate('/warehouse/items')` (`:153-158`); `WarehouseIssueDetail`/`ReceiptDetail`/`InventoryDetail` render an in-place "nebyla nalezena" EmptyState with a Back button and keep the URL (`:122`/`:200`/`:99`). **Fix:** align Item detail to the in-place EmptyState pattern.
|
||||||
|
|
||||||
|
- **Settings save model is inconsistent.** The System tab has **two** "Uložit" buttons doing the same thing (`Settings.tsx:997-1007,1193-1199`); other cards have no save; 2FA saves instantly; on Firma, bank accounts/logo apply instantly while company info/numbering/currency need the bottom save (`CompanySettings.tsx:990-1013,1457-1461`). The user can't predict whether a change is live or pending. **Fix:** drop one duplicate System Save; visually distinguish auto-apply vs pending controls.
|
||||||
|
|
||||||
|
- **Attendance location page formats datetime with `new Date()` instead of the timezone-safe shared helper.** `AttendanceLocation` defines a local `formatDatetimeLocal` parsing with `new Date()` (`:176`); the rest of attendance uses the timezone-safe `formatDatetime` (`attendanceHelpers.ts:42`), so the same timestamp renders differently. **Fix:** build a regex-based timezone-safe formatter that keeps the year (the location detail legitimately wants the year, which `formatDatetime` omits).
|
||||||
|
|
||||||
|
- **Document editor uses two reorder gestures side by side.** Line items reorder via a drag handle (`DocumentItemsEditor.tsx:350-364`), but sections (`SectionsEditor.tsx:200-219`) and company custom fields (`CompanySettings.tsx:1049-1067`) use up/down arrows — on the same page. The drag-only items are also less keyboard-discoverable. **Fix:** add up/down arrow buttons alongside the line-item drag handle (matching SectionsEditor) — also fixes the keyboard-affordance gap.
|
||||||
|
|
||||||
|
- **Login re-implements the theme toggle.** The shell uses shared `<ThemeToggle/>` (animated crossfade, title + aria-label); Login builds its own IconButton with hand-duplicated SVGs, only a title, no aria-label, no animation (`Login.tsx:264-313` vs `ThemeToggle.tsx:36-57`). The first screen every user sees is less accessible. **Fix:** reuse the shared `<ThemeToggle/>`.
|
||||||
|
|
||||||
|
### 4.11 Visual & theming consistency
|
||||||
|
|
||||||
|
- **Projects status-chip colors diverge from the app-wide convention (and duplicate the label map).** `documentStatus.ts` establishes success=done/paid, error=cancelled, info=open. Projects breaks all three — ACTIVE=green, COMPLETED=**blue**, CANCELLED=**grey** (`Projects.tsx:50`, `ProjectDetail.tsx:43`). So a finished project shows blue while a finished order shows green. **Fix:** add a `PROJECT_STATUS` map (aktivni→info, dokonceny→success, zruseny→error) and consume via `statusLabel`/`statusColor`.
|
||||||
|
|
||||||
|
- **Linked order status on Project detail renders as a raw untranslated token.** `ProjectDetail` shows `STATUS_LABELS[project.order_status]` but `STATUS_LABELS` is the _project_ map while `order_status` is `prijata`/`v_realizaci`/… — zero key overlap (`:577`), so a completed order shows "(dokoncena)" lowercase plain text. **Fix:** render via `statusLabel(ORDER_STATUS, project.order_status)` (ideally a `StatusChip`).
|
||||||
|
|
||||||
|
- **Settings admin-role callout uses a solid `info.light`/`info.dark` fill (the forbidden .light-fill anti-pattern).** The "Administrátor má vždy plný přístup" box is hand-styled with `bgcolor:'info.light'`/`color:'info.dark'` (`Settings.tsx:1228-1229`) — the single solid palette-.light fill in the admin, violating the documented channel-alpha rule; in dark mode it's garish low-contrast blue-on-blue. **Fix:** use the kit `<Alert severity='info'>` (`Alert.tsx:8`), or a channel-alpha wash.
|
||||||
|
|
||||||
|
- **Dashboard "Vystavit fakturu" quick action reuses the destructive error/red palette.** `DashQuickActions` sets color `'danger'` → MUI error (`:296`,`:59`), the same red as every Delete button, so "Issue invoice" reads as destructive beside green/blue/orange create actions. _(Caveat: red is also the invoice PDF brand family (#de3a3a), and "Odchod" uses `danger` too — this may be deliberate branding; treat as optional.)_ **Fix:** if not intentional, change to a non-destructive color (info/primary); reserve error/red for deletes.
|
||||||
|
|
||||||
|
- **Plan `DayInRangeModal` shows raw ISO dates while the rest of the plan dialogs format cs-CZ.** It prints "2026-06-28 – 2026-07-02" (`PlanCellModal.tsx:466,474,495`) while ViewModal/DayPanel format "28. 6. 2026." **Fix:** wrap the dates in `formatDate()`.
|
||||||
|
|
||||||
|
- **Project delete confirm differs between list and detail.** The list ConfirmDialog always renders the "Smazat i soubory na disku" checkbox even for projects with no NAS folder, with terse copy, no name, no irreversibility warning (`Projects.tsx:508,516`); the detail gates the checkbox on `has_nas_folder` and uses richer copy + "Tato akce je nevratná." (`ProjectDetail.tsx:621,626`). **Fix:** gate the list checkbox on `has_nas_folder` and use the same copy (name + warning).
|
||||||
|
|
||||||
|
### 4.12 Microcopy & localization
|
||||||
|
|
||||||
|
- **Goods-receipt module named four ways; "Příjmy" also reads as financial income.** Nav "Příjmy" (`navData.tsx:348`), overview button "Nový příjem" (`Warehouse.tsx:203`), list title "Příjmové doklady" + "Nový doklad" (`WarehouseReceipts.tsx:170,178`), detail/audit "Příjemka" (`WarehouseReceiptDetail.tsx:204`, `entityTypeLabels.ts:29`). Issues mirror this (Výdeje/Výdejky/Výdejka). "Příjmy" is the standard Czech word for _income_, so the nav label is actively misleading. **Fix:** settle on "Příjemka"/"Výdejka" everywhere.
|
||||||
|
|
||||||
|
- **Saving/busy labels inconsistent; one "Chyba pripojeni" diacritics typo; Modal overrides caller `submitText` mid-save.** Competing forms: "Ukládám…" (Modal, AttendanceCreate, NoteCard, DashProfile), "Ukládání..." (most pages), "Zpracovávám…" vs "Zpracovávám...", plus "Vytváření..."/"Odesílám..."; ellipsis sometimes "…" sometimes three dots. The connection-error fallback is "Chyba pripojeni" (no diacritics) on the dashboard (`Dashboard.tsx:127`, `AuthContext.tsx:256,314`) vs "Chyba připojení" elsewhere. Modal hardcodes `loading?'Ukládám…':submitText` (`:96`), overriding "Vytvořit uživatele" mid-save. **Fix:** fix the typo (outlier vs ~90 sites); standardize the busy label + Unicode ellipsis; the Modal hardcoding is acceptable for save forms (lower priority).
|
||||||
|
|
||||||
|
- **"Cancelled" order status spelled two ways in `documentStatus.ts`.** Received orders use `stornovana`→"Stornována" (`:49`); issued orders use `cancelled`→"Stornovaná" (`:58`) — side-by-side under the same `/orders` page. These are display labels (safe to change). **Fix:** pick one Czech form for both.
|
||||||
|
|
||||||
|
- **Add-line button uses literal "+ Přidat položku" in document editors vs icon + "Přidat položku" in warehouse forms.** `DocumentItemsEditor.tsx:596` (literal plus, no startIcon) vs warehouse forms with `startIcon={PlusIcon}` (`WarehouseIssueForm.tsx:601` etc.). **Fix:** standardize the affordance and label.
|
||||||
|
|
||||||
|
- **Create/edit modal submit labels mix entity-specific, bare, and default wording.** "Vytvořit zákazníka"/"Uložit změny" (`OffersCustomers.tsx:543`), "Vytvořit uživatele" (`Users.tsx:369`), bare "Vytvořit"/"Uložit" (`OffersTemplates.tsx:361`), and Vehicles falls back to the Modal default "Uložit" (`Modal.tsx:36`). **Fix:** adopt one rule (create="Vytvořit", edit="Uložit změny") and set it as the Modal default.
|
||||||
|
|
||||||
|
- **Create-action verb differs across analogous list pages; both Orders tabs share the identical "Vytvořit objednávku" label.** Imperative "Přidat X" (master data) vs "Nový/Nová X" (documents) vs "Vytvořit objednávku" (Orders, identical on both tabs for two different doc types, `Orders.tsx:125,129`); the empty-state CTA verb mirrors and compounds this. **Fix:** disambiguate the two Orders tabs first ("Nová vydaná/přijatá objednávka"), then pick one verb convention and align the empty-state CTA.
|
||||||
|
|
||||||
|
- **Several numbers/prices bypass cs-CZ formatting; file-size unit casing disagrees (kB vs KB).** Offers item-template price uses `Number().toFixed(2)`→"1234.50" (period, no separator/currency, `OffersTemplates.tsx:287`); warehouse batch picker builds "…toFixed(2) Kč" (`WarehouseIssueForm.tsx:160`); vacation/sick hour balances use `.toFixed(1)`→"12.5" (`AttendanceBalances.tsx:307`); zeros render "0 Kč" vs "0,00 Kč." File-size helper duplicated with "kB" (`FileUpload.tsx:9`) vs "KB" (`OfferDetail.tsx:1072`). **Fix:** route money through `formatCurrency` and decimals through cs-CZ/Intl; extract one `formatFileSize()` with one unit casing.
|
||||||
|
|
||||||
|
- **Leave-request modal day/hour estimate ignores public holidays.** The modal previews "X pracovních dnů" using `calculateBusinessDays` (Mon–Fri only, `Attendance.tsx:498,1242`), but the rest of the system is holiday-aware (server `leave-requests.ts:99` excludes weekends AND holidays). A vacation spanning e.g. 1 May over-counts vs the stored `total_days`/`total_hours`. **Fix:** make the modal estimate holiday-aware (reuse the holiday helper).
|
||||||
|
|
||||||
|
### 4.13 Mobile / responsive
|
||||||
|
|
||||||
|
- **Status-filter tab bars clip on phones — shared `Tabs` has no scrollable variant.** `Tabs.tsx:28-49` uses MUI's default `variant="standard"` (overflow hidden, no scroll). At ~360px the 5 offer/invoice tabs and 4 project tabs overflow the centered Box and get clipped with no scroll affordance — effectively unreachable. **Fix:** set `variant="scrollable"`, `scrollButtons="auto"`, `allowScrollButtonsMobile` — one change fixes every status filter and the issued/received toggle.
|
||||||
|
|
||||||
|
- **Shared Modal never goes full-screen on phones; leave date grid is hardcoded two-column.** `Modal` renders a plain `fullWidth+maxWidth` Dialog with no responsive `fullScreen` (`:67-75`), so the ~9-field Trip form, the admin shift form and the leave form become a narrow scrolling box at 360px. The leave modal's Od/Do grid is hardcoded `minmax(0,1fr) minmax(0,1fr)` (`Attendance.tsx:1195-1199`) while every other date-pair grid is responsive `{xs:'1fr',sm:'1fr 1fr'}` (`ShiftFormModal.tsx:254`, `Trips.tsx:528-535`). **Fix:** add `fullScreen={useMediaQuery(down('sm'))}` to Modal; fix the leave grid.
|
||||||
|
|
||||||
|
- **Long full-page editors keep Save only in the top header (no sticky save); placement differs page-to-page.** Document detail editors place all Save/Create/Activate in the top `headerActionsSx` slot (`OfferDetail.tsx:688-828`), so after scrolling through header fields/picker/line-items/rich-text/notes a phone user must scroll back to the top to save. `AttendanceCreate` pins Zrušit/Uložit at the **bottom** (`:362-374`); modals pin submit in DialogActions — three placements. **Fix:** add a sticky action bar to long editors and standardize placement.
|
||||||
|
|
||||||
|
- **Warehouse issue/receipt line items lack the labeled card-per-row mobile layout the document editor has.** `DocumentItemsEditor` has a dedicated `isMobile` branch — a bordered card per row with "Položka N" index, labeled fields, per-row total, drag/remove (`:170-342`). `WarehouseIssueForm.tsx:513-567` just collapses a grid to `xs:'1fr'` with placeholder-only fields, no border/labels/separator — once a value is typed the placeholder vanishes and rows blur together. Worst exactly where shop-floor mobile use is likely. **Fix:** reuse the card-per-row pattern (ideally a shared mobile line-item card).
|
||||||
|
|
||||||
|
- **FilterBar children mix fixed-vs-grow flex bases, leaving dead space on mobile.** `AttendanceAdmin.tsx:202,205` use `flex:'0 0 180px'`/`'0 0 220px'` and `AttendanceHistory.tsx:516` uses `flex:'0 0 180px'` (fixed, never grow), so on a phone they sit narrow with dead space beside them, while `Offers.tsx:786` uses `flex:'1 1 320px'` which fills the row. **Fix:** adopt one convention (e.g. `flex:'1 1 <min>px'`) or have `FilterBar` stretch children full-width below `sm`.
|
||||||
|
|
||||||
|
### 4.14 Accessibility
|
||||||
|
|
||||||
|
- **Desktop document line-item grid inputs are unlabeled while the mobile card layout labels every field.** The mobile branch passes explicit labels (Popis, Množství, Jednotka, Jedn. cena, Sleva %); the desktop table renders the same TextFields with **no** label/aria-label, relying on visual `<th>` headers not programmatically associated with the inputs; the desktop "V ceně" Checkbox is unnamed (`DocumentItemsEditor.tsx:374-440,445-450`). A screen-reader user editing an offer on desktop hears a column of unlabeled "edit text" boxes. **Fix:** add an `aria-label` to each desktop cell control (e.g. `Množství, položka ${index+1}`).
|
||||||
|
|
||||||
|
- **List-page search boxes are placeholder-only inputs with no accessible name.** Raw TextFields with only a placeholder (not an accessible name) across ~13 pages (`Offers.tsx:787`, `Invoices.tsx:741`, `WarehouseItems.tsx:180`, `Projects.tsx:454`, `IssuedOrders.tsx:386`). **Fix:** add `aria-label="Hledat"` + `type="search"` — best via a shared `SearchField` (also adds the native clear button and mobile search keyboard).
|
||||||
|
|
||||||
|
- **(Cross-listed) DataTable clickable rows aren't keyboard-operable; no skip-to-content link.** See §4.7 (row semantics) and §4.8 (skip link).
|
||||||
|
|
||||||
|
### 4.15 Performance & bundle
|
||||||
|
|
||||||
|
- **MUI + Emotion live in the 781 kB entry chunk, so every deploy busts the cached UI vendor code.** `vite.config.ts` `manualChunks` only carves out react/react-dom/react-router (436 kB) and framer-motion (147 kB); the entire MUI library + Emotion fall into the entry chunk (781 kB raw / 232 kB gzip). With frequent same-day patch releases, daily users re-download ~232 kB gzip of unchanged MUI every deploy. **Fix:** split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk.
|
||||||
|
|
||||||
|
- **framer-motion (147 kB) is a static dependency of the eager AppShell, loading on first paint incl. Login.** `AppShell`/`PageEnter`/`ThemeToggle` statically import framer-motion (`PageHeader` deliberately does not); `AppShell` is eagerly imported by `AdminApp` (`:8`, `AppShell.tsx:101`). Login pays for it too. **Fix:** migrate to `LazyMotion` + the lightweight `m` component (`domAnimation`) — note Login and the Dash\* components import framer-motion directly, so lazy-gating only `PageEnter` won't remove it from the login critical path.
|
||||||
|
|
||||||
|
- **Dashboard + its 7 Dash\* subcomponents are bundled into the entry chunk, downloaded on the Login screen.** Dashboard and Login are statically imported in `AdminApp` (`:14-15`), so an unauthenticated user hitting `/login` downloads the entire authenticated dashboard's code before typing a password. **Fix:** lazy-load Dashboard via `lazyWithReload` like every other route; keep Login eager.
|
||||||
|
|
||||||
|
- **No route-transition progress indicator and no link prefetch — heavy pages blank to a spinner on click.** Clicking a sidebar item blanks main content to a centered spinner while the chunk downloads (PlanWork 44 kB, InvoiceDetail 35 kB, AttendanceLocation 155 kB with Leaflet), with no top progress bar and no prefetch (zero `prefetchQuery`/`onMouseEnter`/`preload` hits). A `ProgressBar` exists but is reused only as an attendance-fund meter. **Fix:** add an unobtrusive top `LinearProgress` during route transitions and prefetch the lazy chunk (+ primary query) on sidebar link hover/focus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Suggested Sequencing
|
||||||
|
|
||||||
|
**Sprint 1 — "Stop lying to the user" (mostly Quick Wins, ~2-3 days).**
|
||||||
|
Ship the data-freshness convergence (one invalidation set per family), the global `QueryCache.onError` so failed fetches stop showing as empty, the scrollable mobile tabs, the invoice-paid confirm, the silent-toggle `onError` toasts, the draft-PDF 404 gate, the wrong-Orders-tab redirect, the search debounces, the `csCZ` localeText, the `document.title`, the project/order status-color fixes, and the diacritics/label typos. These are individually small, collectively high-trust, and low-risk. Pair each with a quick Playwright check on the dev server (the user runs it).
|
||||||
|
|
||||||
|
**Sprint 2 — "Converge the primitives" (the structural anti-drift work, ~1 week).**
|
||||||
|
De-fork the dirty-guards and add them to the six unguarded editors; route the invoice PDF openers through `useDocumentPdf`; consolidate the status maps into `documentStatus.ts`; standardize validation on inline `<Field error>` (starting with the mirrored screens); add `<form onSubmit>` to Modal; migrate hand-rolled headers to `PageHeader`; replace inline English errors with `apiErrorMessage`. This is where you stop the Sprint-1 fixes from re-drifting, and it's the cleanest fit for the codebase's "extend, never fork" rule.
|
||||||
|
|
||||||
|
**Sprint 3 — "Mobile + a11y + warehouse parity" (~1 week).**
|
||||||
|
Full-screen modals, sticky save bars, warehouse card-per-row line items, keyboard-operable rows, skip link, search-field accessible names, warehouse document totals, the leave "Nová žádost" action, and the warehouse "Potvrdit" confirm.
|
||||||
|
|
||||||
|
**Strategic backlog (schedule deliberately, each is multi-day and one needs a migration).**
|
||||||
|
Invoice edit-locking (DB migration + route trio — coordinate the migration per CLAUDE.md: ask the user to stop the dev server, apply to `app_test` too). The router upgrade for in-app dirty-nav blocking. `AttendanceAdmin` onto React Query. `InvoiceDetail` onto the shared editor. Real skeletons. Bundle splitting + route prefetch. Treat the navigation/IA items (breadcrumbs, master-data nav grouping, `/customers` move) as a focused IA pass once the above settle.
|
||||||
|
|
||||||
|
A note on scope discipline: nearly every item here is "make B match the already-correct A," so the safest pattern is to **read the reference implementation first** (offers/issued orders for documents, `WarehouseSuppliers` for lists, `documentStatus.ts` for status) and converge onto it — rather than inventing new patterns. That keeps the diff small and the regression surface low, which matches the user's same-day-patch-release rhythm.
|
||||||
@@ -46,6 +46,17 @@ without explicit user confirmation.**
|
|||||||
errors from prior pids).
|
errors from prior pids).
|
||||||
9. Clean up tarballs locally and in `/tmp/` on the server.
|
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||||
|
|
||||||
|
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
|
||||||
|
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
|
||||||
|
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
|
||||||
|
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
|
||||||
|
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
|
||||||
|
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
|
||||||
|
`client_max_body_size`, rate limits, etc.). When you do change it, always
|
||||||
|
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
|
||||||
|
nginx` (graceful re-read, keeps active connections; a bad config on reload is
|
||||||
|
rejected and the old one keeps running).
|
||||||
|
|
||||||
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||||
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||||
constraint names the migration DROPs, check no data violates new
|
constraint names the migration DROPs, check no data violates new
|
||||||
|
|||||||
@@ -0,0 +1,789 @@
|
|||||||
|
# Per-document custom-field print selection — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
|
||||||
|
|
||||||
|
**Architecture:** A new nullable `selected_custom_fields` column (JSON-array-in-VARCHAR) on `quotations`, `issued_orders`, `invoices`. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to `number[]`. The PDF `buildAddressLines()` company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real `app_test` DB), React 19 + MUI v7 + React Query.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for this plan
|
||||||
|
|
||||||
|
- This shell is non-interactive — `prisma migrate dev` is forbidden. Use the `migrate diff` recipe (Task 1).
|
||||||
|
- **Before the migration, ask the user to stop their dev server and wait for confirmation** (it holds DB connections). Do not run the migration until they confirm.
|
||||||
|
- Server tests run against `app_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
|
||||||
|
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must stay at 0 errors.
|
||||||
|
- Selection semantics in `buildAddressLines`: param **omitted/`undefined`** ⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an **array** (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `prisma/schema.prisma` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
|
||||||
|
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
|
||||||
|
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `parseSelectedCustomFields`.
|
||||||
|
- **Create** `src/__tests__/selected-custom-fields.test.ts` — util + integration coverage.
|
||||||
|
- **Modify** `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts` — new optional array field.
|
||||||
|
- **Modify** `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts` — persist on create/update, decode in detail.
|
||||||
|
- **Modify** `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts` — filter company custom lines.
|
||||||
|
- **Modify** `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts` — add `selected_custom_fields: number[]` to detail interfaces.
|
||||||
|
- **Create** `src/admin/components/document/CustomFieldsPrintPicker.tsx` — shared picker.
|
||||||
|
- **Modify** `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` — render picker, wire into save payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema column + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma` (models `quotations`, `issued_orders`, `invoices`)
|
||||||
|
- Create: `prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ask the user to stop their dev server**
|
||||||
|
|
||||||
|
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later `migrate deploy` step.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the column to each model in `prisma/schema.prisma`**
|
||||||
|
|
||||||
|
Add this line to the `quotations` model (near other scalar columns, e.g. after `language`):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the identical line to the `issued_orders` model and to the `invoices` model.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration SQL**
|
||||||
|
|
||||||
|
Run (Git Bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cortex/boha-app-ts
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
|
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a `migration.sql` containing three `ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL` statements (one per table). Open it and confirm it touches ONLY `quotations`, `issued_orders`, `invoices` and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "All migrations have been applied" and a regenerated client.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Apply to the test DB**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the same migration applied to `app_test`. (If the env parsing is awkward on this shell, temporarily set `DATABASE_URL` to the app_test URL from `.env.test` and run `npx prisma migrate deploy`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS (the new Prisma field is now known to the client).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations
|
||||||
|
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend encode/decode util (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils/custom-fields.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/__tests__/selected-custom-fields.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to confirm failure**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: FAIL — `encodeSelectedCustomFields is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the helpers**
|
||||||
|
|
||||||
|
Append to `src/utils/custom-fields.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-document selection of which COMPANY custom fields print on a PDF.
|
||||||
|
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
|
||||||
|
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
|
||||||
|
*/
|
||||||
|
export function encodeSelectedCustomFields(indices: unknown): string | null {
|
||||||
|
const clean = normalizeIndices(indices);
|
||||||
|
return clean.length > 0 ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
|
||||||
|
export function parseSelectedCustomFields(raw: unknown): number[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (Array.isArray(raw)) return normalizeIndices(raw);
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return [];
|
||||||
|
try {
|
||||||
|
return normalizeIndices(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON in a selection column degrades to "none" (expected
|
||||||
|
// condition — a hand-edited/legacy row should never 500 a PDF render).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndices(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const v of input) {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to confirm it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (all util cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Zod schemas
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/offers.schema.ts`
|
||||||
|
- Modify: `src/schemas/issued-orders.schema.ts`
|
||||||
|
- Modify: `src/schemas/invoices.schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers schema**
|
||||||
|
|
||||||
|
In `src/schemas/offers.schema.ts`, inside `CreateQuotationSchema`, add after the `sections` line (line 50):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateQuotationSchema` already derives from this via `.partial().omit({ quotation_number: true })` — no change needed there.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Issued-orders schema**
|
||||||
|
|
||||||
|
In `src/schemas/issued-orders.schema.ts`, inside `CreateIssuedOrderSchema`, add after the `sections` field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateIssuedOrderSchema` derives via `.partial()` — no change.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Invoices schema**
|
||||||
|
|
||||||
|
In `src/schemas/invoices.schema.ts`, add the same line to BOTH `CreateInvoiceSchema` (after its `sections` field) and `UpdateInvoiceSchema` (after its `sections` field — invoices define the two schemas separately, so it must be added in both):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
|
||||||
|
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Services — persist on write, decode in detail (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/offers.service.ts`
|
||||||
|
- Modify: `src/services/issued-orders.service.ts`
|
||||||
|
- Modify: `src/services/invoices.service.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to all three services**
|
||||||
|
|
||||||
|
At the top of each of the three service files, add (or extend the existing import from `../utils/custom-fields`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers — persist on create**
|
||||||
|
|
||||||
|
In `createOffer` (`src/services/offers.service.ts`), inside `tx.quotations.create({ data: { … } })`, add after `scope_description`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Offers — persist on update**
|
||||||
|
|
||||||
|
In `updateOffer`, inside the `const data = { … }` object (after `scope_description`, before `modified_at`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
(`undefined` = "key absent in payload, leave column untouched" — matches the sibling header fields.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Offers — decode in detail**
|
||||||
|
|
||||||
|
In `getOffer`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Issued orders — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/issued-orders.service.ts`:
|
||||||
|
|
||||||
|
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Invoices — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/invoices.service.ts`:
|
||||||
|
|
||||||
|
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
|
||||||
|
```ts
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Extend the test with an offers round-trip (real DB)**
|
||||||
|
|
||||||
|
Append to `src/__tests__/selected-custom-fields.test.ts`. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> Before running: confirm the prisma import path (`../config/prisma` vs `../config/db` — grep an existing test). Adjust the import to match.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the `customer_id`-less draft path above avoids them.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Typecheck + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PDF rendering — filter company custom lines (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/routes/admin/offers-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/issued-orders-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/invoices-pdf.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers PDF — add the filter param to `buildAddressLines`**
|
||||||
|
|
||||||
|
In `src/routes/admin/offers-pdf.ts`, change the signature (line 28-32):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildAddressLines(
|
||||||
|
entity: Record<string, unknown> | null,
|
||||||
|
isSupplier: boolean,
|
||||||
|
t: (key: string) => string,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
|
): AddressResult {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
|
const cfName = (cf.name || "").trim();
|
||||||
|
const cfValue = (cf.value || "").trim();
|
||||||
|
const showLabel = cf.showLabel !== false;
|
||||||
|
if (cfValue) {
|
||||||
|
fieldMap[`custom_${i}`] =
|
||||||
|
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers PDF — pass the document's selection into the COMPANY call**
|
||||||
|
|
||||||
|
The offer's sender/company block is `supp` (`isSupplier: true` on `settings`). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (`cust`) unchanged so customer custom fields still show in full:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings as unknown as Record<string, unknown>,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing to adjust here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/issued-orders-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
|
||||||
|
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
|
||||||
|
```ts
|
||||||
|
const buyer = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `buildSupplierLines(...)` untouched (supplier fields keep showing in full).
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm the render function's `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before assuming.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard.
|
||||||
|
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(invoice as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `cust = buildAddressLines(customer, false, t)` untouched.
|
||||||
|
- Note the separate `settings.custom_fields` read lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — **do not** filter that; leave it as-is.
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is present.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a focused render assertion (extend the test file)**
|
||||||
|
|
||||||
|
If the three PDF modules export their HTML render function (e.g. offers exports `renderOfferHtml`), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock `html-to-pdf` per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
|
||||||
|
|
||||||
|
it("offer PDF prints only selected company custom fields", () => {
|
||||||
|
const settings = {
|
||||||
|
name: "Naše Firma s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Tel.", value: "123", showLabel: true },
|
||||||
|
{ name: "Web", value: "example.cz", showLabel: true },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const quotation = {
|
||||||
|
customers: null,
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
};
|
||||||
|
const html = renderOfferHtml(quotation as never, settings as never);
|
||||||
|
expect(html).toContain("Tel.: 123");
|
||||||
|
expect(html).not.toContain("example.cz");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — detail query types + shared picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- Create: `src/admin/components/document/CustomFieldsPrintPicker.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the field to the three detail interfaces**
|
||||||
|
|
||||||
|
- `src/admin/lib/queries/offers.ts` — add to `OfferDetailData`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields?: number[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the shared picker component**
|
||||||
|
|
||||||
|
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: indices are keyed off the FULL `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<i>`, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
|
||||||
|
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — wire the picker into the three detail pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/pages/OfferDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/IssuedOrderDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/InvoiceDetail.tsx`
|
||||||
|
|
||||||
|
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import the picker (all three pages)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Seed local state from the loaded document**
|
||||||
|
|
||||||
|
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via `useState` initializers keyed on the query, match that):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
|
||||||
|
// when the detail query resolves (same place other fields are seeded):
|
||||||
|
// setSelectedCustomFields(data.selected_custom_fields ?? []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Respect Rules of Hooks: declare this `useState` with the other hooks, before any early `return`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Render the picker in the editable header/meta area**
|
||||||
|
|
||||||
|
Place near the other document-level settings (currency/language). `companySettings` is already loaded on the page via `companySettingsOptions()`. Use the page's existing "is this document editable / not locked by another user" boolean for `disabled` (e.g. `!canEdit` or the locked flag the page already computes):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use whatever the page already calls its edit-gate (e.g. `canEdit`, `editable`, `isLockedByOther`). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Include the selection in the save payload**
|
||||||
|
|
||||||
|
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.tsx`**
|
||||||
|
|
||||||
|
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors (watch `react-hooks/rules-of-hooks` — the new `useState` must precede any early return).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
|
||||||
|
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole server suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (no regressions; new selected-custom-fields cases green).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + lint + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS; client builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (user-driven, dev server is theirs)**
|
||||||
|
|
||||||
|
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final state check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean tree, the feature commits present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (addressed)
|
||||||
|
|
||||||
|
- **Spec coverage:** storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
|
||||||
|
- **Default = none:** `buildAddressLines` filters only when passed an array; the company call always passes the parsed selection (default `[]`), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅
|
||||||
|
- **Positional identity:** indices key off the full `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
|
||||||
|
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
|
||||||
|
- **Open confirmations flagged inline** (prisma import path in tests; whether each PDF route narrows its document `select`) — each has a grep-first instruction rather than an assumption.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Per-document custom-field print selection
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Approved design — ready for implementation plan
|
||||||
|
**Scope:** offers (quotations), issued orders, invoices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Company custom fields are defined in company settings (`company_settings.custom_fields`,
|
||||||
|
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
|
||||||
|
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
|
||||||
|
|
||||||
|
Today **every** custom field with a value prints on **every** document PDF (offer, issued
|
||||||
|
order, invoice). The only existing per-field control is the company-settings checkbox
|
||||||
|
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
|
||||||
|
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
|
||||||
|
|
||||||
|
The user wants per-document control: on each offer / issued order / invoice **detail page**,
|
||||||
|
tick which company custom fields actually print on that specific document's PDF.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Each offer, issued order, and invoice independently selects which company custom fields
|
||||||
|
print in its PDF sender block.
|
||||||
|
- Selection lives on the document and is editable on its detail page.
|
||||||
|
- Default is **none selected** — existing documents and new drafts print no custom fields
|
||||||
|
until fields are deliberately ticked.
|
||||||
|
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
|
||||||
|
behavior (print all custom fields).
|
||||||
|
- No change to how custom fields are _defined_ in company settings.
|
||||||
|
- No stable per-field IDs — selection is **positional** (see Decisions).
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
|
||||||
|
document prints custom fields only for the indices it explicitly stores.
|
||||||
|
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
|
||||||
|
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
|
||||||
|
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
|
||||||
|
saved document's stored indices point at. Low risk — company fields rarely change, and
|
||||||
|
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
|
||||||
|
over the more robust stable-ID approach.
|
||||||
|
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
|
||||||
|
4. **`showLabel` retained** in company settings, untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data storage
|
||||||
|
|
||||||
|
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
|
||||||
|
|
||||||
|
| Column | Type | Meaning |
|
||||||
|
| ------------------------ | --------- | ------------------------------------------------------------- |
|
||||||
|
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
|
||||||
|
|
||||||
|
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
|
||||||
|
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
|
||||||
|
|
||||||
|
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
|
||||||
|
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
|
||||||
|
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
|
||||||
|
|
||||||
|
### 2. Backend — schemas
|
||||||
|
|
||||||
|
Add to each document's **Create and Update** Zod schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/schemas/offers.schema.ts` — `CreateQuotationSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/issued-orders.schema.ts` — `CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
|
||||||
|
(invoices define the two schemas separately).
|
||||||
|
|
||||||
|
### 3. Backend — services
|
||||||
|
|
||||||
|
In each create/update service, persist the field inside the **existing transaction**:
|
||||||
|
|
||||||
|
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
|
||||||
|
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
|
||||||
|
- No other logic changes; sits alongside the existing header column assignments.
|
||||||
|
|
||||||
|
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
|
||||||
|
`src/services/invoices.service.ts`.
|
||||||
|
|
||||||
|
### 4. Backend — detail responses
|
||||||
|
|
||||||
|
The detail endpoints spread the document row. Decode the stored string back into a
|
||||||
|
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
|
||||||
|
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
|
||||||
|
`selected_custom_fields: number[]` to the corresponding detail interfaces in
|
||||||
|
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
|
||||||
|
|
||||||
|
### 5. PDF rendering
|
||||||
|
|
||||||
|
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
|
||||||
|
company-block `buildAddressLines()` with an optional parameter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
|
||||||
|
includes `i`. Behavior:
|
||||||
|
|
||||||
|
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
|
||||||
|
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
|
||||||
|
DIČ/`vat_id`) are **unaffected** — always built as today.
|
||||||
|
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
|
||||||
|
`Value`) and `field_order` ordering logic is preserved unchanged.
|
||||||
|
|
||||||
|
Only the **company/sender** block is filtered (that's where company custom fields render).
|
||||||
|
The customer block (offers/invoices) and supplier block (issued orders) keep their own
|
||||||
|
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
|
||||||
|
settings, and are not in scope.
|
||||||
|
|
||||||
|
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
|
||||||
|
and passes it into `buildAddressLines(settings, …, selected)`.
|
||||||
|
|
||||||
|
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
|
||||||
|
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
|
||||||
|
|
||||||
|
### 6. Frontend — shared picker component
|
||||||
|
|
||||||
|
New shared component under `src/admin/components/document/` (e.g.
|
||||||
|
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
|
||||||
|
don't fork three copies — per CLAUDE.md document-module conventions).
|
||||||
|
|
||||||
|
Props (shape): the parsed company custom fields (`{name, value}[]` from
|
||||||
|
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
|
||||||
|
`disabled` flag.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
|
||||||
|
so the user knows what each is.
|
||||||
|
- Bound to local state on the detail page, seeded from the document's
|
||||||
|
`selected_custom_fields`.
|
||||||
|
- Hidden/empty when the company has no custom fields defined.
|
||||||
|
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
|
||||||
|
isn't editable or is locked by another user, matching how the rest of the header fields
|
||||||
|
behave on that page.
|
||||||
|
- Included in the page's save payload as `selected_custom_fields: number[]`.
|
||||||
|
|
||||||
|
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
|
||||||
|
each already loads `companySettingsOptions()`, so the field definitions are available with
|
||||||
|
no new query.
|
||||||
|
|
||||||
|
Placement: in the editable header/meta area of each detail page, near the other
|
||||||
|
document-level settings (currency/language/etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
|
||||||
|
idx 0 idx 1 idx 2
|
||||||
|
|
||||||
|
Offer detail page → user ticks "Tel." and "Datová schránka"
|
||||||
|
→ save payload selected_custom_fields: [0, 2]
|
||||||
|
→ service stores quotations.selected_custom_fields = "[0,2]"
|
||||||
|
|
||||||
|
Offer PDF render
|
||||||
|
→ read column "[0,2]" → [0,2]
|
||||||
|
→ buildAddressLines(settings, …, [0,2])
|
||||||
|
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
|
||||||
|
→ sender block prints Tel. and Datová schránka only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
|
||||||
|
|
||||||
|
- Create/update each document with `selected_custom_fields` → column persists the JSON
|
||||||
|
string; empty/omitted → `NULL`.
|
||||||
|
- Detail response decodes to `number[]` (and `[]` for `NULL`).
|
||||||
|
- Schema: non-integer / negative entries rejected; omitted is valid.
|
||||||
|
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
|
||||||
|
selected custom field lines and still includes standard address lines; empty selection ⇒
|
||||||
|
no custom lines; standard lines unaffected.
|
||||||
|
- Regression: customer/supplier custom fields still render regardless of company selection.
|
||||||
|
|
||||||
|
## Migration & rollout
|
||||||
|
|
||||||
|
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
|
||||||
|
`app_test`.
|
||||||
|
- No data backfill (none-selected default is the intended post-ship state).
|
||||||
|
- No production PDF changes for already-archived documents.
|
||||||
|
|
||||||
|
## Affected files (reference)
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` (+ new migration folder)
|
||||||
|
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
|
||||||
|
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
|
||||||
|
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
|
||||||
|
- (detail route handlers, if decoding is done there rather than in the service)
|
||||||
|
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
|
||||||
|
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Status quick actions + project⇄order sync
|
||||||
|
|
||||||
|
**Date:** 2026-07-04 · **Status:** Approved (interactive design w/ owner) · **Scope:** offers, received orders, projects
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Status changes are inconsistent: the Invoices list has a quick chip action (click → confirm → paid),
|
||||||
|
but Offers/ReceivedOrders/Projects tables have inert chips — every change needs the detail page.
|
||||||
|
ProjectDetail edits status via a free combobox in the form (no transitions, no confirm), unlike
|
||||||
|
OfferDetail/OrderDetail's transition buttons. Projects have **no status machine at all** (free-text
|
||||||
|
column). Order→project completion sync exists one-way (`syncProjectStatus`), but finishing a
|
||||||
|
**project** leaves its linked order untouched.
|
||||||
|
|
||||||
|
## Decisions (owner)
|
||||||
|
|
||||||
|
1. **Chip opens a menu** of valid next states (not single-click-advance) → ConfirmDialog per pick,
|
||||||
|
danger styling + cascade notes on destructive/irreversible ones.
|
||||||
|
2. **Reopen allowed**: `dokonceny/zruseny → aktivni` (projects), `dokoncena/stornovana → v_realizaci`
|
||||||
|
(received orders). **Reopening never cascades** to the linked record.
|
||||||
|
3. **Sync scope: finish + cancel, both directions.** Project `dokonceny` ⇄ order `dokoncena`;
|
||||||
|
project `zruseny` ⇄ order `stornovana`.
|
||||||
|
4. Offers quick menu: `Aktivovat` (draft — same number-assignment + PDF-archive semantics as the
|
||||||
|
detail), `Zneplatnit` (danger), and **"Vytvořit objednávku…" which opens the existing
|
||||||
|
create-order modal** (never a raw label flip to `ordered` — that state is owned by the
|
||||||
|
create-order flow).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Projects gain a status machine** (`src/services/projects.service.ts`):
|
||||||
|
`VALID_TRANSITIONS = { aktivni: [dokonceny, zruseny], dokonceny: [aktivni], zruseny: [aktivni] }`.
|
||||||
|
`updateProject` validates status changes (token `invalid_transition` → Czech 400 in the route);
|
||||||
|
a legacy/unknown current status may transition to any canonical value (tolerant). `getProject`
|
||||||
|
returns `valid_transitions` (same contract as offers/orders).
|
||||||
|
|
||||||
|
**Project → order cascade** (new), inside one transaction with the project update:
|
||||||
|
|
||||||
|
- project → `dokonceny` ⇒ linked order → `dokoncena` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- project → `zruseny` ⇒ linked order → `stornovana` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- reopen ⇒ no order change; completed/cancelled orders are never resurrected by a cascade
|
||||||
|
- direct `tx` write (no service recursion); sibling projects of the same order are NOT touched
|
||||||
|
(multi-project orders are a rare schema possibility, not a flow; avoid surprise mass updates)
|
||||||
|
- the service returns what cascaded so the route writes an audit row for the order change too
|
||||||
|
|
||||||
|
**Received orders** (`src/services/orders.service.ts`):
|
||||||
|
|
||||||
|
- `VALID_TRANSITIONS` gains reopen: `dokoncena: [v_realizaci]`, `stornovana: [v_realizaci]`
|
||||||
|
- `syncProjectStatus` becomes reopen-aware: given the previous status, a transition OUT of a
|
||||||
|
terminal state (reopen) skips project sync entirely (decision 2). Forward transitions keep the
|
||||||
|
existing mapping (v_realizaci→aktivni no-op in practice, dokoncena→dokonceny,
|
||||||
|
stornovana→zruseny), and the cascaded project change now gets an audit row.
|
||||||
|
- item/section edit guards stay status-based, so a reopened order is editable again (intended).
|
||||||
|
|
||||||
|
No DB migration (status columns are strings already).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Shared `StatusChipMenu`** (`src/admin/components/StatusChipMenu.tsx`): renders a `StatusChip`;
|
||||||
|
click opens a small MUI `Menu` of actions; each action either opens a `ConfirmDialog`
|
||||||
|
(danger variant + `loading` during the request; cascade note in the message) or runs a plain
|
||||||
|
`onClick` (the modal-opening offer item). Hidden affordance fixed via `title` tooltip. Chip is
|
||||||
|
plain (non-clickable) when the user lacks the edit permission or no actions exist.
|
||||||
|
|
||||||
|
**Offers list**: draft → `Aktivovat` (confirm notes the number assignment; after success the same
|
||||||
|
fire-and-forget PDF-archive call the detail does); active → `Vytvořit objednávku…` (opens the
|
||||||
|
existing modal) + `Zneplatnit` (danger); ordered → `Zneplatnit` (danger).
|
||||||
|
|
||||||
|
**ReceivedOrders list**: prijata → `Zahájit realizaci`, `Stornovat` (danger, note: linked project
|
||||||
|
will be cancelled); v_realizaci → `Dokončit` (note: linked project will be completed),
|
||||||
|
`Stornovat` (danger); dokoncena/stornovana → `Obnovit` (reopen, no cascade note).
|
||||||
|
|
||||||
|
**Projects list**: aktivni → `Dokončit` (note when `order_id` present: linked order will be
|
||||||
|
completed), `Zrušit` (danger, order-storno note); dokonceny/zruseny → `Obnovit`.
|
||||||
|
|
||||||
|
**ProjectDetail**: status `Select` removed from the form (and status removed from the save
|
||||||
|
payload); header gains transition buttons rendered from `valid_transitions`
|
||||||
|
(`Dokončit projekt` / `Zrušit projekt` danger / `Obnovit projekt`), each via ConfirmDialog with
|
||||||
|
cascade notes, as a status-only PUT with its own mutation
|
||||||
|
(invalidate: projects, orders, offers, invoices, warehouse). OrderDetail keeps its pattern;
|
||||||
|
its transition label shows `Obnovit` when reopening from a terminal state.
|
||||||
|
|
||||||
|
Invalidation for all new status mutations: `["orders","offers","projects","invoices"]`
|
||||||
|
(+`["warehouse"]` on project mutations, matching the page's existing set).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Service-level (real `app_test` DB): project transition validation (all legal/illegal edges incl.
|
||||||
|
legacy-status tolerance), project→order cascade both mappings + the guard (no cascade onto
|
||||||
|
dokoncena/stornovana orders), reopen-no-cascade both directions, order reopen transitions, and
|
||||||
|
regression: order→project sync still fires on forward transitions.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Offer machine changes (unchanged: draft→active→ordered/invalidated), issued orders, sibling-project
|
||||||
|
propagation, ReceivedOrders detail-page button changes beyond the automatic reopen button.
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<title>Admin</title>
|
<title>BOHA Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.38",
|
"version": "2.4.42",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.38",
|
"version": "2.4.42",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.38",
|
"version": "2.4.42",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
@@ -239,6 +239,7 @@ model invoices {
|
|||||||
billing_text String? @db.VarChar(500)
|
billing_text String? @db.VarChar(500)
|
||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
invoice_items invoice_items[]
|
invoice_items invoice_items[]
|
||||||
@@ -393,6 +394,7 @@ model issued_orders {
|
|||||||
language String? @default("cs") @db.VarChar(5)
|
language String? @default("cs") @db.VarChar(5)
|
||||||
order_text String? @db.VarChar(500)
|
order_text String? @db.VarChar(500)
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
@@ -527,6 +529,7 @@ model quotations {
|
|||||||
status String @default("active") @db.VarChar(20)
|
status String @default("active") @db.VarChar(20)
|
||||||
scope_title String? @db.VarChar(500)
|
scope_title String? @db.VarChar(500)
|
||||||
scope_description String? @db.Text
|
scope_description String? @db.Text
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
locked_by Int?
|
locked_by Int?
|
||||||
locked_at DateTime? @db.DateTime(0)
|
locked_at DateTime? @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
|
|
||||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||||
const expected = (await previewIssuedOrderNumber()).number;
|
const expected = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
@@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
if ("error" in draft)
|
if ("error" in draft)
|
||||||
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const order = await mkIssued({ status: "sent" });
|
const order = await mkIssued({
|
||||||
|
status: "sent",
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
expect(order.po_number).toBe(before);
|
expect(order.po_number).toBe(before);
|
||||||
expect(order.status).toBe("sent");
|
expect(order.status).toBe("sent");
|
||||||
});
|
});
|
||||||
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
expect("error" in ok).toBe(false);
|
expect("error" in ok).toBe(false);
|
||||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("status-only transition payloads still work when not editable", async () => {
|
it("status-only transition payloads still work when not editable", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
const res = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||||
expect("error" in res && res.error).toBe("supplier_not_found");
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finalizes a sections-only order (no items) successfully", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, {
|
||||||
|
status: "sent",
|
||||||
|
items: [],
|
||||||
|
sections: [
|
||||||
|
{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.status).toBe("sent");
|
||||||
|
expect(row!.po_number).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects finalizing a completely blank order (no items, no sections)", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, {
|
||||||
|
status: "sent",
|
||||||
|
items: [],
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
expect("error" in res && res.error).toBe("empty_document");
|
||||||
|
// Stays a draft — the finalize was rejected before any write.
|
||||||
|
const row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.status).toBe("draft");
|
||||||
|
expect(row!.po_number).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects creating a non-draft order with no content", async () => {
|
||||||
|
const res = await createIssuedOrder({ status: "sent" });
|
||||||
|
expect("error" in res && res.error).toBe("empty_document");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrder", () => {
|
describe("getIssuedOrder", () => {
|
||||||
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
|
|||||||
|
|
||||||
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
||||||
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
|
|
||||||
@@ -543,7 +590,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
|||||||
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
// The IssuedOrderDetail transition flushes unsaved edits by sending the
|
||||||
// full payload + status in ONE PUT while the order is still editable
|
// full payload + status in ONE PUT while the order is still editable
|
||||||
// (sent). The server must apply the items AND the transition together.
|
// (sent). The server must apply the items AND the transition together.
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
|
|
||||||
const res = await app!.inject({
|
const res = await app!.inject({
|
||||||
@@ -574,7 +623,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
|
||||||
const order = await mkIssued({});
|
const order = await mkIssued({
|
||||||
|
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
|
||||||
|
});
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
|
|
||||||
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Odběratel");
|
expect(html).toContain("Odběratel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits the items table and total when there are no items (sections-only order)", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
[],
|
||||||
|
supplier,
|
||||||
|
{ company_name: "Naše firma" },
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
[{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" }],
|
||||||
|
);
|
||||||
|
// The section renders…
|
||||||
|
expect(html).toContain("Rozsah prací");
|
||||||
|
// …but the items table, the billing heading and the total row do not.
|
||||||
|
expect(html).not.toContain('class="items"');
|
||||||
|
expect(html).not.toContain("Celkem bez DPH");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the structured supplier address plus IČO and DIČ", () => {
|
it("renders the structured supplier address plus IČO and DIČ", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
|
|||||||
@@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => {
|
|||||||
expect(withoutDetail!.attachment_name).toBeNull();
|
expect(withoutDetail!.attachment_name).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PUT /:id accepts the canonical storno token through the Zod layer", async () => {
|
||||||
|
// Regression: UpdateOrderSchema carried a phantom "zrusena" enum member
|
||||||
|
// until 2026-07, so { status: "stornovana" } 400ed at parseBody with a
|
||||||
|
// raw English Zod message. Service-level tests bypass the schema — this
|
||||||
|
// must stay a route-level test.
|
||||||
|
const customer = await makeCustomer();
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
customer_id: customer.id,
|
||||||
|
create_project: false,
|
||||||
|
});
|
||||||
|
if (!("id" in res)) failResult(res);
|
||||||
|
createdOrderIds.push(res.id!);
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/api/admin/orders/${res.id}`,
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { status: "stornovana" },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
const row = await prisma.orders.findUnique({ where: { id: res.id! } });
|
||||||
|
expect(row!.status).toBe("stornovana");
|
||||||
|
});
|
||||||
|
|
||||||
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||||
const { withId, withoutId } = await makeOrderPair();
|
const { withId, withoutId } = await makeOrderPair();
|
||||||
const headers = { authorization: `Bearer ${adminToken}` };
|
const headers = { authorization: `Bearer ${adminToken}` };
|
||||||
|
|||||||
301
src/__tests__/project-order-status-sync.test.ts
Normal file
301
src/__tests__/project-order-status-sync.test.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { describe, it, expect, afterEach, afterAll } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import {
|
||||||
|
updateProject,
|
||||||
|
getProject,
|
||||||
|
VALID_TRANSITIONS as PROJECT_VALID_TRANSITIONS,
|
||||||
|
} from "../services/projects.service";
|
||||||
|
import { updateOrder } from "../services/orders.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirectional project⇄order status sync (spec 2026-07-04):
|
||||||
|
* - projects gain a status machine (aktivni ⇄ dokonceny/zruseny) with
|
||||||
|
* legacy-status tolerance,
|
||||||
|
* - finishing/cancelling a project cascades onto its linked OPEN order
|
||||||
|
* (prijata/v_realizaci) — terminal orders are never touched,
|
||||||
|
* - reopen (project → aktivni, order → v_realizaci) is now a legal
|
||||||
|
* transition and NEVER cascades,
|
||||||
|
* - regression: the existing order→project forward sync stays intact.
|
||||||
|
*
|
||||||
|
* Real app_test DB via the service layer (suite convention). Fixtures use a
|
||||||
|
* unique prefix; projects are created without project_number so getProject's
|
||||||
|
* NAS folder probe is skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const N = "posync_";
|
||||||
|
|
||||||
|
const createdProjectIds: number[] = [];
|
||||||
|
const createdOrderIds: number[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
async function mkOrder(status: string) {
|
||||||
|
const order = await prisma.orders.create({
|
||||||
|
data: { order_number: `${N}${Date.now()}_${seq++}`, status },
|
||||||
|
});
|
||||||
|
createdOrderIds.push(order.id);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkProject(status: string, orderId?: number) {
|
||||||
|
const project = await prisma.projects.create({
|
||||||
|
data: { name: `${N}project_${seq++}`, status, order_id: orderId ?? null },
|
||||||
|
});
|
||||||
|
createdProjectIds.push(project.id);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK-safe order: projects reference orders (Restrict), so projects go first.
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.projects.deleteMany({
|
||||||
|
where: { id: { in: createdProjectIds } },
|
||||||
|
});
|
||||||
|
await prisma.orders.deleteMany({ where: { id: { in: createdOrderIds } } });
|
||||||
|
createdProjectIds.length = 0;
|
||||||
|
createdOrderIds.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertOk<T>(
|
||||||
|
res: T,
|
||||||
|
): asserts res is Exclude<T, null | { error: unknown }> {
|
||||||
|
if (!res || (typeof res === "object" && "error" in res)) {
|
||||||
|
throw new Error(`expected success, got ${JSON.stringify(res)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project → order cascade (updateProject)", () => {
|
||||||
|
it("aktivni→dokonceny completes a linked v_realizaci order and returns synced_order", async () => {
|
||||||
|
const order = await mkOrder("v_realizaci");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toEqual({
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
from: "v_realizaci",
|
||||||
|
to: "dokoncena",
|
||||||
|
});
|
||||||
|
expect(res.old_status).toBe("aktivni");
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("dokoncena");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aktivni→zruseny cancels a linked prijata order", async () => {
|
||||||
|
const order = await mkOrder("prijata");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "zruseny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toEqual({
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
from: "prijata",
|
||||||
|
to: "stornovana",
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("stornovana");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guard: never resurrects a terminal order (stornovana stays stornovana)", async () => {
|
||||||
|
const order = await mkOrder("stornovana");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("stornovana");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopen (dokonceny→aktivni) never touches the linked order", async () => {
|
||||||
|
const order = await mkOrder("dokoncena");
|
||||||
|
const project = await mkProject("dokonceny", order.id);
|
||||||
|
|
||||||
|
const res = await updateProject(project.id, { status: "aktivni" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("dokoncena");
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("aktivni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no linked order → no cascade, synced_order null", async () => {
|
||||||
|
const project = await mkProject("aktivni");
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
expect(res.synced_order).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project status machine (updateProject validation)", () => {
|
||||||
|
it("rejects dokonceny→zruseny with the invalid_transition token", async () => {
|
||||||
|
const project = await mkProject("dokonceny");
|
||||||
|
const res = await updateProject(project.id, { status: "zruseny" });
|
||||||
|
expect(res && "error" in res && res.error).toBe("invalid_transition");
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
currentStatus: "dokonceny",
|
||||||
|
newStatus: "zruseny",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing was written.
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(fresh?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy tolerance: an unknown current status may move to any canonical target", async () => {
|
||||||
|
const project = await mkProject("stary");
|
||||||
|
const res = await updateProject(project.id, { status: "dokonceny" });
|
||||||
|
assertOk(res);
|
||||||
|
const fresh = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(fresh?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy tolerance still rejects a non-canonical target", async () => {
|
||||||
|
const project = await mkProject("stary");
|
||||||
|
const res = await updateProject(project.id, { status: "nesmysl" });
|
||||||
|
expect(res && "error" in res && res.error).toBe("invalid_transition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status-unchanged payloads pass without transition validation", async () => {
|
||||||
|
const project = await mkProject("dokonceny");
|
||||||
|
const res = await updateProject(project.id, {
|
||||||
|
status: "dokonceny",
|
||||||
|
notes: `${N}note`,
|
||||||
|
});
|
||||||
|
assertOk(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("order reopen + order → project sync (updateOrder)", () => {
|
||||||
|
it("dokoncena→v_realizaci is now valid and skips project sync (reopen)", async () => {
|
||||||
|
const order = await mkOrder("dokoncena");
|
||||||
|
const project = await mkProject("dokonceny", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "v_realizaci" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toEqual([]);
|
||||||
|
|
||||||
|
const freshOrder = await prisma.orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(freshOrder?.status).toBe("v_realizaci");
|
||||||
|
// Reopen never cascades — the project keeps its terminal status.
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stornovana→v_realizaci is valid too; other exits from terminal stay rejected", async () => {
|
||||||
|
const order = await mkOrder("stornovana");
|
||||||
|
const res = await updateOrder(order.id, { status: "v_realizaci" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
|
||||||
|
const back = await mkOrder("dokoncena");
|
||||||
|
const rejected = await updateOrder(back.id, { status: "prijata" });
|
||||||
|
expect("error" in rejected).toBe(true);
|
||||||
|
if ("error" in rejected) {
|
||||||
|
expect(rejected.status).toBe(400);
|
||||||
|
expect(rejected.error).toContain("Neplatný přechod stavu");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regression: v_realizaci→dokoncena still completes the linked project and reports it", async () => {
|
||||||
|
const order = await mkOrder("v_realizaci");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "dokoncena" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toEqual([
|
||||||
|
{
|
||||||
|
id: project.id,
|
||||||
|
project_number: null,
|
||||||
|
from: "aktivni",
|
||||||
|
to: "dokonceny",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("dokonceny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("regression: prijata→stornovana still cancels the linked project", async () => {
|
||||||
|
const order = await mkOrder("prijata");
|
||||||
|
const project = await mkProject("aktivni", order.id);
|
||||||
|
|
||||||
|
const res = await updateOrder(order.id, { status: "stornovana" });
|
||||||
|
expect("error" in res).toBe(false);
|
||||||
|
if ("error" in res) throw new Error(res.error);
|
||||||
|
expect(res.synced_projects).toHaveLength(1);
|
||||||
|
|
||||||
|
const freshProject = await prisma.projects.findUnique({
|
||||||
|
where: { id: project.id },
|
||||||
|
});
|
||||||
|
expect(freshProject?.status).toBe("zruseny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getProject valid_transitions", () => {
|
||||||
|
it("matches the machine for canonical statuses", async () => {
|
||||||
|
const active = await mkProject("aktivni");
|
||||||
|
expect((await getProject(active.id))?.valid_transitions).toEqual(
|
||||||
|
PROJECT_VALID_TRANSITIONS["aktivni"],
|
||||||
|
);
|
||||||
|
expect((await getProject(active.id))?.valid_transitions).toEqual([
|
||||||
|
"dokonceny",
|
||||||
|
"zruseny",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const done = await mkProject("dokonceny");
|
||||||
|
expect((await getProject(done.id))?.valid_transitions).toEqual(["aktivni"]);
|
||||||
|
|
||||||
|
const cancelled = await mkProject("zruseny");
|
||||||
|
expect((await getProject(cancelled.id))?.valid_transitions).toEqual([
|
||||||
|
"aktivni",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers all canonical targets for a legacy/unknown status", async () => {
|
||||||
|
const legacy = await mkProject("stary");
|
||||||
|
expect((await getProject(legacy.id))?.valid_transitions).toEqual([
|
||||||
|
"aktivni",
|
||||||
|
"dokonceny",
|
||||||
|
"zruseny",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/__tests__/selected-custom-fields.test.ts
Normal file
129
src/__tests__/selected-custom-fields.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
import { afterAll } from "vitest";
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf";
|
||||||
|
import type { OfferForPdf } from "../routes/admin/offers-pdf";
|
||||||
|
import type { company_settings } from "@prisma/client";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offer PDF prints only selected company custom fields", () => {
|
||||||
|
// Two company custom fields; the document selects only index 0.
|
||||||
|
const settings = {
|
||||||
|
company_name: "Test s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Email", value: "selected@example.com", showLabel: false },
|
||||||
|
{ name: "Web", value: "notselected.example.com", showLabel: false },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
logo_data: null,
|
||||||
|
} as unknown as company_settings;
|
||||||
|
|
||||||
|
const baseQuotation = {
|
||||||
|
quotation_number: "TEST-2098-001",
|
||||||
|
language: "EN",
|
||||||
|
currency: "EUR",
|
||||||
|
valid_until: new Date("2098-01-01"),
|
||||||
|
project_code: null,
|
||||||
|
created_at: new Date("2098-01-01"),
|
||||||
|
customers: { name: "Cust" },
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the selected custom field and omits the non-selected one", () => {
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints NO company custom fields when the document selects none", () => {
|
||||||
|
// The company call site always passes a parsed array; a null column
|
||||||
|
// decodes to [] → empty selection → no custom fields on the document.
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: null,
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips";
|
|||||||
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
// WHOLE filtered set (not one 25-row page), honors the month filter in
|
||||||
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
|
||||||
// non-managers to their own trips exactly like the list does.
|
// non-managers to their own trips exactly like the list does.
|
||||||
|
// 3. POST /trips rejects a non-manager filing a trip under someone else's
|
||||||
|
// user_id with an explicit Czech 403 (impersonation gap), while
|
||||||
|
// self-creates and manager on-behalf creates keep working.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
/** Note-prefix for test-created rows so cleanup never touches real data. */
|
||||||
@@ -66,6 +69,18 @@ async function authGet(path: string, token: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function authPost(path: string, token: string, body: unknown) {
|
||||||
|
return app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: path,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: body as object,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function authPut(path: string, token: string, body: unknown) {
|
async function authPut(path: string, token: string, body: unknown) {
|
||||||
return app.inject({
|
return app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => {
|
|||||||
expect(filtered.json().data.count).toBe(2);
|
expect(filtered.json().data.count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/trips — on-behalf authorization", () => {
|
||||||
|
function postBody(params: { userId?: number; note: string }) {
|
||||||
|
return {
|
||||||
|
vehicle_id: vehicleAId,
|
||||||
|
...(params.userId !== undefined ? { user_id: params.userId } : {}),
|
||||||
|
trip_date: "2098-11-10",
|
||||||
|
start_km: 100,
|
||||||
|
end_km: 150,
|
||||||
|
route_from: "Praha",
|
||||||
|
route_to: "Brno",
|
||||||
|
is_business: true,
|
||||||
|
notes: `${N}${params.note}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects a non-manager filing a trip under another user's id with 403", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ userId: adminUserId, note: "authz_spoof" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: "Nemáte oprávnění zadat jízdu za jiného uživatele",
|
||||||
|
});
|
||||||
|
|
||||||
|
// …and the trip must NOT have been created.
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_spoof` },
|
||||||
|
});
|
||||||
|
expect(created).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a non-manager create a trip without user_id (defaults to self)", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ note: "authz_self_implicit" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json().success).toBe(true);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_self_implicit` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a non-manager create a trip with their OWN user_id", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
scopeToken,
|
||||||
|
postBody({ userId: scopeUserId, note: "authz_self_explicit" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_self_explicit` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a manager/admin create a trip on behalf of another user", async () => {
|
||||||
|
const res = await authPost(
|
||||||
|
"/api/admin/trips",
|
||||||
|
adminToken,
|
||||||
|
postBody({ userId: scopeUserId, note: "authz_on_behalf" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const created = await prisma.trips.findFirst({
|
||||||
|
where: { notes: `${N}authz_on_behalf` },
|
||||||
|
});
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created!.user_id).toBe(scopeUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { lazyWithReload } from "./utils/lazyWithReload";
|
|||||||
import MuiProvider from "./ui/MuiProvider";
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
import { LoadingState } from "./ui";
|
import { LoadingState } from "./ui";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
|
||||||
|
|
||||||
|
const Dashboard = lazyWithReload(() => import("./pages/Dashboard"));
|
||||||
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
const Odin = lazyWithReload(() => import("./pages/Odin"));
|
||||||
const Users = lazyWithReload(() => import("./pages/Users"));
|
const Users = lazyWithReload(() => import("./pages/Users"));
|
||||||
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
const Attendance = lazyWithReload(() => import("./pages/Attendance"));
|
||||||
|
|||||||
148
src/admin/components/StatusChipMenu.tsx
Normal file
148
src/admin/components/StatusChipMenu.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState, type ComponentProps } from "react";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { StatusChip, ConfirmDialog } from "../ui";
|
||||||
|
|
||||||
|
/** One entry in a {@link StatusChipMenu}. */
|
||||||
|
export interface StatusChipAction {
|
||||||
|
/** Stable identifier, e.g. the target status. */
|
||||||
|
key: string;
|
||||||
|
/** Menu item text, e.g. "Dokončit". */
|
||||||
|
label: string;
|
||||||
|
/** Red menu-item text + danger ConfirmDialog variant. */
|
||||||
|
danger?: boolean;
|
||||||
|
/**
|
||||||
|
* Confirmation dialog content. When omitted the action fires directly from
|
||||||
|
* the menu without confirmation (used by "Vytvořit objednávku…", which
|
||||||
|
* opens its own modal).
|
||||||
|
*/
|
||||||
|
confirm?: { title: string; message: string; confirmText: string };
|
||||||
|
/**
|
||||||
|
* Executed on pick (after confirmation when `confirm` is set). Awaited:
|
||||||
|
* while pending the ConfirmDialog shows its loading state and cannot be
|
||||||
|
* closed; on resolve the dialog closes; on rejection it stays open (the
|
||||||
|
* caller is responsible for toasting the error).
|
||||||
|
*/
|
||||||
|
onAction: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusChipMenuProps {
|
||||||
|
/** Chip text (current status label). */
|
||||||
|
label: string;
|
||||||
|
/** Chip color, same palette as {@link StatusChip}. */
|
||||||
|
color: ComponentProps<typeof StatusChip>["color"];
|
||||||
|
/** Quick actions. Empty/undefined renders a plain non-clickable chip. */
|
||||||
|
actions?: StatusChipAction[];
|
||||||
|
/** Force a plain chip (e.g. the user lacks the edit permission). */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Tooltip on the clickable chip. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status chip with a quick-action menu, shared by the list pages
|
||||||
|
* (offers / received orders / projects).
|
||||||
|
*
|
||||||
|
* Clicking the chip (stopPropagation, so row navigation never fires) opens a
|
||||||
|
* dense MUI Menu of the valid next actions. Picking one closes the menu and
|
||||||
|
* either fires `onAction` directly (no `confirm` given) or opens the single
|
||||||
|
* shared ConfirmDialog instance — danger variant for destructive actions,
|
||||||
|
* `loading` while the awaited `onAction` is pending, staying open when it
|
||||||
|
* rejects per app convention (the caller toasts errors).
|
||||||
|
*
|
||||||
|
* With no actions — or with `disabled` — it renders a plain non-clickable
|
||||||
|
* StatusChip without a tooltip.
|
||||||
|
*/
|
||||||
|
export default function StatusChipMenu({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
actions,
|
||||||
|
disabled = false,
|
||||||
|
title = "Změnit stav",
|
||||||
|
}: StatusChipMenuProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [pending, setPending] = useState<StatusChipAction | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
if (disabled || !actions || actions.length === 0) {
|
||||||
|
return <StatusChip label={label} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = (action: StatusChipAction) => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
if (action.confirm) {
|
||||||
|
setPending(action);
|
||||||
|
} else {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await action.onAction();
|
||||||
|
} catch {
|
||||||
|
// Expected: the caller toasts its own errors; nothing to close here.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!pending) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await pending.onAction();
|
||||||
|
// Success → close; ConfirmDialog freezes its content through the fade.
|
||||||
|
setPending(null);
|
||||||
|
} catch {
|
||||||
|
// Expected: rejection keeps the dialog open; the caller toasts the error.
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StatusChip
|
||||||
|
label={label}
|
||||||
|
color={color}
|
||||||
|
title={title}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={Boolean(anchorEl)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||||
|
slotProps={{ list: { dense: true } }}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<MenuItem
|
||||||
|
key={action.key}
|
||||||
|
// stopPropagation: MUI portals re-bubble synthetic events to
|
||||||
|
// React-tree ancestors — a future onRowClick row must not fire.
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePick(action);
|
||||||
|
}}
|
||||||
|
sx={action.danger ? { color: "error.main" } : undefined}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pending !== null}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onConfirm={() => void handleConfirm()}
|
||||||
|
title={pending?.confirm?.title ?? ""}
|
||||||
|
message={pending?.confirm?.message ?? ""}
|
||||||
|
confirmText={pending?.confirm?.confirmText}
|
||||||
|
confirmVariant={pending?.danger ? "danger" : "primary"}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/admin/components/TitleSync.tsx
Normal file
42
src/admin/components/TitleSync.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { menuSections, isItemActive } from "../ui/navData";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null-rendering helper that keeps the browser tab title in sync with the
|
||||||
|
* active navigation item. Scans menuSections in order (first match wins) via
|
||||||
|
* the same isItemActive logic the sidebar uses, so detail routes covered by
|
||||||
|
* matchPrefix (e.g. /offers/123) inherit their section item's label.
|
||||||
|
* No cleanup on unmount — the last title simply persists.
|
||||||
|
*/
|
||||||
|
// Labels reused across sections ("Záznam", "Moje historie", "Správa",
|
||||||
|
// "Přehled") would produce identical tab titles — qualify those with their
|
||||||
|
// section label so /attendance and /trips tabs stay distinguishable.
|
||||||
|
const labelCounts = new Map<string, number>();
|
||||||
|
for (const section of menuSections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
labelCounts.set(item.label, (labelCounts.get(item.label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TitleSync() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const section of menuSections) {
|
||||||
|
const item = section.items.find((candidate) =>
|
||||||
|
isItemActive(candidate, pathname),
|
||||||
|
);
|
||||||
|
if (item) {
|
||||||
|
const ambiguous = (labelCounts.get(item.label) ?? 0) > 1;
|
||||||
|
document.title = ambiguous
|
||||||
|
? `${item.label} – ${section.label} · BOHA`
|
||||||
|
: `${item.label} · BOHA`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.title = "BOHA Admin";
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import DialogActions from "@mui/material/DialogActions";
|
|||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
|
import { USER_INVALIDATE } from "../../lib/queries/users";
|
||||||
import { iconBadgeSx } from "../../theme";
|
import { iconBadgeSx } from "../../theme";
|
||||||
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
import { Card, Button, Modal, Field, TextField } from "../../ui";
|
||||||
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
import useDialogScrollLock from "../../ui/useDialogScrollLock";
|
||||||
@@ -142,6 +143,9 @@ export default function DashProfile({
|
|||||||
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
|
||||||
queryKey: ["totp", "qr", totpQrUri],
|
queryKey: ["totp", "qr", totpQrUri],
|
||||||
enabled: !!totpQrUri,
|
enabled: !!totpQrUri,
|
||||||
|
// Purely local QR generation — the global "server data" error toast would
|
||||||
|
// misattribute a failure here; the totpQrFailed inline message covers it.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
// The URI embeds the TOTP secret — drop it from the cache as soon as the
|
||||||
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
|
||||||
@@ -221,9 +225,13 @@ export default function DashProfile({
|
|||||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||||
});
|
});
|
||||||
// Refresh anything keyed on the current user's data so stale views
|
// Refresh anything keyed on the current user's data so stale views
|
||||||
// (dashboard widgets, user lists) pick up the edited profile.
|
// pick up the edited profile — a self-rename must also refresh the
|
||||||
|
// domains embedding the display name (USER_INVALIDATE), not just the
|
||||||
|
// dashboard and user list.
|
||||||
|
for (const key of USER_INVALIDATE) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [key] });
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
||||||
// before the success toast appears, so the toast doesn't flash over the
|
// before the success toast appears, so the toast doesn't flash over the
|
||||||
|
|||||||
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no printable custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -492,6 +492,12 @@ interface DocumentItemsEditorProps {
|
|||||||
itemDescriptionMaxLength?: number;
|
itemDescriptionMaxLength?: number;
|
||||||
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
|
||||||
templatesSlot?: ReactNode;
|
templatesSlot?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Allow removing every row so the list can be empty (issued orders may be
|
||||||
|
* issued by sections alone). Default false keeps the offers/invoices rule of
|
||||||
|
* at least one item.
|
||||||
|
*/
|
||||||
|
allowEmpty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -509,6 +515,7 @@ export default function DocumentItemsEditor({
|
|||||||
showDiscount = false,
|
showDiscount = false,
|
||||||
itemDescriptionMaxLength = 5000,
|
itemDescriptionMaxLength = 5000,
|
||||||
templatesSlot,
|
templatesSlot,
|
||||||
|
allowEmpty = false,
|
||||||
}: DocumentItemsEditorProps) {
|
}: DocumentItemsEditorProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
@@ -538,10 +545,13 @@ export default function DocumentItemsEditor({
|
|||||||
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
const addItem = () => onChange([...items, emptyDocumentItem()]);
|
||||||
|
|
||||||
const removeItem = (index: number) => {
|
const removeItem = (index: number) => {
|
||||||
if (items.length <= 1) return;
|
if (!allowEmpty && items.length <= 1) return;
|
||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When allowEmpty, even the last row is deletable (list may go to zero).
|
||||||
|
const canDeleteRow = allowEmpty || items.length > 1;
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -608,7 +618,7 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
showDiscount={showDiscount}
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
@@ -666,7 +676,7 @@ export default function DocumentItemsEditor({
|
|||||||
index={index}
|
index={index}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
canDelete={items.length > 1}
|
canDelete={canDeleteRow}
|
||||||
showIncludedInTotal={showIncludedInTotal}
|
showIncludedInTotal={showIncludedInTotal}
|
||||||
showDiscount={showDiscount}
|
showDiscount={showDiscount}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ export default function OdinChat() {
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const threadRef = useRef<HTMLDivElement>(null);
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
const seededId = useRef<number | null>(null);
|
const seededId = useRef<number | null>(null);
|
||||||
|
// Drag & drop over the chat column. The depth counter pairs enter/leave
|
||||||
|
// events bubbling from children so the overlay doesn't flicker mid-drag.
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const dragDepth = useRef(0);
|
||||||
|
|
||||||
// Below md the conversation list collapses into a slide-in drawer.
|
// Below md the conversation list collapses into a slide-in drawer.
|
||||||
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
|
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
|
||||||
@@ -177,17 +181,63 @@ export default function OdinChat() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Attaching only stages files; nothing is sent until Odeslat.
|
// Attaching only stages files; nothing is sent until Odeslat.
|
||||||
const onFiles = (files: FileList | null) => {
|
const stageFiles = (files: File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setAttachments((a) => [
|
setAttachments((a) => [
|
||||||
...a,
|
...a,
|
||||||
...Array.from(files).map((file) => ({ id: nextUid(), file })),
|
...files.map((file) => ({ id: nextUid(), file })),
|
||||||
]);
|
]);
|
||||||
|
};
|
||||||
|
const onFiles = (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
stageFiles(Array.from(files));
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
};
|
};
|
||||||
const removeAttachment = (id: string) =>
|
const removeAttachment = (id: string) =>
|
||||||
setAttachments((a) => a.filter((s) => s.id !== id));
|
setAttachments((a) => a.filter((s) => s.id !== id));
|
||||||
|
|
||||||
|
// Drag & drop anywhere over the chat column stages files exactly like the
|
||||||
|
// paperclip (same accept filter as the file input). Only file drags react —
|
||||||
|
// text/link drags pass through untouched — and nothing stages while busy.
|
||||||
|
const isAccepted = (f: File) =>
|
||||||
|
f.type === "application/pdf" || f.type.startsWith("image/");
|
||||||
|
const dragHasFiles = (e: React.DragEvent) =>
|
||||||
|
Array.from(e.dataTransfer.types).includes("Files");
|
||||||
|
|
||||||
|
const onDragEnter = (e: React.DragEvent) => {
|
||||||
|
if (busy || !dragHasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current += 1;
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
const onDragOver = (e: React.DragEvent) => {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
// preventDefault permits the drop (and keeps the browser from replacing
|
||||||
|
// the app with the dropped file); while busy the drop itself is refused.
|
||||||
|
e.preventDefault();
|
||||||
|
if (busy) e.dataTransfer.dropEffect = "none";
|
||||||
|
};
|
||||||
|
const onDragLeave = () => {
|
||||||
|
if (dragDepth.current === 0) return;
|
||||||
|
dragDepth.current -= 1;
|
||||||
|
if (dragDepth.current === 0) setDragOver(false);
|
||||||
|
};
|
||||||
|
const onDrop = (e: React.DragEvent) => {
|
||||||
|
if (!dragHasFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth.current = 0;
|
||||||
|
setDragOver(false);
|
||||||
|
if (busy) return;
|
||||||
|
const dropped = Array.from(e.dataTransfer.files);
|
||||||
|
const accepted = dropped.filter(isAccepted);
|
||||||
|
if (accepted.length === 0) {
|
||||||
|
// Nothing usable — hint instead of silently doing nothing.
|
||||||
|
if (dropped.length > 0) alert.info("Podporované soubory: PDF a obrázky");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stageFiles(accepted);
|
||||||
|
};
|
||||||
|
|
||||||
const onSelect = (id: number) => {
|
const onSelect = (id: number) => {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
if (id === activeId || busy) return;
|
if (id === activeId || busy) return;
|
||||||
@@ -467,9 +517,14 @@ export default function OdinChat() {
|
|||||||
sidebar
|
sidebar
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat column */}
|
{/* Chat column — also the drop target for invoice files (overlay below). */}
|
||||||
<Box
|
<Box
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
sx={{
|
sx={{
|
||||||
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -557,11 +612,14 @@ export default function OdinChat() {
|
|||||||
{review.length > 0 && (
|
{review.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
maxHeight: "35vh",
|
maxHeight: "40vh",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
// Flex children shrink by default — with 2+ cards they'd get
|
||||||
|
// compressed to fit instead of overflowing into the scrollbar.
|
||||||
|
"& > *": { flexShrink: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{review.map((inv) => (
|
{review.map((inv) => (
|
||||||
@@ -591,6 +649,63 @@ export default function OdinChat() {
|
|||||||
onRemoveAttachment={removeAttachment}
|
onRemoveAttachment={removeAttachment}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Drop-target overlay — pointer-events pass through so the drag
|
||||||
|
events keep firing on the column beneath it. */}
|
||||||
|
{dragOver && (
|
||||||
|
<Box
|
||||||
|
sx={(t) => ({
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
// Longhand on purpose — see the container border note above.
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "primary.main",
|
||||||
|
borderRadius: { xs: 0, md: 3 },
|
||||||
|
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.08)`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
px: 3,
|
||||||
|
py: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
boxShadow: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 32, height: 32, color: "primary.main", mb: 0.5 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Přetáhněte soubory sem
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
PDF nebo obrázky faktur
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { registerQueryErrorNotifier } from "../lib/queryClient";
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,6 +82,13 @@ export function AlertProvider({ children }: { children: ReactNode }) {
|
|||||||
[addAlert, removeAlert],
|
[addAlert, removeAlert],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Global React Query error toasts (queryClient.ts). `addAlert` is a stable
|
||||||
|
// useCallback, so this registers once on mount; re-registering the same
|
||||||
|
// handler is idempotent either way.
|
||||||
|
useEffect(() => {
|
||||||
|
registerQueryErrorNotifier((msg) => addAlert(msg, "error"));
|
||||||
|
}, [addAlert]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertContext.Provider value={methods}>
|
<AlertContext.Provider value={methods}>
|
||||||
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
|
<AlertStateContext.Provider value={{ alerts, removeAlert }}>
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return { success: false, error: data.error };
|
return { success: false, error: data.error };
|
||||||
} catch {
|
} catch {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
"Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu.";
|
"Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
return { success: false, error: errorMsg };
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(data.error);
|
setError(data.error);
|
||||||
return { success: false, error: data.error };
|
return { success: false, error: data.error };
|
||||||
} catch {
|
} catch {
|
||||||
const errorMsg = "Chyba pripojeni.";
|
const errorMsg = "Chyba připojení.";
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
return { success: false, error: errorMsg };
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Single source of truth for document status labels (Czech) + chip colors
|
* Single source of truth for document status labels (Czech) + chip colors
|
||||||
* (MUI semantic) across the Offers / Invoices / Orders modules.
|
* (MUI semantic) across the Offers / Invoices / Orders / Projects modules.
|
||||||
*
|
*
|
||||||
* Previously every list AND detail page defined its own `STATUS_LABELS` +
|
* Previously every list AND detail page defined its own `STATUS_LABELS` +
|
||||||
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
|
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
|
||||||
@@ -46,7 +46,7 @@ export const ORDER_STATUS: Record<string, StatusMeta> = {
|
|||||||
prijata: { label: "Přijatá", color: "info" },
|
prijata: { label: "Přijatá", color: "info" },
|
||||||
v_realizaci: { label: "V realizaci", color: "warning" },
|
v_realizaci: { label: "V realizaci", color: "warning" },
|
||||||
dokoncena: { label: "Dokončená", color: "success" },
|
dokoncena: { label: "Dokončená", color: "success" },
|
||||||
stornovana: { label: "Stornována", color: "error" },
|
stornovana: { label: "Stornovaná", color: "error" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
|
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
|
||||||
@@ -75,6 +75,17 @@ export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
|
|||||||
paid: { label: "Uhrazena", color: "success" },
|
paid: { label: "Uhrazena", color: "success" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROJECT (projects). Czech-keyed like customer orders. Colors follow the
|
||||||
|
* app-wide convention: open/in-progress = info, done = success,
|
||||||
|
* cancelled = error.
|
||||||
|
*/
|
||||||
|
export const PROJECT_STATUS: Record<string, StatusMeta> = {
|
||||||
|
aktivni: { label: "Aktivní", color: "info" },
|
||||||
|
dokonceny: { label: "Dokončený", color: "success" },
|
||||||
|
zruseny: { label: "Zrušený", color: "error" },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display label for a document's official number. Drafts (deferred numbering)
|
* Display label for a document's official number. Drafts (deferred numbering)
|
||||||
* have a NULL/empty number until they are finalized — show "Koncept" instead
|
* have a NULL/empty number until they are finalized — show "Koncept" instead
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
|
||||||
import { jsonQuery } from "../apiAdapter";
|
|
||||||
|
|
||||||
export const auditLogOptions = (filters: {
|
|
||||||
search?: string;
|
|
||||||
action?: string;
|
|
||||||
entityType?: string;
|
|
||||||
dateFrom?: string;
|
|
||||||
dateTo?: string;
|
|
||||||
page?: number;
|
|
||||||
}) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ["audit-log", filters],
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters.search) params.set("search", filters.search);
|
|
||||||
if (filters.action) params.set("action", filters.action);
|
|
||||||
if (filters.entityType) params.set("entity_type", filters.entityType);
|
|
||||||
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
|
|
||||||
if (filters.dateTo) params.set("date_to", filters.dateTo);
|
|
||||||
if (filters.page) params.set("page", String(filters.page));
|
|
||||||
const qs = params.toString();
|
|
||||||
return jsonQuery<{
|
|
||||||
data: Record<string, unknown>[];
|
|
||||||
pagination: Record<string, unknown>;
|
|
||||||
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -85,6 +85,7 @@ export interface InvoiceDetail {
|
|||||||
bank_account_id?: number | null;
|
bank_account_id?: number | null;
|
||||||
items?: InvoiceItem[];
|
items?: InvoiceItem[];
|
||||||
sections?: InvoiceSection[];
|
sections?: InvoiceSection[];
|
||||||
|
selected_custom_fields?: number[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
vat_amount: number;
|
vat_amount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
sections: IssuedOrderSection[];
|
sections: IssuedOrderSection[];
|
||||||
supplier: Record<string, unknown> | null;
|
supplier: Record<string, unknown> | null;
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||||
locked_by: {
|
locked_by: {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -143,6 +144,9 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
|
|||||||
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
// 404s (deleted/missing orders) are not transient. Retrying just spams
|
||||||
// GETs and keeps the detail page on an infinite spinner.
|
// GETs and keeps the detail page on an infinite spinner.
|
||||||
retry: false,
|
retry: false,
|
||||||
|
// IssuedOrderDetail toasts its own Czech error + navigates away (and
|
||||||
|
// suppresses it during delete) — the global QueryCache toast would double up.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const issuedOrderNextNumberOptions = () =>
|
export const issuedOrderNextNumberOptions = () =>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export interface OfferDetailData {
|
|||||||
locked_by: OfferLockInfo | null;
|
locked_by: OfferLockInfo | null;
|
||||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const offerDetailOptions = (id: string | undefined) =>
|
export const offerDetailOptions = (id: string | undefined) =>
|
||||||
@@ -194,6 +195,9 @@ export const offerDetailOptions = (id: string | undefined) =>
|
|||||||
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
// 404s (deleted/missing offers) are not transient. Retrying just spams
|
||||||
// GETs and fires the detail-page redirect toast repeatedly.
|
// GETs and fires the detail-page redirect toast repeatedly.
|
||||||
retry: false,
|
retry: false,
|
||||||
|
// OfferDetail toasts its own Czech error + navigates away (and suppresses
|
||||||
|
// it during delete) — the global QueryCache toast would double up.
|
||||||
|
meta: { suppressGlobalErrorToast: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const offerNextNumberOptions = () =>
|
export const offerNextNumberOptions = () =>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface ProjectData {
|
|||||||
quotation_number?: string;
|
quotation_number?: string;
|
||||||
has_nas_folder?: boolean;
|
has_nas_folder?: boolean;
|
||||||
project_notes?: ProjectNote[];
|
project_notes?: ProjectNote[];
|
||||||
|
/** Valid status-machine targets from the current status (server-computed). */
|
||||||
|
valid_transitions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ export interface Role {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-key domains to invalidate on any user create/update/delete: a user's
|
||||||
|
* display name is embedded in these domains (CLAUDE.md: "user CRUD →
|
||||||
|
* trips+attendance").
|
||||||
|
*/
|
||||||
|
export const USER_INVALIDATE: readonly string[] = [
|
||||||
|
"users",
|
||||||
|
"trips",
|
||||||
|
"attendance",
|
||||||
|
"leave-requests",
|
||||||
|
"leave",
|
||||||
|
"projects",
|
||||||
|
];
|
||||||
|
|
||||||
export const userListOptions = (permission?: string) =>
|
export const userListOptions = (permission?: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["users", { permission }],
|
queryKey: ["users", { permission }],
|
||||||
|
|||||||
@@ -1,6 +1,39 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global query-error notifier. AlertContext registers its `error()` toast
|
||||||
|
* here on mount; until then failures fall back to console.error.
|
||||||
|
*/
|
||||||
|
let queryErrorNotifier: ((msg: string) => void) | null = null;
|
||||||
|
|
||||||
|
export function registerQueryErrorNotifier(fn: (msg: string) => void) {
|
||||||
|
queryErrorNotifier = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate limit: at most one query-error toast per window (a page can have many failing queries). */
|
||||||
|
const ERROR_TOAST_WINDOW_MS = 4000;
|
||||||
|
let lastErrorToastAt = 0;
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error, query) => {
|
||||||
|
// Pages with bespoke error handling (detail pages that toast + navigate,
|
||||||
|
// purely-local queries like QR generation) opt out via query meta.
|
||||||
|
if (query.meta?.suppressGlobalErrorToast) return;
|
||||||
|
// Background refetch failure with stale data still on screen — stay silent.
|
||||||
|
if (query.state.data !== undefined) return;
|
||||||
|
// 401s are handled by apiFetch/AuthContext (token refresh / logout).
|
||||||
|
if (error instanceof Error && error.message === "Unauthorized") return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastErrorToastAt < ERROR_TOAST_WINDOW_MS) return;
|
||||||
|
lastErrorToastAt = now;
|
||||||
|
if (queryErrorNotifier) {
|
||||||
|
queryErrorNotifier("Nepodařilo se načíst data ze serveru");
|
||||||
|
} else {
|
||||||
|
console.error("Query failed before alert notifier registered:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { czechPlural } from "../utils/formatters";
|
import { czechPlural } from "../utils/formatters";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||||
@@ -122,6 +123,9 @@ export default function AuditLog() {
|
|||||||
date_from: "",
|
date_from: "",
|
||||||
date_to: "",
|
date_to: "",
|
||||||
});
|
});
|
||||||
|
// Raw value stays in the TextField; only the debounced value hits the
|
||||||
|
// queryKey/request, so typing doesn't refire the query on every keystroke.
|
||||||
|
const debouncedSearch = useDebounce(filters.search, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage] = useState(50);
|
const [perPage] = useState(50);
|
||||||
const [showCleanup, setShowCleanup] = useState(false);
|
const [showCleanup, setShowCleanup] = useState(false);
|
||||||
@@ -136,7 +140,7 @@ export default function AuditLog() {
|
|||||||
queryKey: [
|
queryKey: [
|
||||||
"audit-log",
|
"audit-log",
|
||||||
{
|
{
|
||||||
search: filters.search,
|
search: debouncedSearch,
|
||||||
action: filters.action,
|
action: filters.action,
|
||||||
entityType: filters.entity_type,
|
entityType: filters.entity_type,
|
||||||
dateFrom: filters.date_from,
|
dateFrom: filters.date_from,
|
||||||
@@ -150,7 +154,7 @@ export default function AuditLog() {
|
|||||||
page: String(page),
|
page: String(page),
|
||||||
per_page: String(perPage),
|
per_page: String(perPage),
|
||||||
});
|
});
|
||||||
if (filters.search) params.set("search", filters.search);
|
if (debouncedSearch) params.set("search", debouncedSearch);
|
||||||
if (filters.action) params.set("action", filters.action);
|
if (filters.action) params.set("action", filters.action);
|
||||||
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
if (filters.entity_type) params.set("entity_type", filters.entity_type);
|
||||||
if (filters.date_from) params.set("date_from", filters.date_from);
|
if (filters.date_from) params.set("date_from", filters.date_from);
|
||||||
@@ -177,6 +181,12 @@ export default function AuditLog() {
|
|||||||
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||||
// whole entrance animation on every filter change).
|
// whole entrance animation on every filter change).
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
// logAudit() writes on every mutation app-wide and none of those
|
||||||
|
// mutations invalidate ["audit-log"], so cached data goes stale the
|
||||||
|
// moment anything happens elsewhere — always refetch on mount (same
|
||||||
|
// rationale as dashboardOptions).
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
alert.success(result?.message || "Docházka zaznamenána");
|
alert.success(result?.message || "Docházka zaznamenána");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
setPunching(false);
|
setPunching(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
|
|||||||
import SectionsEditor, {
|
import SectionsEditor, {
|
||||||
type DocumentSection,
|
type DocumentSection,
|
||||||
} from "../components/document/SectionsEditor";
|
} from "../components/document/SectionsEditor";
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -611,6 +612,9 @@ export default function InvoiceDetail() {
|
|||||||
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
||||||
// shared editor with offers/issued orders).
|
// shared editor with offers/issued orders).
|
||||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||||
{
|
{
|
||||||
_key: "inv-1",
|
_key: "inv-1",
|
||||||
@@ -821,11 +825,15 @@ export default function InvoiceDetail() {
|
|||||||
: [];
|
: [];
|
||||||
setSections(mappedSections);
|
setSections(mappedSections);
|
||||||
|
|
||||||
|
const mappedCustomFields = inv.selected_custom_fields ?? [];
|
||||||
|
setSelectedCustomFields(mappedCustomFields);
|
||||||
|
|
||||||
// Capture initial snapshot for dirty-checking
|
// Capture initial snapshot for dirty-checking
|
||||||
initialSnapshotRef.current = JSON.stringify({
|
initialSnapshotRef.current = JSON.stringify({
|
||||||
form: formData,
|
form: formData,
|
||||||
items: mappedItems,
|
items: mappedItems,
|
||||||
sections: mappedSections,
|
sections: mappedSections,
|
||||||
|
selectedCustomFields: mappedCustomFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
setDataReady(true);
|
setDataReady(true);
|
||||||
@@ -923,15 +931,21 @@ export default function InvoiceDetail() {
|
|||||||
// Edit mode: captured inside the sync effect from raw query data.
|
// Edit mode: captured inside the sync effect from raw query data.
|
||||||
// Create mode: captured on the first render after sync effects populate the form.
|
// Create mode: captured on the first render after sync effects populate the form.
|
||||||
if (dataReady && !initialSnapshotRef.current) {
|
if (dataReady && !initialSnapshotRef.current) {
|
||||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
initialSnapshotRef.current = JSON.stringify({
|
||||||
|
form,
|
||||||
|
items,
|
||||||
|
sections,
|
||||||
|
selectedCustomFields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (!initialSnapshotRef.current) return false;
|
if (!initialSnapshotRef.current) return false;
|
||||||
return (
|
return (
|
||||||
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
JSON.stringify({ form, items, sections, selectedCustomFields }) !==
|
||||||
|
initialSnapshotRef.current
|
||||||
);
|
);
|
||||||
}, [form, items, sections]);
|
}, [form, items, sections, selectedCustomFields]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
@@ -1037,7 +1051,7 @@ export default function InvoiceDetail() {
|
|||||||
>({
|
>({
|
||||||
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
|
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
|
||||||
method: () => (isEdit ? "PUT" : "POST"),
|
method: () => (isEdit ? "PUT" : "POST"),
|
||||||
invalidate: ["invoices", "orders"],
|
invalidate: ["invoices", "orders", "projects"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const invoiceId = isEdit ? Number(id) : data.invoice_id;
|
const invoiceId = isEdit ? Number(id) : data.invoice_id;
|
||||||
// PDF binary generation — KEEP as raw apiFetch
|
// PDF binary generation — KEEP as raw apiFetch
|
||||||
@@ -1050,13 +1064,13 @@ export default function InvoiceDetail() {
|
|||||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||||
url: () => `${API_BASE}/invoices/${id}`,
|
url: () => `${API_BASE}/invoices/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["invoices", "orders"],
|
invalidate: ["invoices", "orders", "projects"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const invoiceDeleteMutation = useApiMutation<void, unknown>({
|
const invoiceDeleteMutation = useApiMutation<void, unknown>({
|
||||||
url: () => `${API_BASE}/invoices/${id}`,
|
url: () => `${API_BASE}/invoices/${id}`,
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["invoices", "orders"],
|
invalidate: ["invoices", "orders", "projects"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateSubmit = async (targetStatus?: string) => {
|
const handleCreateSubmit = async (targetStatus?: string) => {
|
||||||
@@ -1095,6 +1109,7 @@ export default function InvoiceDetail() {
|
|||||||
content: s.content,
|
content: s.content,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
};
|
};
|
||||||
// Only set status when a target is given (create-as-draft / create-as-live
|
// Only set status when a target is given (create-as-draft / create-as-live
|
||||||
// / finalize). Editing a live invoice sends no status — the backend keeps
|
// / finalize). Editing a live invoice sends no status — the backend keeps
|
||||||
@@ -1107,7 +1122,12 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
const data = await saveMutation.mutateAsync(payload);
|
const data = await saveMutation.mutateAsync(payload);
|
||||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
initialSnapshotRef.current = JSON.stringify({
|
||||||
|
form,
|
||||||
|
items,
|
||||||
|
sections,
|
||||||
|
selectedCustomFields,
|
||||||
|
});
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
navigate(`/invoices/${data.invoice_id}`);
|
navigate(`/invoices/${data.invoice_id}`);
|
||||||
}
|
}
|
||||||
@@ -2209,6 +2229,15 @@ export default function InvoiceDetail() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={false}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import {
|
import {
|
||||||
invoiceListOptions,
|
invoiceListOptions,
|
||||||
invoiceStatsOptions,
|
invoiceStatsOptions,
|
||||||
@@ -214,6 +215,7 @@ export default function Invoices() {
|
|||||||
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
|
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
|
||||||
const { sort, order, handleSort } = useTableSort("invoice_number");
|
const { sort, order, handleSort } = useTableSort("invoice_number");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
|
||||||
@@ -269,6 +271,11 @@ export default function Invoices() {
|
|||||||
invoice: Invoice | null;
|
invoice: Invoice | null;
|
||||||
}>({ show: false, invoice: null });
|
}>({ show: false, invoice: null });
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [paidConfirm, setPaidConfirm] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
invoice: Invoice | null;
|
||||||
|
}>({ show: false, invoice: null });
|
||||||
|
const [markingPaid, setMarkingPaid] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -279,7 +286,7 @@ export default function Invoices() {
|
|||||||
isFetching: loading,
|
isFetching: loading,
|
||||||
} = usePaginatedQuery<Invoice>(
|
} = usePaginatedQuery<Invoice>(
|
||||||
invoiceListOptions({
|
invoiceListOptions({
|
||||||
search,
|
search: debouncedSearch,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
page,
|
page,
|
||||||
@@ -294,7 +301,7 @@ export default function Invoices() {
|
|||||||
// matches what's shown. Separate from the KPI `statsQuery` above.
|
// matches what's shown. Separate from the KPI `statsQuery` above.
|
||||||
const listTotalsQuery = useQuery(
|
const listTotalsQuery = useQuery(
|
||||||
invoiceTotalsOptions({
|
invoiceTotalsOptions({
|
||||||
search,
|
search: debouncedSearch,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
month: statsMonth,
|
month: statsMonth,
|
||||||
year: statsYear,
|
year: statsYear,
|
||||||
@@ -336,16 +343,21 @@ export default function Invoices() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStatus = async (inv: Invoice) => {
|
const handleMarkPaid = async () => {
|
||||||
if (inv.status === "paid") return;
|
if (!paidConfirm.invoice) return;
|
||||||
|
setMarkingPaid(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`${API_BASE}/invoices/${inv.id}`, {
|
const res = await apiFetch(
|
||||||
method: "PUT",
|
`${API_BASE}/invoices/${paidConfirm.invoice.id}`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({ status: "paid" }),
|
method: "PUT",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: "paid" }),
|
||||||
|
},
|
||||||
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
setPaidConfirm({ show: false, invoice: null });
|
||||||
alert.success("Faktura označena jako zaplacená");
|
alert.success("Faktura označena jako zaplacená");
|
||||||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
@@ -355,6 +367,8 @@ export default function Invoices() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setMarkingPaid(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -557,7 +571,8 @@ export default function Invoices() {
|
|||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(INVOICE_STATUS, inv.status)}
|
label={statusLabel(INVOICE_STATUS, inv.status)}
|
||||||
color={statusColor(INVOICE_STATUS, inv.status)}
|
color={statusColor(INVOICE_STATUS, inv.status)}
|
||||||
onClick={() => toggleStatus(inv)}
|
title="Označit jako zaplacenou"
|
||||||
|
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -613,7 +628,8 @@ export default function Invoices() {
|
|||||||
>
|
>
|
||||||
{inv.status === "paid" ? ViewIcon : EditIcon}
|
{inv.status === "paid" ? ViewIcon : EditIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{hasPermission("invoices.view") && (
|
{/* Drafts have no number yet — /file 404s for them. */}
|
||||||
|
{hasPermission("invoices.view") && inv.invoice_number && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => handlePdf(inv)}
|
onClick={() => handlePdf(inv)}
|
||||||
@@ -760,7 +776,7 @@ export default function Invoices() {
|
|||||||
sortDir={order}
|
sortDir={order}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
empty={
|
empty={
|
||||||
search || statusFilter ? (
|
debouncedSearch || statusFilter ? (
|
||||||
<EmptyState title="Žádné faktury neodpovídají filtru." />
|
<EmptyState title="Žádné faktury neodpovídají filtru." />
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -807,12 +823,23 @@ export default function Invoices() {
|
|||||||
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
|
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat fakturu"
|
title="Smazat fakturu"
|
||||||
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
|
message={`Opravdu chcete smazat fakturu "${documentNumberLabel(deleteConfirm.invoice?.invoice_number)}"? Tato akce je nevratná.`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={paidConfirm.show}
|
||||||
|
onClose={() => setPaidConfirm({ show: false, invoice: null })}
|
||||||
|
onConfirm={handleMarkPaid}
|
||||||
|
title="Označit fakturu jako zaplacenou"
|
||||||
|
message={`Označit fakturu "${documentNumberLabel(paidConfirm.invoice?.invoice_number)}" jako zaplacenou? Tato akce je nevratná.`}
|
||||||
|
confirmText="Označit jako zaplacenou"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
loading={markingPaid}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</PageEnter>
|
</PageEnter>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import SectionsEditor, {
|
|||||||
type DocumentSection,
|
type DocumentSection,
|
||||||
} from "../components/document/SectionsEditor";
|
} from "../components/document/SectionsEditor";
|
||||||
import LockBanner from "../components/document/LockBanner";
|
import LockBanner from "../components/document/LockBanner";
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
import { useDocumentLock } from "../hooks/useDocumentLock";
|
import { useDocumentLock } from "../hooks/useDocumentLock";
|
||||||
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
||||||
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
||||||
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
|
|||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
items: IssuedOrderItemPayload[];
|
items: IssuedOrderItemPayload[];
|
||||||
sections: IssuedOrderSectionPayload[];
|
sections: IssuedOrderSectionPayload[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
|
|||||||
emptyDocumentItem(),
|
emptyDocumentItem(),
|
||||||
]);
|
]);
|
||||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
// Which save action is in flight ("draft" | the live status | "save"), so
|
// Which save action is in flight ("draft" | the live status | "save"), so
|
||||||
@@ -255,7 +260,7 @@ export default function IssuedOrderDetail() {
|
|||||||
const canExport = hasPermission("orders.view");
|
const canExport = hasPermission("orders.view");
|
||||||
|
|
||||||
const { isDirty, markClean } = useUnsavedChangesGuard(
|
const { isDirty, markClean } = useUnsavedChangesGuard(
|
||||||
{ form, items, sections },
|
{ form, items, sections, selectedCustomFields },
|
||||||
!isEdit || dataReady,
|
!isEdit || dataReady,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -296,7 +301,9 @@ export default function IssuedOrderDetail() {
|
|||||||
// Issued orders have no Sleva column; keep the shared item shape happy.
|
// Issued orders have no Sleva column; keep the shared item shape happy.
|
||||||
discount: 0,
|
discount: 0,
|
||||||
}))
|
}))
|
||||||
: [emptyDocumentItem()];
|
: // Issued orders may be issued by sections alone — a saved order with
|
||||||
|
// no items reopens with an empty list (not a blank starter row).
|
||||||
|
[];
|
||||||
setItems(mappedItems);
|
setItems(mappedItems);
|
||||||
|
|
||||||
const mappedSections =
|
const mappedSections =
|
||||||
@@ -308,6 +315,7 @@ export default function IssuedOrderDetail() {
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
setSections(mappedSections);
|
setSections(mappedSections);
|
||||||
|
setSelectedCustomFields(d.selected_custom_fields ?? []);
|
||||||
|
|
||||||
setLockedBy(d.locked_by ?? null);
|
setLockedBy(d.locked_by ?? null);
|
||||||
// Acquire the edit lock when nobody holds it, the order is still
|
// Acquire the edit lock when nobody holds it, the order is still
|
||||||
@@ -395,8 +403,18 @@ export default function IssuedOrderDetail() {
|
|||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
||||||
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
||||||
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
// Items are optional (an order may be issued by sections alone), but a
|
||||||
newErrors.items = "Přidejte alespoň jednu položku";
|
// completely blank order must not be finalizable: require at least one
|
||||||
|
// item with a description OR one non-empty section (title or content).
|
||||||
|
const hasItem = items.some((i) => i.description.trim());
|
||||||
|
const hasSection = sections.some(
|
||||||
|
(s) =>
|
||||||
|
(s.title_cz || "").trim() ||
|
||||||
|
(s.title || "").trim() ||
|
||||||
|
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||||
|
);
|
||||||
|
if (!hasItem && !hasSection) {
|
||||||
|
newErrors.items = "Přidejte alespoň jednu položku nebo obsah";
|
||||||
}
|
}
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
if (Object.keys(newErrors).length > 0) return;
|
if (Object.keys(newErrors).length > 0) return;
|
||||||
@@ -430,6 +448,7 @@ export default function IssuedOrderDetail() {
|
|||||||
content: s.content,
|
content: s.content,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
};
|
};
|
||||||
// Only set status when a target is given (create-as-draft / create-as-live
|
// Only set status when a target is given (create-as-draft / create-as-live
|
||||||
// / finalize). Editing a live order sends no status — the backend keeps
|
// / finalize). Editing a live order sends no status — the backend keeps
|
||||||
@@ -874,6 +893,15 @@ export default function IssuedOrderDetail() {
|
|||||||
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
@@ -883,6 +911,7 @@ export default function IssuedOrderDetail() {
|
|||||||
currency={form.currency}
|
currency={form.currency}
|
||||||
readOnly={!editable}
|
readOnly={!editable}
|
||||||
error={errors.items}
|
error={errors.items}
|
||||||
|
allowEmpty
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rich-text PDF sections — the free-form PDF content lives here */}
|
{/* Rich-text PDF sections — the free-form PDF content lives here */}
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
|||||||
action={
|
action={
|
||||||
hasPermission("orders.create") ? (
|
hasPermission("orders.create") ? (
|
||||||
<Button component={RouterLink} to="/orders/issued/new">
|
<Button component={RouterLink} to="/orders/issued/new">
|
||||||
Vytvořit objednávku
|
Nová vydaná objednávka
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function LeaveRequests() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["leave-requests", "leave", "attendance"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCancelModal({ open: false, id: null });
|
setCancelModal({ open: false, id: null });
|
||||||
alert.success(data?.message || "Žádost byla zrušena");
|
alert.success(data?.message || "Žádost byla zrušena");
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import SectionsEditor, {
|
|||||||
type DocumentSection,
|
type DocumentSection,
|
||||||
} from "../components/document/SectionsEditor";
|
} from "../components/document/SectionsEditor";
|
||||||
import LockBanner from "../components/document/LockBanner";
|
import LockBanner from "../components/document/LockBanner";
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
import { useDocumentLock } from "../hooks/useDocumentLock";
|
import { useDocumentLock } from "../hooks/useDocumentLock";
|
||||||
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
||||||
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
||||||
@@ -114,6 +115,7 @@ interface OfferSavePayload {
|
|||||||
language: string;
|
language: string;
|
||||||
items: OfferItemPayload[];
|
items: OfferItemPayload[];
|
||||||
sections: OfferSectionPayload[];
|
sections: OfferSectionPayload[];
|
||||||
|
selected_custom_fields: number[];
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +180,9 @@ export default function OfferDetail() {
|
|||||||
emptyDocumentItem(),
|
emptyDocumentItem(),
|
||||||
]);
|
]);
|
||||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
||||||
const [offerStatus, setOfferStatus] = useState<string>("");
|
const [offerStatus, setOfferStatus] = useState<string>("");
|
||||||
const [quotationNumber, setQuotationNumber] = useState("");
|
const [quotationNumber, setQuotationNumber] = useState("");
|
||||||
@@ -270,7 +275,7 @@ export default function OfferDetail() {
|
|||||||
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
|
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
|
||||||
|
|
||||||
const { markClean } = useUnsavedChangesGuard(
|
const { markClean } = useUnsavedChangesGuard(
|
||||||
{ form, items, sections },
|
{ form, items, sections, selectedCustomFields },
|
||||||
!loading,
|
!loading,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,6 +323,7 @@ export default function OfferDetail() {
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
setSections(mappedSections);
|
setSections(mappedSections);
|
||||||
|
setSelectedCustomFields(d.selected_custom_fields ?? []);
|
||||||
markClean({ form: formData, items: mappedItems, sections: mappedSections });
|
markClean({ form: formData, items: mappedItems, sections: mappedSections });
|
||||||
setOfferStatus(d.status || "");
|
setOfferStatus(d.status || "");
|
||||||
setOrderInfo(d.order ?? null);
|
setOrderInfo(d.order ?? null);
|
||||||
@@ -445,6 +451,7 @@ export default function OfferDetail() {
|
|||||||
content: s.content,
|
content: s.content,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
};
|
};
|
||||||
// Only set status when a target is given (create-as-draft / create-as-live
|
// Only set status when a target is given (create-as-draft / create-as-live
|
||||||
// / finalize). Editing a live offer sends no status — the backend keeps
|
// / finalize). Editing a live offer sends no status — the backend keeps
|
||||||
@@ -929,6 +936,15 @@ export default function OfferDetail() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Items (drag-and-drop, offers carry the "V ceně" column) */}
|
{/* Items (drag-and-drop, offers carry the "V ceně" column) */}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import ListItemText from "@mui/material/ListItemText";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +44,6 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
StatusChip,
|
|
||||||
FileUpload,
|
FileUpload,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
@@ -413,6 +415,21 @@ export default function Offers() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quick-action finalize (draft → active) from the status chip menu. A
|
||||||
|
// status-only PUT deliberately passes the editable-state guard; the number
|
||||||
|
// is assigned in-transaction server-side (same semantics as the detail).
|
||||||
|
const activateMutation = useApiMutation<
|
||||||
|
{ id: number; status: "active" },
|
||||||
|
{ id: number }
|
||||||
|
>({
|
||||||
|
url: (input) => `${API_BASE}/offers/${input.id}`,
|
||||||
|
method: () => "PUT",
|
||||||
|
invalidate: ["offers", "orders", "projects", "invoices"],
|
||||||
|
onSuccess: () => {
|
||||||
|
alert.success("Nabídka byla aktivována");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasPermission("offers.view")) return <Forbidden />;
|
if (!hasPermission("offers.view")) return <Forbidden />;
|
||||||
|
|
||||||
const handleDuplicate = async (quotation: Quotation) => {
|
const handleDuplicate = async (quotation: Quotation) => {
|
||||||
@@ -489,6 +506,89 @@ export default function Offers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (q: Quotation) => {
|
||||||
|
try {
|
||||||
|
await activateMutation.mutateAsync({ id: q.id, status: "active" });
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
// Re-throw so the chip's ConfirmDialog stays open (app convention).
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// A finalized offer now has its official number — archive the PDF on the
|
||||||
|
// NAS, exactly like OfferDetail after finalize. Fire-and-forget: never
|
||||||
|
// block the toast/refresh — but a failure is surfaced, not swallowed.
|
||||||
|
apiFetch(`${API_BASE}/offers-pdf/${q.id}?save=1`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) alert.error("Nepodařilo se archivovat PDF nabídky na NAS");
|
||||||
|
})
|
||||||
|
.catch(() => alert.error("Nepodařilo se archivovat PDF nabídky na NAS"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Zneplatnit" chip-menu entry — same wording as the page's existing
|
||||||
|
// invalidate ConfirmDialog and the same row mutation underneath.
|
||||||
|
const invalidateChipAction = (q: Quotation): StatusChipAction => ({
|
||||||
|
key: "invalidated",
|
||||||
|
label: "Zneplatnit",
|
||||||
|
danger: true,
|
||||||
|
confirm: {
|
||||||
|
title: "Zneplatnit nabídku",
|
||||||
|
message: `Opravdu chcete zneplatnit nabídku „${documentNumberLabel(q.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`,
|
||||||
|
confirmText: "Zneplatnit",
|
||||||
|
},
|
||||||
|
onAction: async () => {
|
||||||
|
try {
|
||||||
|
await invalidateMutation.mutateAsync(q.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
// Re-throw so the chip's ConfirmDialog stays open (app convention).
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick actions for the status chip menu, by offer status. Only rendered
|
||||||
|
// when the user holds offers.edit (the page's mutating-action permission);
|
||||||
|
// "Vytvořit objednávku…" additionally mirrors the create-order icon's gates
|
||||||
|
// (active + no existing order + orders.create) and opens the same modal.
|
||||||
|
const statusQuickActions = (q: Quotation): StatusChipAction[] => {
|
||||||
|
switch (q.status) {
|
||||||
|
case "draft":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "active",
|
||||||
|
label: "Aktivovat",
|
||||||
|
confirm: {
|
||||||
|
title: "Aktivovat nabídku",
|
||||||
|
message: `Nabídce „${documentNumberLabel(q.quotation_number)}“ bude přiděleno oficiální číslo. Aktivovat?`,
|
||||||
|
confirmText: "Aktivovat",
|
||||||
|
},
|
||||||
|
onAction: () => handleActivate(q),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "active": {
|
||||||
|
const actions: StatusChipAction[] = [];
|
||||||
|
if (!q.order_id && hasPermission("orders.create")) {
|
||||||
|
actions.push({
|
||||||
|
key: "create-order",
|
||||||
|
label: "Vytvořit objednávku…",
|
||||||
|
onAction: () => {
|
||||||
|
setCustomerOrderNumber("");
|
||||||
|
setOrderAttachment(null);
|
||||||
|
setOrderModal({ show: true, quotation: q });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
actions.push(invalidateChipAction(q));
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
case "ordered":
|
||||||
|
return [invalidateChipAction(q)];
|
||||||
|
default:
|
||||||
|
// `invalidated` (and any unknown status): plain chip, no actions.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Only show the full-page skeleton on the very first load; on subsequent
|
// Only show the full-page skeleton on the very first load; on subsequent
|
||||||
// refetches (filter/customer/tab/page change) keep the table visible (the
|
// refetches (filter/customer/tab/page change) keep the table visible (the
|
||||||
// Card dims via isFetching) so it doesn't flash.
|
// Card dims via isFetching) so it doesn't flash.
|
||||||
@@ -572,14 +672,19 @@ export default function Offers() {
|
|||||||
// The offer's own status is `active`/`ordered`/`invalidated`. When its
|
// The offer's own status is `active`/`ordered`/`invalidated`. When its
|
||||||
// linked order is completed, surface that as "Dokončená" (success) —
|
// linked order is completed, surface that as "Dokončená" (success) —
|
||||||
// matching the OfferDetail header chip and the completed row tint.
|
// matching the OfferDetail header chip and the completed row tint.
|
||||||
|
// Completed rows are read-only everywhere on this page, so the chip
|
||||||
|
// stays plain (no quick actions) in that derived state.
|
||||||
const completed =
|
const completed =
|
||||||
q.status !== "invalidated" && q.order_status === "dokoncena";
|
q.status !== "invalidated" && q.order_status === "dokoncena";
|
||||||
return completed ? (
|
return completed ? (
|
||||||
<StatusChip label="Dokončená" color="success" />
|
<StatusChipMenu label="Dokončená" color="success" />
|
||||||
) : (
|
) : (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={statusLabel(OFFER_STATUS, q.status)}
|
label={statusLabel(OFFER_STATUS, q.status)}
|
||||||
color={statusColor(OFFER_STATUS, q.status)}
|
color={statusColor(OFFER_STATUS, q.status)}
|
||||||
|
actions={
|
||||||
|
hasPermission("offers.edit") ? statusQuickActions(q) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function OrderDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orderQuery.error) {
|
if (orderQuery.error) {
|
||||||
alert.error("Nepodařilo se načíst objednávku");
|
alert.error("Nepodařilo se načíst objednávku");
|
||||||
navigate("/orders");
|
navigate("/orders?tab=prijate");
|
||||||
}
|
}
|
||||||
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -159,13 +159,13 @@ export default function OrderDetail() {
|
|||||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||||
url: () => `${API_BASE}/orders/${id}`,
|
url: () => `${API_BASE}/orders/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["orders", "invoices"],
|
invalidate: ["orders", "offers", "projects", "invoices"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const notesMutation = useApiMutation<{ notes: string }, unknown>({
|
const notesMutation = useApiMutation<{ notes: string }, unknown>({
|
||||||
url: () => `${API_BASE}/orders/${id}`,
|
url: () => `${API_BASE}/orders/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["orders", "invoices"],
|
invalidate: ["orders", "offers", "projects", "invoices"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderDeleteMutation = useApiMutation<
|
const orderDeleteMutation = useApiMutation<
|
||||||
@@ -174,7 +174,7 @@ export default function OrderDetail() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/orders/${id}`,
|
url: () => `${API_BASE}/orders/${id}`,
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["orders", "invoices"],
|
invalidate: ["orders", "offers", "projects", "invoices"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||||
@@ -277,7 +277,7 @@ export default function OrderDetail() {
|
|||||||
try {
|
try {
|
||||||
await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles });
|
await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles });
|
||||||
alert.success("Objednávka byla smazána");
|
alert.success("Objednávka byla smazána");
|
||||||
navigate("/orders");
|
navigate("/orders?tab=prijate");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,6 +292,11 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
if (!order) return null;
|
if (!order) return null;
|
||||||
|
|
||||||
|
// From a terminal state the backend offers v_realizaci as a REOPEN — label
|
||||||
|
// it "Obnovit" (not "Zahájit realizaci") and note the no-cascade semantics.
|
||||||
|
const isReopen =
|
||||||
|
order.status === "dokoncena" || order.status === "stornovana";
|
||||||
|
|
||||||
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
|
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
_index: index,
|
_index: index,
|
||||||
@@ -396,7 +401,7 @@ export default function OrderDetail() {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/orders"
|
to="/orders?tab=prijate"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
startIcon={BackIcon}
|
startIcon={BackIcon}
|
||||||
@@ -475,7 +480,9 @@ export default function OrderDetail() {
|
|||||||
>
|
>
|
||||||
{statusChanging === status
|
{statusChanging === status
|
||||||
? "Zpracovávám…"
|
? "Zpracovávám…"
|
||||||
: TRANSITION_LABELS[status] || status}
|
: isReopen && status === "v_realizaci"
|
||||||
|
? "Obnovit"
|
||||||
|
: TRANSITION_LABELS[status] || status}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
{hasPermission("orders.delete") && (
|
{hasPermission("orders.delete") && (
|
||||||
@@ -782,9 +789,15 @@ export default function OrderDetail() {
|
|||||||
onClose={() => setStatusConfirm({ show: false, status: null })}
|
onClose={() => setStatusConfirm({ show: false, status: null })}
|
||||||
onConfirm={handleStatusChange}
|
onConfirm={handleStatusChange}
|
||||||
title="Změnit stav objednávky"
|
title="Změnit stav objednávky"
|
||||||
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`}
|
message={
|
||||||
|
isReopen && statusConfirm.status === "v_realizaci"
|
||||||
|
? `Opravdu chcete obnovit objednávku "${order.order_number}"? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`
|
||||||
|
: `Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`
|
||||||
|
}
|
||||||
confirmText={
|
confirmText={
|
||||||
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
isReopen && statusConfirm.status === "v_realizaci"
|
||||||
|
? "Obnovit"
|
||||||
|
: TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
||||||
}
|
}
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -122,11 +122,11 @@ export default function Orders() {
|
|||||||
startIcon={PlusIcon}
|
startIcon={PlusIcon}
|
||||||
onClick={() => navigate("/orders/issued/new")}
|
onClick={() => navigate("/orders/issued/new")}
|
||||||
>
|
>
|
||||||
Vytvořit objednávku
|
Nová vydaná objednávka
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button startIcon={PlusIcon} onClick={() => setCreateOpen(true)}>
|
<Button startIcon={PlusIcon} onClick={() => setCreateOpen(true)}>
|
||||||
Vytvořit objednávku
|
Nová přijatá objednávka
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|||||||
@@ -31,22 +31,19 @@ import {
|
|||||||
PageEnter,
|
PageEnter,
|
||||||
headerActionsSx,
|
headerActionsSx,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
|
import {
|
||||||
|
PROJECT_STATUS,
|
||||||
|
ORDER_STATUS,
|
||||||
|
statusLabel,
|
||||||
|
statusColor,
|
||||||
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const TRANSITION_LABELS: Record<string, string> = {
|
||||||
aktivni: "Aktivní",
|
dokonceny: "Dokončit projekt",
|
||||||
dokonceny: "Dokončený",
|
zruseny: "Zrušit projekt",
|
||||||
zruseny: "Zrušený",
|
aktivni: "Obnovit projekt",
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<
|
|
||||||
string,
|
|
||||||
"default" | "success" | "error" | "warning" | "info"
|
|
||||||
> = {
|
|
||||||
aktivni: "success",
|
|
||||||
dokonceny: "info",
|
|
||||||
zruseny: "default",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -56,7 +53,6 @@ interface User {
|
|||||||
|
|
||||||
interface ProjectForm {
|
interface ProjectForm {
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
responsible_user_id: string;
|
responsible_user_id: string;
|
||||||
@@ -85,11 +81,15 @@ export default function ProjectDetail() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [form, setForm] = useState<ProjectForm>({
|
const [form, setForm] = useState<ProjectForm>({
|
||||||
name: "",
|
name: "",
|
||||||
status: "aktivni",
|
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
responsible_user_id: "",
|
responsible_user_id: "",
|
||||||
});
|
});
|
||||||
|
const [statusChanging, setStatusChanging] = useState<string | null>(null);
|
||||||
|
const [statusConfirm, setStatusConfirm] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
status: string | null;
|
||||||
|
}>({ show: false, status: null });
|
||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -130,7 +130,6 @@ export default function ProjectDetail() {
|
|||||||
if (project && !formInitialized.current) {
|
if (project && !formInitialized.current) {
|
||||||
setForm({
|
setForm({
|
||||||
name: project.name || "",
|
name: project.name || "",
|
||||||
status: project.status || "aktivni",
|
|
||||||
start_date: (project.start_date || "").substring(0, 10),
|
start_date: (project.start_date || "").substring(0, 10),
|
||||||
end_date: (project.end_date || "").substring(0, 10),
|
end_date: (project.end_date || "").substring(0, 10),
|
||||||
responsible_user_id: project.responsible_user_id || "",
|
responsible_user_id: project.responsible_user_id || "",
|
||||||
@@ -150,7 +149,6 @@ export default function ProjectDetail() {
|
|||||||
const projectSaveMutation = useApiMutation<
|
const projectSaveMutation = useApiMutation<
|
||||||
{
|
{
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
|
||||||
start_date: string | null;
|
start_date: string | null;
|
||||||
end_date: string | null;
|
end_date: string | null;
|
||||||
responsible_user_id: string | null;
|
responsible_user_id: string | null;
|
||||||
@@ -162,6 +160,14 @@ export default function ProjectDetail() {
|
|||||||
invalidate: ["projects", "warehouse"],
|
invalidate: ["projects", "warehouse"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Status transitions (header buttons). A project transition may cascade to
|
||||||
|
// the linked order (and its documents), so invalidate broadly.
|
||||||
|
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||||
|
url: () => `${API_BASE}/projects/${id}`,
|
||||||
|
method: () => "PUT",
|
||||||
|
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
|
||||||
|
});
|
||||||
|
|
||||||
const projectDeleteMutation = useApiMutation<
|
const projectDeleteMutation = useApiMutation<
|
||||||
{ delete_files: boolean },
|
{ delete_files: boolean },
|
||||||
unknown
|
unknown
|
||||||
@@ -198,7 +204,6 @@ export default function ProjectDetail() {
|
|||||||
try {
|
try {
|
||||||
await projectSaveMutation.mutateAsync({
|
await projectSaveMutation.mutateAsync({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
status: form.status,
|
|
||||||
start_date: form.start_date || null,
|
start_date: form.start_date || null,
|
||||||
end_date: form.end_date || null,
|
end_date: form.end_date || null,
|
||||||
responsible_user_id: form.responsible_user_id || null,
|
responsible_user_id: form.responsible_user_id || null,
|
||||||
@@ -211,6 +216,51 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async () => {
|
||||||
|
if (!statusConfirm.status) return;
|
||||||
|
const newStatus = statusConfirm.status;
|
||||||
|
setStatusChanging(newStatus);
|
||||||
|
setStatusConfirm({ show: false, status: null });
|
||||||
|
try {
|
||||||
|
await statusMutation.mutateAsync({ status: newStatus });
|
||||||
|
alert.success("Stav byl změněn");
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setStatusChanging(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirm message with the cascade note — the linked-order cascade only
|
||||||
|
// exists when the project actually has a linked order; reopening never
|
||||||
|
// cascades.
|
||||||
|
const statusConfirmMessage = (status: string | null): string => {
|
||||||
|
if (!status || !project) return "";
|
||||||
|
if (status === "aktivni") {
|
||||||
|
return (
|
||||||
|
'Projekt se vrátí do stavu "Aktivní".' +
|
||||||
|
(project.order_id ? " Propojená objednávka zůstane beze změny." : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const base = `Označit projekt "${project.project_number} – ${project.name}" jako ${
|
||||||
|
status === "zruseny" ? "zrušený" : "dokončený"
|
||||||
|
}?`;
|
||||||
|
// The cascade only fires while the order is still open (prijata /
|
||||||
|
// v_realizaci) — a terminal order (e.g. after a project reopen) stays
|
||||||
|
// untouched, so don't promise a change that won't happen.
|
||||||
|
const orderCascades =
|
||||||
|
project.order_id &&
|
||||||
|
(project.order_status === "prijata" ||
|
||||||
|
project.order_status === "v_realizaci");
|
||||||
|
if (!orderCascades) return base;
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
(status === "zruseny"
|
||||||
|
? " Propojená objednávka bude automaticky stornována."
|
||||||
|
: " Propojená objednávka bude automaticky dokončena.")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
@@ -335,16 +385,31 @@ export default function ProjectDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip
|
<StatusChip
|
||||||
label={STATUS_LABELS[project.status] || project.status}
|
label={statusLabel(PROJECT_STATUS, project.status)}
|
||||||
color={STATUS_COLORS[project.status] || "default"}
|
color={statusColor(PROJECT_STATUS, project.status)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Box sx={headerActionsSx}>
|
<Box sx={headerActionsSx}>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || statusChanging !== null}
|
||||||
|
>
|
||||||
{saving ? "Ukládání..." : "Uložit"}
|
{saving ? "Ukládání..." : "Uložit"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{(project.valid_transitions ?? []).map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
color={status === "zruseny" ? "error" : "primary"}
|
||||||
|
onClick={() => setStatusConfirm({ show: true, status })}
|
||||||
|
disabled={saving || statusChanging !== null}
|
||||||
|
>
|
||||||
|
{statusChanging === status
|
||||||
|
? "Zpracovávám…"
|
||||||
|
: TRANSITION_LABELS[status] || status}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
{!project.order_id && (
|
{!project.order_id && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -408,21 +473,10 @@ export default function ProjectDetail() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Stav">
|
|
||||||
<Select
|
|
||||||
value={form.status}
|
|
||||||
onChange={(v) => updateForm("status", v)}
|
|
||||||
disabled={!canEdit}
|
|
||||||
>
|
|
||||||
<MenuItem value="aktivni">Aktivní</MenuItem>
|
|
||||||
<MenuItem value="dokonceny">Dokončený</MenuItem>
|
|
||||||
<MenuItem value="zruseny">Zrušený</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Datum zahájení">
|
<Field label="Datum zahájení">
|
||||||
<DateField
|
<DateField
|
||||||
value={form.start_date}
|
value={form.start_date}
|
||||||
@@ -556,30 +610,36 @@ export default function ProjectDetail() {
|
|||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Objednávka
|
Objednávka
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{project.order_id ? (
|
{project.order_id ? (
|
||||||
<Box
|
<>
|
||||||
component={RouterLink}
|
<Box
|
||||||
to={`/orders/${project.order_id}`}
|
component={RouterLink}
|
||||||
sx={{
|
to={`/orders/${project.order_id}`}
|
||||||
color: "primary.main",
|
sx={{
|
||||||
textDecoration: "none",
|
color: "primary.main",
|
||||||
"&:hover": { textDecoration: "underline" },
|
textDecoration: "none",
|
||||||
}}
|
"&:hover": { textDecoration: "underline" },
|
||||||
>
|
}}
|
||||||
{project.order_number}
|
>
|
||||||
|
{project.order_number}
|
||||||
|
</Box>
|
||||||
{project.order_status && (
|
{project.order_status && (
|
||||||
<Box
|
<StatusChip
|
||||||
component="span"
|
label={statusLabel(ORDER_STATUS, project.order_status)}
|
||||||
sx={{ color: "text.secondary", ml: 1 }}
|
color={statusColor(ORDER_STATUS, project.order_status)}
|
||||||
>
|
/>
|
||||||
(
|
|
||||||
{STATUS_LABELS[project.order_status] ||
|
|
||||||
project.order_status}
|
|
||||||
)
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
@@ -610,6 +670,22 @@ export default function ProjectDetail() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Status change confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={statusConfirm.show}
|
||||||
|
onClose={() => setStatusConfirm({ show: false, status: null })}
|
||||||
|
onConfirm={handleStatusChange}
|
||||||
|
title="Změnit stav projektu"
|
||||||
|
message={statusConfirmMessage(statusConfirm.status)}
|
||||||
|
confirmText={
|
||||||
|
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
|
||||||
|
}
|
||||||
|
confirmVariant={
|
||||||
|
statusConfirm.status === "zruseny" ? "danger" : "primary"
|
||||||
|
}
|
||||||
|
cancelText="Zrušit"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm}
|
isOpen={deleteConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
import { formatDate } from "../utils/formatters";
|
import { formatDate } from "../utils/formatters";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import useDebounce from "../hooks/useDebounce";
|
import useDebounce from "../hooks/useDebounce";
|
||||||
@@ -30,7 +33,6 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
DateField,
|
DateField,
|
||||||
StatusChip,
|
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
@@ -38,29 +40,20 @@ import {
|
|||||||
type TabDef,
|
type TabDef,
|
||||||
type DataColumn,
|
type DataColumn,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
|
import {
|
||||||
|
PROJECT_STATUS,
|
||||||
|
statusLabel,
|
||||||
|
statusColor,
|
||||||
|
statusOptions,
|
||||||
|
} from "../lib/documentStatus";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
aktivni: "Aktivní",
|
|
||||||
dokonceny: "Dokončený",
|
|
||||||
zruseny: "Zrušený",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<
|
|
||||||
string,
|
|
||||||
"default" | "success" | "error" | "warning" | "info"
|
|
||||||
> = {
|
|
||||||
aktivni: "success",
|
|
||||||
dokonceny: "info",
|
|
||||||
zruseny: "default",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Status filter tabs (mirrors the offers page): "" = all.
|
// Status filter tabs (mirrors the offers page): "" = all.
|
||||||
const STATUS_TABS: TabDef[] = [
|
const STATUS_TABS: TabDef[] = statusOptions(PROJECT_STATUS, {
|
||||||
{ value: "", label: "Všechny" },
|
value: "",
|
||||||
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
label: "Všechny",
|
||||||
];
|
});
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -153,6 +146,17 @@ export default function Projects() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quick status change from the list chip menu. A project transition may
|
||||||
|
// cascade to the linked order (and its documents), so invalidate broadly.
|
||||||
|
const statusMutation = useApiMutation<
|
||||||
|
{ id: number; status: string },
|
||||||
|
unknown
|
||||||
|
>({
|
||||||
|
url: ({ id }) => `${API_BASE}/projects/${id}`,
|
||||||
|
method: () => "PUT",
|
||||||
|
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
|
||||||
|
});
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -268,6 +272,78 @@ export default function Projects() {
|
|||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canEditStatus = hasPermission("projects.edit");
|
||||||
|
|
||||||
|
// "Číslo – Název" reference for confirm messages (name may be empty on
|
||||||
|
// legacy rows).
|
||||||
|
const projectRef = (p: Project) =>
|
||||||
|
p.name ? `${p.project_number} – ${p.name}` : p.project_number;
|
||||||
|
|
||||||
|
const changeStatus = async (p: Project, status: string) => {
|
||||||
|
try {
|
||||||
|
await statusMutation.mutateAsync({ id: p.id, status });
|
||||||
|
alert.success("Stav byl změněn");
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
throw e; // rejection keeps the ConfirmDialog open (app convention)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quick actions per current status; unknown/legacy statuses get none
|
||||||
|
// (plain chip) — those are resolved on the detail page.
|
||||||
|
const statusActions = (p: Project): StatusChipAction[] => {
|
||||||
|
if (p.status === "aktivni") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "dokonceny",
|
||||||
|
label: "Dokončit",
|
||||||
|
confirm: {
|
||||||
|
title: "Dokončit projekt",
|
||||||
|
message:
|
||||||
|
`Označit projekt "${projectRef(p)}" jako dokončený?` +
|
||||||
|
(p.order_id
|
||||||
|
? " Propojená objednávka bude automaticky dokončena."
|
||||||
|
: ""),
|
||||||
|
confirmText: "Dokončit",
|
||||||
|
},
|
||||||
|
onAction: () => changeStatus(p, "dokonceny"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "zruseny",
|
||||||
|
label: "Zrušit",
|
||||||
|
danger: true,
|
||||||
|
confirm: {
|
||||||
|
title: "Zrušit projekt",
|
||||||
|
message:
|
||||||
|
`Označit projekt "${projectRef(p)}" jako zrušený?` +
|
||||||
|
(p.order_id
|
||||||
|
? " Propojená objednávka bude automaticky stornována."
|
||||||
|
: ""),
|
||||||
|
confirmText: "Zrušit projekt",
|
||||||
|
},
|
||||||
|
onAction: () => changeStatus(p, "zruseny"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (p.status === "dokonceny" || p.status === "zruseny") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "aktivni",
|
||||||
|
label: "Obnovit",
|
||||||
|
confirm: {
|
||||||
|
title: "Obnovit projekt",
|
||||||
|
message:
|
||||||
|
'Projekt se vrátí do stavu "Aktivní".' +
|
||||||
|
(p.order_id ? " Propojená objednávka zůstane beze změny." : ""),
|
||||||
|
confirmText: "Obnovit",
|
||||||
|
},
|
||||||
|
onAction: () => changeStatus(p, "aktivni"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
const columns: DataColumn<Project>[] = [
|
const columns: DataColumn<Project>[] = [
|
||||||
{
|
{
|
||||||
key: "project_number",
|
key: "project_number",
|
||||||
@@ -315,9 +391,11 @@ export default function Projects() {
|
|||||||
width: "10%",
|
width: "10%",
|
||||||
sortKey: "status",
|
sortKey: "status",
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={STATUS_LABELS[p.status] || p.status}
|
label={statusLabel(PROJECT_STATUS, p.status)}
|
||||||
color={STATUS_COLORS[p.status] || "default"}
|
color={statusColor(PROJECT_STATUS, p.status)}
|
||||||
|
actions={statusActions(p)}
|
||||||
|
disabled={!canEditStatus}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -394,9 +472,10 @@ export default function Projects() {
|
|||||||
const rowSx = (p: Project) => {
|
const rowSx = (p: Project) => {
|
||||||
if (p.status === "dokonceny") {
|
if (p.status === "dokonceny") {
|
||||||
return {
|
return {
|
||||||
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
|
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
|
backgroundColor:
|
||||||
|
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../utils/formatters";
|
} from "../utils/formatters";
|
||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||||
import {
|
import {
|
||||||
companySettingsOptions,
|
companySettingsOptions,
|
||||||
@@ -248,6 +249,7 @@ export default function ReceivedInvoices({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { sort, order, handleSort } = useTableSort("created_at");
|
const { sort, order, handleSort } = useTableSort("created_at");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
|
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
|
||||||
@@ -257,6 +259,10 @@ export default function ReceivedInvoices({
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
invoice: ReceivedInvoice | null;
|
invoice: ReceivedInvoice | null;
|
||||||
}>({ show: false, invoice: null });
|
}>({ show: false, invoice: null });
|
||||||
|
const [paidConfirm, setPaidConfirm] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
invoice: ReceivedInvoice | null;
|
||||||
|
}>({ show: false, invoice: null });
|
||||||
const hasLoadedOnce = useRef(false);
|
const hasLoadedOnce = useRef(false);
|
||||||
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
|
||||||
@@ -286,7 +292,7 @@ export default function ReceivedInvoices({
|
|||||||
receivedInvoiceListOptions({
|
receivedInvoiceListOptions({
|
||||||
month: statsMonth,
|
month: statsMonth,
|
||||||
year: statsYear,
|
year: statsYear,
|
||||||
search,
|
search: debouncedSearch,
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
page,
|
page,
|
||||||
@@ -306,7 +312,7 @@ export default function ReceivedInvoices({
|
|||||||
receivedInvoiceTotalsOptions({
|
receivedInvoiceTotalsOptions({
|
||||||
month: statsMonth,
|
month: statsMonth,
|
||||||
year: statsYear,
|
year: statsYear,
|
||||||
search,
|
search: debouncedSearch,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -580,10 +586,14 @@ export default function ReceivedInvoices({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleStatus = async (inv: ReceivedInvoice) => {
|
const handleMarkPaid = async () => {
|
||||||
if (inv.status === "paid") return;
|
if (!paidConfirm.invoice) return;
|
||||||
try {
|
try {
|
||||||
await toggleStatusMutation.mutateAsync({ id: inv.id, status: "paid" });
|
await toggleStatusMutation.mutateAsync({
|
||||||
|
id: paidConfirm.invoice.id,
|
||||||
|
status: "paid",
|
||||||
|
});
|
||||||
|
setPaidConfirm({ show: false, invoice: null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav");
|
alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav");
|
||||||
}
|
}
|
||||||
@@ -675,7 +685,8 @@ export default function ReceivedInvoices({
|
|||||||
<StatusChip
|
<StatusChip
|
||||||
label={statusLabel(RECEIVED_INVOICE_STATUS, inv.status)}
|
label={statusLabel(RECEIVED_INVOICE_STATUS, inv.status)}
|
||||||
color={statusColor(RECEIVED_INVOICE_STATUS, inv.status)}
|
color={statusColor(RECEIVED_INVOICE_STATUS, inv.status)}
|
||||||
onClick={() => toggleStatus(inv)}
|
title="Označit jako uhrazenou"
|
||||||
|
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -855,7 +866,7 @@ export default function ReceivedInvoices({
|
|||||||
sortDir={order}
|
sortDir={order}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
empty={
|
empty={
|
||||||
search ? (
|
debouncedSearch ? (
|
||||||
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
<EmptyState title="Žádné faktury neodpovídají hledání." />
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -1329,6 +1340,17 @@ export default function ReceivedInvoices({
|
|||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={paidConfirm.show}
|
||||||
|
onClose={() => setPaidConfirm({ show: false, invoice: null })}
|
||||||
|
onConfirm={handleMarkPaid}
|
||||||
|
title="Označit fakturu jako uhrazenou"
|
||||||
|
message={`Označit fakturu "${paidConfirm.invoice?.invoice_number || paidConfirm.invoice?.supplier_name || ""}" jako uhrazenou?`}
|
||||||
|
confirmText="Označit jako uhrazenou"
|
||||||
|
cancelText="Zrušit"
|
||||||
|
loading={toggleStatusMutation.isPending}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import StatusChipMenu, {
|
||||||
|
type StatusChipAction,
|
||||||
|
} from "../components/StatusChipMenu";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -32,7 +35,6 @@ import {
|
|||||||
Field,
|
Field,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
StatusChip,
|
|
||||||
CheckboxField,
|
CheckboxField,
|
||||||
FileUpload,
|
FileUpload,
|
||||||
FilterBar,
|
FilterBar,
|
||||||
@@ -180,6 +182,17 @@ export default function OrdersReceived({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Quick status change from the table chip (StatusChipMenu). The `id` rides
|
||||||
|
// along in the input only to build the URL; UpdateOrderSchema strips it.
|
||||||
|
const statusMutation = useApiMutation<
|
||||||
|
{ id: number; status: string },
|
||||||
|
unknown
|
||||||
|
>({
|
||||||
|
url: ({ id }) => `${API_BASE}/orders/${id}`,
|
||||||
|
method: () => "PUT",
|
||||||
|
invalidate: ["orders", "offers", "projects", "invoices"],
|
||||||
|
});
|
||||||
|
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
customer_id: "",
|
customer_id: "",
|
||||||
customer_order_number: "",
|
customer_order_number: "",
|
||||||
@@ -352,6 +365,79 @@ export default function OrdersReceived({
|
|||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick actions for the status chip menu, mirroring the order status
|
||||||
|
// machine (VALID_TRANSITIONS incl. the reopen edges). Rejections propagate
|
||||||
|
// so the ConfirmDialog stays open per app convention; we toast here.
|
||||||
|
const statusActions = (o: Order): StatusChipAction[] => {
|
||||||
|
const changeStatus = (status: string) => async () => {
|
||||||
|
try {
|
||||||
|
await statusMutation.mutateAsync({ id: o.id, status });
|
||||||
|
alert.success("Stav byl změněn");
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stornovat: StatusChipAction = {
|
||||||
|
key: "stornovana",
|
||||||
|
label: "Stornovat",
|
||||||
|
danger: true,
|
||||||
|
confirm: {
|
||||||
|
title: "Stornovat objednávku",
|
||||||
|
message: `Opravdu chcete stornovat objednávku „${o.order_number}“? Propojený projekt bude automaticky zrušen.`,
|
||||||
|
confirmText: "Stornovat",
|
||||||
|
},
|
||||||
|
onAction: changeStatus("stornovana"),
|
||||||
|
};
|
||||||
|
switch (o.status) {
|
||||||
|
case "prijata":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "v_realizaci",
|
||||||
|
label: "Zahájit realizaci",
|
||||||
|
confirm: {
|
||||||
|
title: "Zahájit realizaci",
|
||||||
|
message: `Opravdu chcete zahájit realizaci objednávky „${o.order_number}“?`,
|
||||||
|
confirmText: "Zahájit realizaci",
|
||||||
|
},
|
||||||
|
onAction: changeStatus("v_realizaci"),
|
||||||
|
},
|
||||||
|
stornovat,
|
||||||
|
];
|
||||||
|
case "v_realizaci":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "dokoncena",
|
||||||
|
label: "Dokončit",
|
||||||
|
confirm: {
|
||||||
|
title: "Dokončit objednávku",
|
||||||
|
message: `Opravdu chcete dokončit objednávku „${o.order_number}“? Propojený projekt bude automaticky dokončen.`,
|
||||||
|
confirmText: "Dokončit",
|
||||||
|
},
|
||||||
|
onAction: changeStatus("dokoncena"),
|
||||||
|
},
|
||||||
|
stornovat,
|
||||||
|
];
|
||||||
|
case "dokoncena":
|
||||||
|
case "stornovana":
|
||||||
|
// Reopen — deliberately no cascade to the linked project.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "v_realizaci",
|
||||||
|
label: "Obnovit",
|
||||||
|
confirm: {
|
||||||
|
title: "Obnovit objednávku",
|
||||||
|
message: `Opravdu chcete obnovit objednávku „${o.order_number}“? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`,
|
||||||
|
confirmText: "Obnovit",
|
||||||
|
},
|
||||||
|
onAction: changeStatus("v_realizaci"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: DataColumn<Order>[] = [
|
const columns: DataColumn<Order>[] = [
|
||||||
{
|
{
|
||||||
key: "order_number",
|
key: "order_number",
|
||||||
@@ -403,9 +489,11 @@ export default function OrdersReceived({
|
|||||||
width: "13%",
|
width: "13%",
|
||||||
sortKey: "status",
|
sortKey: "status",
|
||||||
render: (o) => (
|
render: (o) => (
|
||||||
<StatusChip
|
<StatusChipMenu
|
||||||
label={statusLabel(ORDER_STATUS, o.status)}
|
label={statusLabel(ORDER_STATUS, o.status)}
|
||||||
color={statusColor(ORDER_STATUS, o.status)}
|
color={statusColor(ORDER_STATUS, o.status)}
|
||||||
|
actions={statusActions(o)}
|
||||||
|
disabled={!hasPermission("orders.edit")}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -589,7 +677,7 @@ export default function OrdersReceived({
|
|||||||
title="Smazat objednávku"
|
title="Smazat objednávku"
|
||||||
message={
|
message={
|
||||||
deleteConfirm.order
|
deleteConfirm.order
|
||||||
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
|
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}“? Bude smazán i přidružený projekt. Tato akce je nevratná.`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation, apiErrorMessage } from "../lib/queries/mutations";
|
||||||
import {
|
import {
|
||||||
userListOptions,
|
userListOptions,
|
||||||
roleListOptions,
|
roleListOptions,
|
||||||
|
USER_INVALIDATE,
|
||||||
type User,
|
type User,
|
||||||
} from "../lib/queries/users";
|
} from "../lib/queries/users";
|
||||||
import {
|
import {
|
||||||
@@ -114,15 +115,6 @@ export default function Users() {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const USER_INVALIDATE = [
|
|
||||||
"users",
|
|
||||||
"trips",
|
|
||||||
"attendance",
|
|
||||||
"leave-requests",
|
|
||||||
"leave",
|
|
||||||
"projects",
|
|
||||||
];
|
|
||||||
|
|
||||||
const saveUser = useApiMutation<UserPayload, void>({
|
const saveUser = useApiMutation<UserPayload, void>({
|
||||||
url: () =>
|
url: () =>
|
||||||
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
|
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
|
||||||
@@ -168,6 +160,9 @@ export default function Users() {
|
|||||||
input.is_active ? "Uživatel byl aktivován" : "Uživatel byl deaktivován",
|
input.is_active ? "Uživatel byl aktivován" : "Uživatel byl deaktivován",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
alert.error(apiErrorMessage(err, "Nepodařilo se změnit stav uživatele"));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPermission("users.view")) return <Forbidden />;
|
if (!hasPermission("users.view")) return <Forbidden />;
|
||||||
@@ -290,6 +285,9 @@ export default function Users() {
|
|||||||
label={u.is_active ? "Aktivní" : "Neaktivní"}
|
label={u.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
color={u.is_active ? "success" : "default"}
|
color={u.is_active ? "success" : "default"}
|
||||||
onClick={u.id === currentUser?.id ? undefined : () => toggleActive(u)}
|
onClick={u.id === currentUser?.id ? undefined : () => toggleActive(u)}
|
||||||
|
title={
|
||||||
|
u.id === currentUser?.id ? undefined : "Kliknutím přepnete stav"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import { vehicleListOptions } from "../lib/queries/vehicles";
|
import { vehicleListOptions } from "../lib/queries/vehicles";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation, apiErrorMessage } from "../lib/queries/mutations";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -157,6 +157,9 @@ export default function Vehicles() {
|
|||||||
: "Vozidlo bylo deaktivováno",
|
: "Vozidlo bylo deaktivováno",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
alert.error(apiErrorMessage(err, "Nepodařilo se změnit stav vozidla"));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPermission("vehicles.manage")) return <Forbidden />;
|
if (!hasPermission("vehicles.manage")) return <Forbidden />;
|
||||||
@@ -265,6 +268,7 @@ export default function Vehicles() {
|
|||||||
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
color={v.is_active ? "success" : "default"}
|
color={v.is_active ? "success" : "default"}
|
||||||
onClick={() => toggleActive(v)}
|
onClick={() => toggleActive(v)}
|
||||||
|
title="Kliknutím přepnete stav"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export default function WarehouseLocations() {
|
|||||||
label={l.is_active ? "Aktivní" : "Neaktivní"}
|
label={l.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
color={l.is_active ? "success" : "default"}
|
color={l.is_active ? "success" : "default"}
|
||||||
onClick={() => toggleActive(l)}
|
onClick={() => toggleActive(l)}
|
||||||
|
title="Kliknutím přepnete stav"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ export default function WarehouseSuppliers() {
|
|||||||
label={s.is_active ? "Aktivní" : "Neaktivní"}
|
label={s.is_active ? "Aktivní" : "Neaktivní"}
|
||||||
color={s.is_active ? "success" : "default"}
|
color={s.is_active ? "success" : "default"}
|
||||||
onClick={() => toggleActive(s)}
|
onClick={() => toggleActive(s)}
|
||||||
|
title="Kliknutím přepnete stav"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type PaletteColor,
|
type PaletteColor,
|
||||||
type PaletteColorChannel,
|
type PaletteColorChannel,
|
||||||
} from "@mui/material/styles";
|
} from "@mui/material/styles";
|
||||||
|
import { csCZ } from "@mui/material/locale";
|
||||||
|
|
||||||
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||||
const FONT_HEADING = "'Urbanist', sans-serif";
|
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||||
@@ -25,257 +26,265 @@ export const FILLED_DARK_BG = {
|
|||||||
info: { bg: "#1d4ed8", hover: "#1e40af" },
|
info: { bg: "#1d4ed8", hover: "#1e40af" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const theme = createTheme({
|
// The theme composes MUI's Czech component localization (csCZ) as a second
|
||||||
cssVariables: {
|
// createTheme argument (the supported deep-merge form). It only injects
|
||||||
colorSchemeSelector: "[data-theme='%s']",
|
// component defaultProps — Czech built-in strings for Autocomplete
|
||||||
},
|
// ("Žádné možnosti"), TablePagination, Alert, Pagination, … — and does not
|
||||||
colorSchemes: {
|
// touch the palette / cssVariables config.
|
||||||
light: {
|
export const theme = createTheme(
|
||||||
palette: {
|
{
|
||||||
mode: "light",
|
cssVariables: {
|
||||||
primary: { main: "#c73030" },
|
colorSchemeSelector: "[data-theme='%s']",
|
||||||
success: { main: "#15803d", contrastText: "#fff" },
|
|
||||||
warning: { main: "#b45309", contrastText: "#fff" },
|
|
||||||
error: { main: "#b91c1c", contrastText: "#fff" },
|
|
||||||
info: { main: "#1d4ed8", contrastText: "#fff" },
|
|
||||||
// Sidebar section hues (full objects so cssVariables emits *Channel
|
|
||||||
// tokens — the nav tiles use channel-alpha washes).
|
|
||||||
teal: {
|
|
||||||
main: "#0e8a7c",
|
|
||||||
light: "#3aa99c",
|
|
||||||
dark: "#0a675d",
|
|
||||||
contrastText: "#fff",
|
|
||||||
},
|
|
||||||
violet: {
|
|
||||||
main: "#6a4cb4",
|
|
||||||
light: "#8a6fd0",
|
|
||||||
dark: "#54399a",
|
|
||||||
contrastText: "#fff",
|
|
||||||
},
|
|
||||||
slate: {
|
|
||||||
main: "#5c6470",
|
|
||||||
light: "#7d8694",
|
|
||||||
dark: "#454c56",
|
|
||||||
contrastText: "#fff",
|
|
||||||
},
|
|
||||||
background: { default: "#f4f3f1", paper: "#ffffff" },
|
|
||||||
text: { primary: "#1a1a1a", secondary: "#555555" },
|
|
||||||
divider: "rgba(0,0,0,0.1)",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
dark: {
|
colorSchemes: {
|
||||||
palette: {
|
light: {
|
||||||
mode: "dark",
|
palette: {
|
||||||
primary: { main: "#d63031" },
|
mode: "light",
|
||||||
success: { main: "#22c55e", contrastText: "#1a1a1a" },
|
primary: { main: "#c73030" },
|
||||||
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
|
success: { main: "#15803d", contrastText: "#fff" },
|
||||||
error: { main: "#ef4444", contrastText: "#1a1a1a" },
|
warning: { main: "#b45309", contrastText: "#fff" },
|
||||||
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
|
error: { main: "#b91c1c", contrastText: "#fff" },
|
||||||
// Sidebar section hues — brighter mains so the glyphs read on dark.
|
info: { main: "#1d4ed8", contrastText: "#fff" },
|
||||||
teal: {
|
// Sidebar section hues (full objects so cssVariables emits *Channel
|
||||||
main: "#2dd4bf",
|
// tokens — the nav tiles use channel-alpha washes).
|
||||||
light: "#5eead4",
|
teal: {
|
||||||
dark: "#14b8a6",
|
main: "#0e8a7c",
|
||||||
contrastText: "#1a1a1a",
|
light: "#3aa99c",
|
||||||
},
|
dark: "#0a675d",
|
||||||
violet: {
|
contrastText: "#fff",
|
||||||
main: "#a78bfa",
|
|
||||||
light: "#c4b5fd",
|
|
||||||
dark: "#8b5cf6",
|
|
||||||
contrastText: "#1a1a1a",
|
|
||||||
},
|
|
||||||
slate: {
|
|
||||||
main: "#94a3b8",
|
|
||||||
light: "#cbd5e1",
|
|
||||||
dark: "#64748b",
|
|
||||||
contrastText: "#1a1a1a",
|
|
||||||
},
|
|
||||||
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
|
||||||
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
|
||||||
divider: "rgba(255,255,255,0.08)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: { borderRadius: 10 },
|
|
||||||
typography: {
|
|
||||||
fontFamily: FONT_BODY,
|
|
||||||
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
|
||||||
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
|
||||||
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
|
||||||
h4: {
|
|
||||||
fontFamily: FONT_HEADING,
|
|
||||||
fontWeight: 700,
|
|
||||||
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
|
||||||
// viewports (long document titles + number). Scale down on xs only.
|
|
||||||
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
|
||||||
},
|
|
||||||
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
|
||||||
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
|
||||||
button: { textTransform: "none", fontWeight: 600 },
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiButton: {
|
|
||||||
defaultProps: { disableElevation: true },
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: "0.95rem",
|
|
||||||
// Snappy press (150ms) + smooth color/shadow (250ms).
|
|
||||||
transition: [
|
|
||||||
`background-color 250ms ${EASE}`,
|
|
||||||
`border-color 250ms ${EASE}`,
|
|
||||||
`color 200ms ${EASE}`,
|
|
||||||
`box-shadow 250ms ${EASE}`,
|
|
||||||
`transform 150ms ${EASE}`,
|
|
||||||
`filter 200ms ${EASE}`,
|
|
||||||
].join(", "),
|
|
||||||
"&:active": { transform: "scale(0.97)" },
|
|
||||||
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
|
||||||
},
|
|
||||||
containedPrimary: {
|
|
||||||
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
|
||||||
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
|
||||||
// The gradient can't transition, so animate lift + glow instead.
|
|
||||||
"&:hover": {
|
|
||||||
transform: "translateY(-1px)",
|
|
||||||
filter: "brightness(1.04)",
|
|
||||||
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
|
|
||||||
},
|
},
|
||||||
"&:active": { transform: "translateY(0) scale(0.97)" },
|
violet: {
|
||||||
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
|
main: "#6a4cb4",
|
||||||
// the lift/press transform (glow + brightness stay, they don't move).
|
light: "#8a6fd0",
|
||||||
"@media (prefers-reduced-motion: reduce)": {
|
dark: "#54399a",
|
||||||
"&:hover": { transform: "none" },
|
contrastText: "#fff",
|
||||||
"&:active": { transform: "none" },
|
|
||||||
},
|
},
|
||||||
|
slate: {
|
||||||
|
main: "#5c6470",
|
||||||
|
light: "#7d8694",
|
||||||
|
dark: "#454c56",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||||
|
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||||
|
divider: "rgba(0,0,0,0.1)",
|
||||||
},
|
},
|
||||||
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
|
|
||||||
// contrastText → near-black text on colored fills in dark mode, which
|
|
||||||
// also clashed with the always-white primary — "black text on one red
|
|
||||||
// button, white on another"). In dark mode the fill drops to a darker
|
|
||||||
// shade so white stays legible.
|
|
||||||
containedError: ({ theme }) => ({
|
|
||||||
color: "#fff",
|
|
||||||
...theme.applyStyles("dark", {
|
|
||||||
"&:not(.Mui-disabled)": {
|
|
||||||
backgroundColor: FILLED_DARK_BG.error.bg,
|
|
||||||
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
containedSuccess: ({ theme }) => ({
|
|
||||||
color: "#fff",
|
|
||||||
...theme.applyStyles("dark", {
|
|
||||||
"&:not(.Mui-disabled)": {
|
|
||||||
backgroundColor: FILLED_DARK_BG.success.bg,
|
|
||||||
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
containedWarning: ({ theme }) => ({
|
|
||||||
color: "#fff",
|
|
||||||
...theme.applyStyles("dark", {
|
|
||||||
"&:not(.Mui-disabled)": {
|
|
||||||
backgroundColor: FILLED_DARK_BG.warning.bg,
|
|
||||||
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
containedInfo: ({ theme }) => ({
|
|
||||||
color: "#fff",
|
|
||||||
...theme.applyStyles("dark", {
|
|
||||||
"&:not(.Mui-disabled)": {
|
|
||||||
backgroundColor: FILLED_DARK_BG.info.bg,
|
|
||||||
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
dark: {
|
||||||
MuiCard: {
|
palette: {
|
||||||
styleOverrides: {
|
mode: "dark",
|
||||||
root: {
|
primary: { main: "#d63031" },
|
||||||
borderRadius: 16,
|
success: { main: "#22c55e", contrastText: "#1a1a1a" },
|
||||||
boxShadow:
|
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
|
||||||
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
error: { main: "#ef4444", contrastText: "#1a1a1a" },
|
||||||
transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`,
|
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
|
||||||
|
// Sidebar section hues — brighter mains so the glyphs read on dark.
|
||||||
|
teal: {
|
||||||
|
main: "#2dd4bf",
|
||||||
|
light: "#5eead4",
|
||||||
|
dark: "#14b8a6",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
main: "#a78bfa",
|
||||||
|
light: "#c4b5fd",
|
||||||
|
dark: "#8b5cf6",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
main: "#94a3b8",
|
||||||
|
light: "#cbd5e1",
|
||||||
|
dark: "#64748b",
|
||||||
|
contrastText: "#1a1a1a",
|
||||||
|
},
|
||||||
|
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||||
|
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||||
|
divider: "rgba(255,255,255,0.08)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiChip: {
|
shape: { borderRadius: 10 },
|
||||||
styleOverrides: {
|
typography: {
|
||||||
root: {
|
fontFamily: FONT_BODY,
|
||||||
borderRadius: 999,
|
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
fontWeight: 700,
|
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
|
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
},
|
h4: {
|
||||||
// Filled colored chips follow the same rule as filled buttons: white
|
fontFamily: FONT_HEADING,
|
||||||
// label in both themes, darker fill in dark mode so white stays legible.
|
fontWeight: 700,
|
||||||
// (Chip has no per-color `filledX` key, so target the color class and
|
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
||||||
// scope to the filled variant.)
|
// viewports (long document titles + number). Scale down on xs only.
|
||||||
colorError: ({ theme }) => ({
|
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
||||||
"&.MuiChip-filled": {
|
},
|
||||||
|
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
button: { textTransform: "none", fontWeight: 600 },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingInline: "0.95rem",
|
||||||
|
// Snappy press (150ms) + smooth color/shadow (250ms).
|
||||||
|
transition: [
|
||||||
|
`background-color 250ms ${EASE}`,
|
||||||
|
`border-color 250ms ${EASE}`,
|
||||||
|
`color 200ms ${EASE}`,
|
||||||
|
`box-shadow 250ms ${EASE}`,
|
||||||
|
`transform 150ms ${EASE}`,
|
||||||
|
`filter 200ms ${EASE}`,
|
||||||
|
].join(", "),
|
||||||
|
"&:active": { transform: "scale(0.97)" },
|
||||||
|
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
||||||
|
},
|
||||||
|
containedPrimary: {
|
||||||
|
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
||||||
|
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
||||||
|
// The gradient can't transition, so animate lift + glow instead.
|
||||||
|
"&:hover": {
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
filter: "brightness(1.04)",
|
||||||
|
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
|
||||||
|
},
|
||||||
|
"&:active": { transform: "translateY(0) scale(0.97)" },
|
||||||
|
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
|
||||||
|
// the lift/press transform (glow + brightness stay, they don't move).
|
||||||
|
"@media (prefers-reduced-motion: reduce)": {
|
||||||
|
"&:hover": { transform: "none" },
|
||||||
|
"&:active": { transform: "none" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
|
||||||
|
// contrastText → near-black text on colored fills in dark mode, which
|
||||||
|
// also clashed with the always-white primary — "black text on one red
|
||||||
|
// button, white on another"). In dark mode the fill drops to a darker
|
||||||
|
// shade so white stays legible.
|
||||||
|
containedError: ({ theme }) => ({
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
...theme.applyStyles("dark", {
|
...theme.applyStyles("dark", {
|
||||||
backgroundColor: FILLED_DARK_BG.error.bg,
|
"&:not(.Mui-disabled)": {
|
||||||
"&.MuiChip-clickable:hover": {
|
backgroundColor: FILLED_DARK_BG.error.bg,
|
||||||
backgroundColor: FILLED_DARK_BG.error.hover,
|
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}),
|
containedSuccess: ({ theme }) => ({
|
||||||
colorSuccess: ({ theme }) => ({
|
|
||||||
"&.MuiChip-filled": {
|
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
...theme.applyStyles("dark", {
|
...theme.applyStyles("dark", {
|
||||||
backgroundColor: FILLED_DARK_BG.success.bg,
|
"&:not(.Mui-disabled)": {
|
||||||
"&.MuiChip-clickable:hover": {
|
backgroundColor: FILLED_DARK_BG.success.bg,
|
||||||
backgroundColor: FILLED_DARK_BG.success.hover,
|
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}),
|
containedWarning: ({ theme }) => ({
|
||||||
colorWarning: ({ theme }) => ({
|
|
||||||
"&.MuiChip-filled": {
|
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
...theme.applyStyles("dark", {
|
...theme.applyStyles("dark", {
|
||||||
backgroundColor: FILLED_DARK_BG.warning.bg,
|
"&:not(.Mui-disabled)": {
|
||||||
"&.MuiChip-clickable:hover": {
|
backgroundColor: FILLED_DARK_BG.warning.bg,
|
||||||
backgroundColor: FILLED_DARK_BG.warning.hover,
|
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}),
|
containedInfo: ({ theme }) => ({
|
||||||
colorInfo: ({ theme }) => ({
|
|
||||||
"&.MuiChip-filled": {
|
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
...theme.applyStyles("dark", {
|
...theme.applyStyles("dark", {
|
||||||
backgroundColor: FILLED_DARK_BG.info.bg,
|
"&:not(.Mui-disabled)": {
|
||||||
"&.MuiChip-clickable:hover": {
|
backgroundColor: FILLED_DARK_BG.info.bg,
|
||||||
backgroundColor: FILLED_DARK_BG.info.hover,
|
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
}),
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiOutlinedInput: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
// Soft brand focus ring fades in over 200ms.
|
|
||||||
transition: `box-shadow 200ms ${EASE}`,
|
|
||||||
"&.Mui-focused": {
|
|
||||||
boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)",
|
|
||||||
},
|
|
||||||
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
|
||||||
},
|
},
|
||||||
notchedOutline: {
|
},
|
||||||
transition: `border-color 200ms ${EASE}`,
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow:
|
||||||
|
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
||||||
|
transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 999,
|
||||||
|
fontWeight: 700,
|
||||||
|
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
|
||||||
|
},
|
||||||
|
// Filled colored chips follow the same rule as filled buttons: white
|
||||||
|
// label in both themes, darker fill in dark mode so white stays legible.
|
||||||
|
// (Chip has no per-color `filledX` key, so target the color class and
|
||||||
|
// scope to the filled variant.)
|
||||||
|
colorError: ({ theme }) => ({
|
||||||
|
"&.MuiChip-filled": {
|
||||||
|
color: "#fff",
|
||||||
|
...theme.applyStyles("dark", {
|
||||||
|
backgroundColor: FILLED_DARK_BG.error.bg,
|
||||||
|
"&.MuiChip-clickable:hover": {
|
||||||
|
backgroundColor: FILLED_DARK_BG.error.hover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
colorSuccess: ({ theme }) => ({
|
||||||
|
"&.MuiChip-filled": {
|
||||||
|
color: "#fff",
|
||||||
|
...theme.applyStyles("dark", {
|
||||||
|
backgroundColor: FILLED_DARK_BG.success.bg,
|
||||||
|
"&.MuiChip-clickable:hover": {
|
||||||
|
backgroundColor: FILLED_DARK_BG.success.hover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
colorWarning: ({ theme }) => ({
|
||||||
|
"&.MuiChip-filled": {
|
||||||
|
color: "#fff",
|
||||||
|
...theme.applyStyles("dark", {
|
||||||
|
backgroundColor: FILLED_DARK_BG.warning.bg,
|
||||||
|
"&.MuiChip-clickable:hover": {
|
||||||
|
backgroundColor: FILLED_DARK_BG.warning.hover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
colorInfo: ({ theme }) => ({
|
||||||
|
"&.MuiChip-filled": {
|
||||||
|
color: "#fff",
|
||||||
|
...theme.applyStyles("dark", {
|
||||||
|
backgroundColor: FILLED_DARK_BG.info.bg,
|
||||||
|
"&.MuiChip-clickable:hover": {
|
||||||
|
backgroundColor: FILLED_DARK_BG.info.hover,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiOutlinedInput: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
// Soft brand focus ring fades in over 200ms.
|
||||||
|
transition: `box-shadow 200ms ${EASE}`,
|
||||||
|
"&.Mui-focused": {
|
||||||
|
boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)",
|
||||||
|
},
|
||||||
|
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
||||||
|
},
|
||||||
|
notchedOutline: {
|
||||||
|
transition: `border-color 200ms ${EASE}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
csCZ,
|
||||||
|
);
|
||||||
|
|
||||||
export const fonts = {
|
export const fonts = {
|
||||||
body: FONT_BODY,
|
body: FONT_BODY,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { setLogoutAlert } from "../utils/api";
|
|||||||
import SidebarNav from "./SidebarNav";
|
import SidebarNav from "./SidebarNav";
|
||||||
import LoadingState from "./LoadingState";
|
import LoadingState from "./LoadingState";
|
||||||
import ShortcutsHelp from "../components/ShortcutsHelp";
|
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||||
|
import TitleSync from "../components/TitleSync";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 248;
|
const DRAWER_WIDTH = 248;
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ export default function AppShell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScopedCssBaseline>
|
<ScopedCssBaseline>
|
||||||
|
<TitleSync />
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={
|
animate={
|
||||||
@@ -110,6 +112,38 @@ export default function AppShell() {
|
|||||||
ease: [0.4, 0, 0.2, 1],
|
ease: [0.4, 0, 0.2, 1],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Skip link: first focusable element; visually hidden (clipped)
|
||||||
|
until keyboard focus reveals it above the drawer. */}
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href="#main-content"
|
||||||
|
sx={(theme) => ({
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
zIndex: theme.zIndex.drawer + 2,
|
||||||
|
width: "1px",
|
||||||
|
height: "1px",
|
||||||
|
overflow: "hidden",
|
||||||
|
clipPath: "inset(50%)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: theme.vars!.palette.primary.main,
|
||||||
|
color: theme.vars!.palette.primary.contrastText,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
"&:focus": {
|
||||||
|
width: "auto",
|
||||||
|
height: "auto",
|
||||||
|
overflow: "visible",
|
||||||
|
clipPath: "none",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Přeskočit na obsah
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -209,10 +243,15 @@ export default function AppShell() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
||||||
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
||||||
|
// Skip-link target: focus lands here programmatically — the
|
||||||
|
// region-wide focus ring would be noise, not a signal.
|
||||||
|
"&:focus": { outline: "none" },
|
||||||
...(immersiveOnMobile && {
|
...(immersiveOnMobile && {
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: { xs: "hidden", md: "visible" },
|
overflow: { xs: "hidden", md: "visible" },
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function CustomerPicker({
|
|||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
autoHighlight
|
autoHighlight
|
||||||
|
noOptionsText="Žádní zákazníci"
|
||||||
renderOption={(props, c) => {
|
renderOption={(props, c) => {
|
||||||
const { key, ...rest } = props as typeof props & { key: string };
|
const { key, ...rest } = props as typeof props & { key: string };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function SupplierPicker({
|
|||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
autoHighlight
|
autoHighlight
|
||||||
|
noOptionsText="Žádní dodavatelé"
|
||||||
renderOption={(props, s) => {
|
renderOption={(props, s) => {
|
||||||
const { key, ...rest } = props as typeof props & { key: string };
|
const { key, ...rest } = props as typeof props & { key: string };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export function Tabs({
|
|||||||
<MuiTabs
|
<MuiTabs
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(_, v) => onChange(v)}
|
onChange={(_, v) => onChange(v)}
|
||||||
|
// Scrollable so many tabs (e.g. 5 status filters) stay reachable on
|
||||||
|
// ~360px phones instead of clipping. When everything fits (desktop),
|
||||||
|
// scroll buttons don't render and this looks identical to "standard" —
|
||||||
|
// callers' centered flex wrappers keep shrink-wrapping the bar.
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
sx={{
|
sx={{
|
||||||
borderBottom: 1,
|
borderBottom: 1,
|
||||||
borderColor: "divider",
|
borderColor: "divider",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getRate } from "../../services/exchange-rates";
|
|||||||
import { localDateStr } from "../../utils/date";
|
import { localDateStr } from "../../utils/date";
|
||||||
import { parseId, success } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
import { lineNet } from "../../utils/money";
|
import { lineNet } from "../../utils/money";
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -34,6 +35,7 @@ function buildAddressLines(
|
|||||||
entity: Record<string, unknown> | null,
|
entity: Record<string, unknown> | null,
|
||||||
isSupplier: boolean,
|
isSupplier: boolean,
|
||||||
tObj: Record<string, string>,
|
tObj: Record<string, string>,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
): AddressResult {
|
): AddressResult {
|
||||||
if (!entity) return { name: "", lines: [] };
|
if (!entity) return { name: "", lines: [] };
|
||||||
|
|
||||||
@@ -89,7 +91,9 @@ function buildAddressLines(
|
|||||||
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||||
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||||
|
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
cfData.forEach((cf, i) => {
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
const cfName = (cf.name || "").trim();
|
const cfName = (cf.name || "").trim();
|
||||||
const cfValue = (cf.value || "").trim();
|
const cfValue = (cf.value || "").trim();
|
||||||
const showLabel = cf.showLabel !== false;
|
const showLabel = cf.showLabel !== false;
|
||||||
@@ -454,7 +458,15 @@ export default async function invoicesPdfRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const supp = buildAddressLines(settings, true, t);
|
const supp = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(invoice as { selected_custom_fields?: unknown })
|
||||||
|
.selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
const cust = buildAddressLines(customer, false, t);
|
const cust = buildAddressLines(customer, false, t);
|
||||||
|
|
||||||
const suppLinesHtml = supp.lines
|
const suppLinesHtml = supp.lines
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requirePermission } from "../../middleware/auth";
|
|||||||
import { parseId, success } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -31,6 +32,7 @@ function buildAddressLines(
|
|||||||
entity: Record<string, unknown> | null,
|
entity: Record<string, unknown> | null,
|
||||||
isCompany: boolean,
|
isCompany: boolean,
|
||||||
tObj: Record<string, string>,
|
tObj: Record<string, string>,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
): AddressResult {
|
): AddressResult {
|
||||||
if (!entity) return { name: "", lines: [] };
|
if (!entity) return { name: "", lines: [] };
|
||||||
|
|
||||||
@@ -86,7 +88,9 @@ function buildAddressLines(
|
|||||||
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||||
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||||
|
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
cfData.forEach((cf, i) => {
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
const cfName = (cf.name || "").trim();
|
const cfName = (cf.name || "").trim();
|
||||||
const cfValue = (cf.value || "").trim();
|
const cfValue = (cf.value || "").trim();
|
||||||
const showLabel = cf.showLabel !== false;
|
const showLabel = cf.showLabel !== false;
|
||||||
@@ -313,7 +317,14 @@ export function renderIssuedOrderHtml(
|
|||||||
|
|
||||||
// PO direction: our company (settings) = Odběratel (buyer);
|
// PO direction: our company (settings) = Odběratel (buyer);
|
||||||
// the sklad_suppliers record = Dodavatel (supplier).
|
// the sklad_suppliers record = Dodavatel (supplier).
|
||||||
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
const buyer = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
); // company → Odběratel
|
||||||
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
||||||
|
|
||||||
const buyerLinesHtml = buyer.lines
|
const buyerLinesHtml = buyer.lines
|
||||||
@@ -741,8 +752,11 @@ ${indentCSS}
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Polozky -->
|
<!-- Polozky — an order may be issued by sections alone; with no items the
|
||||||
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
heading, items table and total row are omitted entirely. -->
|
||||||
|
${
|
||||||
|
items.length > 0
|
||||||
|
? `<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||||
<table class="items">
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -768,7 +782,9 @@ ${indentCSS}
|
|||||||
<span class="value">${formatCurrency(total, currency)}</span>
|
<span class="value">${formatCurrency(total, currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
${scopeHtml}
|
${scopeHtml}
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
return error(reply, "Dodavatel nenalezen", 400);
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
if (order.error === "po_number_taken")
|
if (order.error === "po_number_taken")
|
||||||
return error(reply, "Číslo objednávky je již použito", 409);
|
return error(reply, "Číslo objednávky je již použito", 409);
|
||||||
|
if (order.error === "empty_document")
|
||||||
|
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
|
||||||
return error(reply, "Neznámá chyba", 500);
|
return error(reply, "Neznámá chyba", 500);
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -408,6 +410,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
|
if (result.error === "empty_document")
|
||||||
|
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
|
||||||
return error(reply, "Neznámá chyba", 500);
|
return error(reply, "Neznámá chyba", 500);
|
||||||
}
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { nasOffersManager } from "../../services/nas-offers-manager";
|
|||||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
import { parseId, success } from "../../utils/response";
|
import { parseId, success } from "../../utils/response";
|
||||||
import { lineNet } from "../../utils/money";
|
import { lineNet } from "../../utils/money";
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -29,6 +30,7 @@ function buildAddressLines(
|
|||||||
entity: Record<string, unknown> | null,
|
entity: Record<string, unknown> | null,
|
||||||
isSupplier: boolean,
|
isSupplier: boolean,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
): AddressResult {
|
): AddressResult {
|
||||||
if (!entity) return { name: "", lines: [] };
|
if (!entity) return { name: "", lines: [] };
|
||||||
|
|
||||||
@@ -85,7 +87,9 @@ function buildAddressLines(
|
|||||||
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
|
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
|
||||||
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
|
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
|
||||||
|
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
cfData.forEach((cf, i) => {
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
const cfName = (cf.name || "").trim();
|
const cfName = (cf.name || "").trim();
|
||||||
const cfValue = (cf.value || "").trim();
|
const cfValue = (cf.value || "").trim();
|
||||||
const showLabel = cf.showLabel !== false;
|
const showLabel = cf.showLabel !== false;
|
||||||
@@ -210,6 +214,10 @@ export function renderOfferHtml(
|
|||||||
settings as unknown as Record<string, unknown>,
|
settings as unknown as Record<string, unknown>,
|
||||||
true,
|
true,
|
||||||
t,
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(quotation as { selected_custom_fields?: unknown })
|
||||||
|
.selected_custom_fields,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const custLinesHtml = cust.lines
|
const custLinesHtml = cust.lines
|
||||||
|
|||||||
@@ -331,6 +331,26 @@ export default async function ordersRoutes(
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upravena objednávka ${result.data.order_number}`,
|
description: `Upravena objednávka ${result.data.order_number}`,
|
||||||
});
|
});
|
||||||
|
// The order→project cascade updated linked project(s) — give each its
|
||||||
|
// own audit trail entry.
|
||||||
|
for (const p of result.synced_projects ?? []) {
|
||||||
|
const verb =
|
||||||
|
p.to === "dokonceny"
|
||||||
|
? "dokončen"
|
||||||
|
: p.to === "zruseny"
|
||||||
|
? "zrušen"
|
||||||
|
: "aktivován";
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "update",
|
||||||
|
entityType: "project",
|
||||||
|
entityId: p.id,
|
||||||
|
description: `Projekt ${p.project_number ?? `#${p.id}`} automaticky ${verb} (synchronizace s objednávkou)`,
|
||||||
|
oldValues: { status: p.from },
|
||||||
|
newValues: { status: p.to },
|
||||||
|
});
|
||||||
|
}
|
||||||
return success(reply, { id }, 200, "Objednávka byla uložena");
|
return success(reply, { id }, 200, "Objednávka byla uložena");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ export default async function projectsRoutes(
|
|||||||
const result = await updateProject(id, parsed.data);
|
const result = await updateProject(id, parsed.data);
|
||||||
if (!result) return error(reply, "Projekt nenalezen", 404);
|
if (!result) return error(reply, "Projekt nenalezen", 404);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
|
if (result.error === "invalid_transition" && "currentStatus" in result)
|
||||||
|
return error(
|
||||||
|
reply,
|
||||||
|
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
result.error,
|
result.error,
|
||||||
@@ -138,6 +144,7 @@ export default async function projectsRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusChanged = result.old_status !== result.status;
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
@@ -145,7 +152,26 @@ export default async function projectsRoutes(
|
|||||||
entityType: "project",
|
entityType: "project",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upraven projekt ${result.name}`,
|
description: `Upraven projekt ${result.name}`,
|
||||||
|
oldValues: statusChanged ? { status: result.old_status } : undefined,
|
||||||
|
newValues: statusChanged ? { status: result.status } : undefined,
|
||||||
});
|
});
|
||||||
|
// The project→order cascade updated the linked order too — give the
|
||||||
|
// order its own audit trail entry.
|
||||||
|
if (result.synced_order) {
|
||||||
|
const so = result.synced_order;
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "update",
|
||||||
|
entityType: "order",
|
||||||
|
entityId: so.id,
|
||||||
|
description: `Objednávka ${so.order_number ?? `#${so.id}`} automaticky ${
|
||||||
|
so.to === "dokoncena" ? "dokončena" : "stornována"
|
||||||
|
} (synchronizace s projektem)`,
|
||||||
|
oldValues: { status: so.from },
|
||||||
|
newValues: { status: so.to },
|
||||||
|
});
|
||||||
|
}
|
||||||
return success(reply, { id }, 200, "Projekt byl uložen");
|
return success(reply, { id }, 200, "Projekt byl uložen");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -325,6 +325,25 @@ export default async function tripsRoutes(
|
|||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
const authData = request.authData!;
|
const authData = request.authData!;
|
||||||
|
|
||||||
|
// Only managers may file a trip on behalf of someone else — the same
|
||||||
|
// manager detection buildTripsWhere uses for read scoping (admin role
|
||||||
|
// bypasses permission checks entirely). Explicit Czech 403 instead of
|
||||||
|
// silently ignoring the submitted user_id.
|
||||||
|
const isManager =
|
||||||
|
authData.roleName === "admin" ||
|
||||||
|
authData.permissions.includes("trips.manage");
|
||||||
|
if (
|
||||||
|
body.user_id !== undefined &&
|
||||||
|
Number(body.user_id) !== authData.userId &&
|
||||||
|
!isManager
|
||||||
|
) {
|
||||||
|
return error(
|
||||||
|
reply,
|
||||||
|
"Nemáte oprávnění zadat jízdu za jiného uživatele",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.end_km < body.start_km) {
|
if (body.end_km < body.start_km) {
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export const CreateInvoiceSchema = z.object({
|
|||||||
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
|
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
|
||||||
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
|
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
|
||||||
sections: z.array(DocumentSectionSchema).optional(),
|
sections: z.array(DocumentSectionSchema).optional(),
|
||||||
|
// Positional indices of company custom fields to print on this document's PDF.
|
||||||
|
selected_custom_fields: z
|
||||||
|
.array(z.number().int().nonnegative())
|
||||||
|
.max(50)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateInvoiceSchema = z.object({
|
export const UpdateInvoiceSchema = z.object({
|
||||||
@@ -74,6 +79,10 @@ export const UpdateInvoiceSchema = z.object({
|
|||||||
paid_date: nullableIsoDateString,
|
paid_date: nullableIsoDateString,
|
||||||
items: z.array(InvoiceItemSchema).optional(),
|
items: z.array(InvoiceItemSchema).optional(),
|
||||||
sections: z.array(DocumentSectionSchema).optional(),
|
sections: z.array(DocumentSectionSchema).optional(),
|
||||||
|
selected_custom_fields: z
|
||||||
|
.array(z.number().int().nonnegative())
|
||||||
|
.max(50)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||||
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
||||||
sections: z.array(DocumentSectionSchema).optional(),
|
sections: z.array(DocumentSectionSchema).optional(),
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z
|
||||||
|
.array(z.number().int().nonnegative())
|
||||||
|
.max(50)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update = partial create MINUS the number: PO numbers are immutable
|
// Update = partial create MINUS the number: PO numbers are immutable
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ export const CreateQuotationSchema = z.object({
|
|||||||
scope_description: z.string().max(8000).nullish(),
|
scope_description: z.string().max(8000).nullish(),
|
||||||
items: z.array(QuotationItemSchema).optional(),
|
items: z.array(QuotationItemSchema).optional(),
|
||||||
sections: z.array(ScopeSectionSchema).optional(),
|
sections: z.array(ScopeSectionSchema).optional(),
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z
|
||||||
|
.array(z.number().int().nonnegative())
|
||||||
|
.max(50)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update = partial create MINUS the number: quotation numbers are immutable
|
// Update = partial create MINUS the number: quotation numbers are immutable
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const CreateOrderSchema = z.object({
|
|||||||
quotation_id: nullableIntIdFromForm.nullish(),
|
quotation_id: nullableIntIdFromForm.nullish(),
|
||||||
customer_id: nullableIntIdFromForm.nullish(),
|
customer_id: nullableIntIdFromForm.nullish(),
|
||||||
status: z
|
status: z
|
||||||
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
|
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("prijata"),
|
.default("prijata"),
|
||||||
currency: z.string().max(10).optional().default("CZK"),
|
currency: z.string().max(10).optional().default("CZK"),
|
||||||
@@ -52,7 +52,9 @@ export const CreateOrderSchema = z.object({
|
|||||||
|
|
||||||
export const UpdateOrderSchema = z.object({
|
export const UpdateOrderSchema = z.object({
|
||||||
customer_order_number: z.string().max(100).nullish(),
|
customer_order_number: z.string().max(100).nullish(),
|
||||||
status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(),
|
status: z
|
||||||
|
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
|
||||||
|
.optional(),
|
||||||
currency: z.string().max(10).optional(),
|
currency: z.string().max(10).optional(),
|
||||||
language: z.string().max(5).optional(),
|
language: z.string().max(5).optional(),
|
||||||
scope_title: z.string().max(255).nullish(),
|
scope_title: z.string().max(255).nullish(),
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
releaseInvoiceNumber,
|
releaseInvoiceNumber,
|
||||||
assignInvoiceNumber,
|
assignInvoiceNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
// Status transition rules matching PHP.
|
// Status transition rules matching PHP.
|
||||||
// draft -> issued is the finalize step (consumes + assigns the invoice number).
|
// draft -> issued is the finalize step (consumes + assigns the invoice number).
|
||||||
@@ -512,6 +516,9 @@ export async function getInvoice(id: number) {
|
|||||||
customer_name: invoice.customers?.name || null,
|
customer_name: invoice.customers?.name || null,
|
||||||
order_number: invoice.orders?.order_number || null,
|
order_number: invoice.orders?.order_number || null,
|
||||||
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
|
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(
|
||||||
|
rest.selected_custom_fields,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,6 +568,9 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
internal_notes: body.internal_notes
|
internal_notes: body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
: null,
|
: null,
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -652,6 +662,10 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
|
data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
|
||||||
if (body.tax_date !== undefined)
|
if (body.tax_date !== undefined)
|
||||||
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal notes editable in draft/issued/overdue (never printed)
|
// Internal notes editable in draft/issued/overdue (never printed)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
isIssuedOrderNumberTaken,
|
isIssuedOrderNumberTaken,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { nasOrdersManager } from "./nas-financials-manager";
|
import { nasOrdersManager } from "./nas-financials-manager";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
export interface IssuedOrderItemInput {
|
export interface IssuedOrderItemInput {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -125,6 +129,7 @@ const NON_STATUS_UPDATE_FIELDS = [
|
|||||||
"language",
|
"language",
|
||||||
"order_text",
|
"order_text",
|
||||||
"internal_notes",
|
"internal_notes",
|
||||||
|
"selected_custom_fields",
|
||||||
"items",
|
"items",
|
||||||
"sections",
|
"sections",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -143,6 +148,32 @@ export function computeIssuedOrderTotals(
|
|||||||
return { total: Math.round(total * 100) / 100 };
|
return { total: Math.round(total * 100) / 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An issued order may be issued by sections alone (no line items), but a
|
||||||
|
* completely blank order must not be finalizable. Content = at least one item
|
||||||
|
* with a description OR one section with a title or stripped (non-tag) content.
|
||||||
|
* Mirrors the PDF visibility test and the frontend submit guard.
|
||||||
|
*/
|
||||||
|
function hasOrderContent(
|
||||||
|
items: Array<{ description?: string | null }> | undefined,
|
||||||
|
sections:
|
||||||
|
| Array<{
|
||||||
|
title?: string | null;
|
||||||
|
title_cz?: string | null;
|
||||||
|
content?: string | null;
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
): boolean {
|
||||||
|
const anyItem = (items || []).some((it) => (it.description || "").trim());
|
||||||
|
const anySection = (sections || []).some(
|
||||||
|
(s) =>
|
||||||
|
(s.title_cz || "").trim() ||
|
||||||
|
(s.title || "").trim() ||
|
||||||
|
(s.content || "").replace(/<[^>]*>/g, "").trim(),
|
||||||
|
);
|
||||||
|
return anyItem || anySection;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
||||||
const { page, limit, skip, sort, order } = params;
|
const { page, limit, skip, sort, order } = params;
|
||||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||||
@@ -253,6 +284,9 @@ export async function getIssuedOrder(id: number) {
|
|||||||
supplier: order.suppliers,
|
supplier: order.suppliers,
|
||||||
supplier_name: order.suppliers?.name || null,
|
supplier_name: order.suppliers?.name || null,
|
||||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(
|
||||||
|
rest.selected_custom_fields,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +295,12 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const status = body.status ? String(body.status) : "draft";
|
const status = body.status ? String(body.status) : "draft";
|
||||||
|
|
||||||
|
// A non-draft (created-as-finalized) order must not be blank — require an
|
||||||
|
// item or a non-empty section. Drafts may be saved empty (WIP).
|
||||||
|
if (status !== "draft" && !hasOrderContent(body.items, body.sections)) {
|
||||||
|
return { error: "empty_document" as const };
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
@@ -319,6 +359,9 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
internal_notes: body.internal_notes
|
internal_notes: body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
: null,
|
: null,
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,6 +467,10 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
data.internal_notes = body.internal_notes
|
data.internal_notes = body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
: null;
|
: null;
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.status !== undefined) data.status = String(body.status);
|
if (body.status !== undefined) data.status = String(body.status);
|
||||||
@@ -437,6 +484,27 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
body.status !== undefined &&
|
body.status !== undefined &&
|
||||||
String(body.status) === "sent";
|
String(body.status) === "sent";
|
||||||
|
|
||||||
|
// Finalizing a blank order is rejected — require an item or a non-empty
|
||||||
|
// section. The finalize payload carries items/sections; fall back to the
|
||||||
|
// stored rows when a partial finalize omits them.
|
||||||
|
if (finalizing) {
|
||||||
|
const itemsForCheck = Array.isArray(body.items)
|
||||||
|
? body.items
|
||||||
|
: await prisma.issued_order_items.findMany({
|
||||||
|
where: { issued_order_id: id },
|
||||||
|
select: { description: true },
|
||||||
|
});
|
||||||
|
const sectionsForCheck = Array.isArray(body.sections)
|
||||||
|
? body.sections
|
||||||
|
: await prisma.issued_order_sections.findMany({
|
||||||
|
where: { issued_order_id: id },
|
||||||
|
select: { title: true, title_cz: true, content: true },
|
||||||
|
});
|
||||||
|
if (!hasOrderContent(itemsForCheck, sectionsForCheck)) {
|
||||||
|
return { error: "empty_document" as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ONE transaction for the header write, the finalize numbering AND the
|
// ONE transaction for the header write, the finalize numbering AND the
|
||||||
// items/sections full-replace. The items replace used to run in a SECOND
|
// items/sections full-replace. The items replace used to run in a SECOND
|
||||||
// transaction after the header tx — a failure between the two left a torn
|
// transaction after the header tx — a failure between the two left a torn
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
assignOfferNumber,
|
assignOfferNumber,
|
||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { nasOffersManager } from "./nas-offers-manager";
|
import { nasOffersManager } from "./nas-offers-manager";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
interface QuotationItemInput {
|
interface QuotationItemInput {
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -64,6 +68,7 @@ const NON_STATUS_UPDATE_FIELDS = [
|
|||||||
"language",
|
"language",
|
||||||
"scope_title",
|
"scope_title",
|
||||||
"scope_description",
|
"scope_description",
|
||||||
|
"selected_custom_fields",
|
||||||
"items",
|
"items",
|
||||||
"sections",
|
"sections",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -283,6 +288,9 @@ export async function getOffer(id: number) {
|
|||||||
// Computed server-side so the frontend renders only legal status buttons
|
// Computed server-side so the frontend renders only legal status buttons
|
||||||
// (same contract as getIssuedOrder).
|
// (same contract as getIssuedOrder).
|
||||||
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
|
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(
|
||||||
|
rest.selected_custom_fields,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +350,9 @@ export async function createOffer(body: Record<string, unknown>) {
|
|||||||
scope_description: body.scope_description
|
scope_description: body.scope_description
|
||||||
? String(body.scope_description)
|
? String(body.scope_description)
|
||||||
: null,
|
: null,
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,6 +483,10 @@ export async function updateOffer(id: number, body: Record<string, unknown>) {
|
|||||||
? String(body.scope_description)
|
? String(body.scope_description)
|
||||||
: null
|
: null
|
||||||
: undefined,
|
: undefined,
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
modified_at: new Date(),
|
modified_at: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ interface OrderSectionInput {
|
|||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status transition rules matching PHP
|
// Status transition rules matching PHP, plus the deliberate reopen edges
|
||||||
|
// dokoncena/stornovana → v_realizaci (spec 2026-07-04 — status quick actions).
|
||||||
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
prijata: ["v_realizaci", "stornovana"],
|
prijata: ["v_realizaci", "stornovana"],
|
||||||
v_realizaci: ["dokoncena", "stornovana"],
|
v_realizaci: ["dokoncena", "stornovana"],
|
||||||
dokoncena: [],
|
dokoncena: ["v_realizaci"],
|
||||||
stornovana: [],
|
stornovana: ["v_realizaci"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_ALLOWED_SORT_FIELDS = [
|
const ORDER_ALLOWED_SORT_FIELDS = [
|
||||||
@@ -54,23 +55,53 @@ const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
|
|||||||
stornovana: "zruseny",
|
stornovana: "zruseny",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Order statuses that count as terminal — a transition OUT of one is a REOPEN,
|
||||||
|
// which deliberately never cascades to the linked project(s) (spec 2026-07-04).
|
||||||
|
const TERMINAL_ORDER_STATUSES = ["dokoncena", "stornovana"];
|
||||||
|
|
||||||
|
/** Project change performed by syncProjectStatus, returned for auditing. */
|
||||||
|
export interface SyncedProject {
|
||||||
|
id: number;
|
||||||
|
project_number: string | null;
|
||||||
|
from: string | null;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Propagate an order status change onto its linked project(s). No-op when the
|
* Propagate an order status change onto its linked project(s). No-op when the
|
||||||
* new status has no project-status mapping. Accepts a Prisma client (the tx
|
* new status has no project-status mapping, or when the change is a reopen
|
||||||
* client inside a transaction, or the base client otherwise) so both update
|
* (previous status terminal — reopening never cascades, spec 2026-07-04).
|
||||||
* branches share one implementation.
|
* Accepts a Prisma client (the tx client inside a transaction, or the base
|
||||||
|
* client otherwise) so both update branches share one implementation.
|
||||||
|
* Returns the projects it actually changed so the route can audit them.
|
||||||
*/
|
*/
|
||||||
async function syncProjectStatus(
|
async function syncProjectStatus(
|
||||||
client: Prisma.TransactionClient,
|
client: Prisma.TransactionClient,
|
||||||
orderId: number,
|
orderId: number,
|
||||||
|
previousStatus: string,
|
||||||
newStatus: string,
|
newStatus: string,
|
||||||
): Promise<void> {
|
): Promise<SyncedProject[]> {
|
||||||
|
if (TERMINAL_ORDER_STATUSES.includes(previousStatus)) return [];
|
||||||
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
|
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
|
||||||
if (!projectStatus) return;
|
if (!projectStatus) return [];
|
||||||
await client.projects.updateMany({
|
const linked = await client.projects.findMany({
|
||||||
where: { order_id: orderId },
|
where: { order_id: orderId },
|
||||||
|
select: { id: true, project_number: true, status: true },
|
||||||
|
});
|
||||||
|
// Filter in JS (not via `status: { not: … }`) so NULL-status projects are
|
||||||
|
// still picked up — SQL `<>` would exclude them.
|
||||||
|
const changed = linked.filter((p) => p.status !== projectStatus);
|
||||||
|
if (changed.length === 0) return [];
|
||||||
|
await client.projects.updateMany({
|
||||||
|
where: { id: { in: changed.map((p) => p.id) } },
|
||||||
data: { status: projectStatus },
|
data: { status: projectStatus },
|
||||||
});
|
});
|
||||||
|
return changed.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
project_number: p.project_number,
|
||||||
|
from: p.status,
|
||||||
|
to: projectStatus,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
|
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
|
||||||
@@ -640,6 +671,10 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Projects cascaded onto by a status change — surfaced to the route for
|
||||||
|
// per-project audit rows.
|
||||||
|
let syncedProjects: SyncedProject[] = [];
|
||||||
|
|
||||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
const strFields = [
|
const strFields = [
|
||||||
"customer_order_number",
|
"customer_order_number",
|
||||||
@@ -678,7 +713,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
|
|
||||||
// Sync project status when order status changes (matching PHP)
|
// Sync project status when order status changes (matching PHP)
|
||||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||||
await syncProjectStatus(tx, id, String(body.status));
|
syncedProjects = await syncProjectStatus(
|
||||||
|
tx,
|
||||||
|
id,
|
||||||
|
currentStatus,
|
||||||
|
String(body.status),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(body.items)) {
|
if (Array.isArray(body.items)) {
|
||||||
@@ -714,11 +754,19 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
|
|
||||||
// Sync project status when order status changes (matching PHP)
|
// Sync project status when order status changes (matching PHP)
|
||||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||||
await syncProjectStatus(prisma, id, String(body.status));
|
syncedProjects = await syncProjectStatus(
|
||||||
|
prisma,
|
||||||
|
id,
|
||||||
|
currentStatus,
|
||||||
|
String(body.status),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: { id, order_number: existing.order_number } };
|
return {
|
||||||
|
data: { id, order_number: existing.order_number },
|
||||||
|
synced_projects: syncedProjects,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOrder(id: number, deleteFiles = false) {
|
export async function deleteOrder(id: number, deleteFiles = false) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
import type { CreateProjectInput } from "../schemas/projects.schema";
|
import type { CreateProjectInput } from "../schemas/projects.schema";
|
||||||
|
import type { projects } from "../types";
|
||||||
|
|
||||||
const nasFileManager = new NasFileManager();
|
const nasFileManager = new NasFileManager();
|
||||||
|
|
||||||
@@ -17,6 +18,29 @@ const ALLOWED_SORT_FIELDS = [
|
|||||||
"created_at",
|
"created_at",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Status transition rules (spec 2026-07-04 — projects gain a status machine;
|
||||||
|
// dokonceny/zruseny → aktivni is a deliberate reopen edge).
|
||||||
|
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
|
aktivni: ["dokonceny", "zruseny"],
|
||||||
|
dokonceny: ["aktivni"],
|
||||||
|
zruseny: ["aktivni"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CANONICAL_STATUSES = Object.keys(VALID_TRANSITIONS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legal next statuses for a project. The status column is a legacy free-text
|
||||||
|
* string, so an unknown/legacy current status may move to any canonical value
|
||||||
|
* (minus itself) — tolerant, matching the updateProject validation.
|
||||||
|
*/
|
||||||
|
function validTransitionsFor(status: string | null): string[] {
|
||||||
|
const current = status ?? "";
|
||||||
|
if (Object.prototype.hasOwnProperty.call(VALID_TRANSITIONS, current)) {
|
||||||
|
return VALID_TRANSITIONS[current];
|
||||||
|
}
|
||||||
|
return CANONICAL_STATUSES.filter((s) => s !== current);
|
||||||
|
}
|
||||||
|
|
||||||
interface ListProjectsParams {
|
interface ListProjectsParams {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -91,13 +115,39 @@ export async function getProject(id: number) {
|
|||||||
order_number: orders?.order_number ?? null,
|
order_number: orders?.order_number ?? null,
|
||||||
order_status: orders?.status ?? null,
|
order_status: orders?.status ?? null,
|
||||||
quotation_number: quotations?.quotation_number ?? null,
|
quotation_number: quotations?.quotation_number ?? null,
|
||||||
|
// Computed server-side so the frontend renders only legal status buttons
|
||||||
|
// (same contract as offers/orders/issued orders).
|
||||||
|
valid_transitions: validTransitionsFor(project.status),
|
||||||
has_nas_folder: project.project_number
|
has_nas_folder: project.project_number
|
||||||
? nasFileManager.projectFolderExists(project.project_number)
|
? nasFileManager.projectFolderExists(project.project_number)
|
||||||
: false,
|
: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(id: number, body: Record<string, unknown>) {
|
/** Order change performed by the project→order cascade, returned for auditing. */
|
||||||
|
export interface SyncedOrder {
|
||||||
|
id: number;
|
||||||
|
order_number: string | null;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit union — inference would normalize the members with `?: undefined`
|
||||||
|
// props (and narrow synced_order to `null`, its value at the return
|
||||||
|
// statement), breaking the route's `"error" in result` narrowing.
|
||||||
|
type UpdateProjectResult =
|
||||||
|
| null
|
||||||
|
| { error: string; status: number }
|
||||||
|
| { error: "invalid_transition"; currentStatus: string; newStatus: string }
|
||||||
|
| (projects & {
|
||||||
|
old_status: string | null;
|
||||||
|
synced_order: SyncedOrder | null;
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
id: number,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<UpdateProjectResult> {
|
||||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
@@ -108,6 +158,25 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
return { error: "Číslo projektu nelze změnit", status: 400 };
|
return { error: "Číslo projektu nelze změnit", status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status changes must follow the transition table (offers/orders parity).
|
||||||
|
// Legacy tolerance: the column is historic free text, so an unknown current
|
||||||
|
// status may move to any canonical value.
|
||||||
|
const currentStatus = existing.status ?? "";
|
||||||
|
const statusChanges =
|
||||||
|
body.status !== undefined && String(body.status) !== currentStatus;
|
||||||
|
const newStatus = statusChanges ? String(body.status) : null;
|
||||||
|
if (statusChanges && newStatus !== null) {
|
||||||
|
const allowed = Object.prototype.hasOwnProperty.call(
|
||||||
|
VALID_TRANSITIONS,
|
||||||
|
currentStatus,
|
||||||
|
)
|
||||||
|
? VALID_TRANSITIONS[currentStatus]
|
||||||
|
: CANONICAL_STATUSES;
|
||||||
|
if (!allowed.includes(newStatus)) {
|
||||||
|
return { error: "invalid_transition" as const, currentStatus, newStatus };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FK pre-validation (mirrors createProject): a dangling id would raise
|
// FK pre-validation (mirrors createProject): a dangling id would raise
|
||||||
// P2003 at Prisma and surface as a generic 500 — return the same Czech
|
// P2003 at Prisma and surface as a generic 500 — return the same Czech
|
||||||
// 400s as the create path. null still clears the FK.
|
// 400s as the create path. null still clears the FK.
|
||||||
@@ -164,7 +233,45 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
if (body.end_date !== undefined)
|
if (body.end_date !== undefined)
|
||||||
data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
||||||
|
|
||||||
const updated = await prisma.projects.update({ where: { id }, data });
|
// Project → order cascade (spec 2026-07-04): finishing/cancelling a project
|
||||||
|
// completes/cancels its linked order iff the order is still open
|
||||||
|
// (prijata/v_realizaci). Reopen (→aktivni) never touches the order, and
|
||||||
|
// completed/cancelled orders are never resurrected. Direct tx write — no
|
||||||
|
// updateOrder recursion — in the SAME transaction as the project update.
|
||||||
|
let syncedOrder: SyncedOrder | null = null;
|
||||||
|
|
||||||
|
const wantsCascade =
|
||||||
|
statusChanges &&
|
||||||
|
(newStatus === "dokonceny" || newStatus === "zruseny") &&
|
||||||
|
existing.order_id != null;
|
||||||
|
|
||||||
|
const updated = wantsCascade
|
||||||
|
? await prisma.$transaction(async (tx) => {
|
||||||
|
const row = await tx.projects.update({ where: { id }, data });
|
||||||
|
const order = await tx.orders.findUnique({
|
||||||
|
where: { id: existing.order_id! },
|
||||||
|
select: { id: true, order_number: true, status: true },
|
||||||
|
});
|
||||||
|
const orderStatus = order?.status ?? "";
|
||||||
|
if (
|
||||||
|
order &&
|
||||||
|
(orderStatus === "prijata" || orderStatus === "v_realizaci")
|
||||||
|
) {
|
||||||
|
const to = newStatus === "dokonceny" ? "dokoncena" : "stornovana";
|
||||||
|
await tx.orders.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: to, modified_at: new Date() },
|
||||||
|
});
|
||||||
|
syncedOrder = {
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
from: orderStatus,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
})
|
||||||
|
: await prisma.projects.update({ where: { id }, data });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
body.name !== undefined &&
|
body.name !== undefined &&
|
||||||
@@ -184,7 +291,9 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
// old_status + synced_order let the route audit the status change and the
|
||||||
|
// cascaded order update.
|
||||||
|
return { ...updated, old_status: existing.status, synced_order: syncedOrder };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,3 +46,36 @@ export function decodeCustomFields(raw: string | null): {
|
|||||||
return { custom_fields: [], field_order: [] };
|
return { custom_fields: [], field_order: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document selection of which COMPANY custom fields print on a PDF.
|
||||||
|
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
|
||||||
|
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
|
||||||
|
*/
|
||||||
|
export function encodeSelectedCustomFields(indices: unknown): string | null {
|
||||||
|
const clean = normalizeIndices(indices);
|
||||||
|
return clean.length > 0 ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
|
||||||
|
export function parseSelectedCustomFields(raw: unknown): number[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (Array.isArray(raw)) return normalizeIndices(raw);
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return [];
|
||||||
|
try {
|
||||||
|
return normalizeIndices(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON in a selection column degrades to "none" (expected
|
||||||
|
// condition — a hand-edited/legacy row should never 500 a PDF render).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndices(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const v of input) {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user