# Cross-Module Consistency Report: Offers, Invoices, Orders

> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
> Examination only — no code was changed. File:line refs are at the time of the audit.

## 1. Executive Summary

The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.

## 2. Recommended Canonical Patterns

| Dimension                       | Standardize on                                                                               | Why                                                                                        |
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| List KPI cards                  | **Invoices** (`Invoices.tsx:420-505`)                                                        | Only module with a 4-card metrics grid; gives business context Orders/Offers lack          |
| List load/skeleton              | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784)                                | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank           |
| List row state tinting          | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx`                                         | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
| List status filter              | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders)                   | Tabs show all options inline; Select scales — choose by option count                       |
| Search-aware empty state        | **Offers** (`Offers.tsx:840-855`)                                                            | Distinguishes search-empty (no CTA) from list-empty (with CTA)                             |
| Detail read-only mode           | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`)                                | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced        |
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`)                                                           | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct)  |
| Save-button loading             | **Invoice/Order** (CircularProgress + text)                                                  | Clearer visual signal than Offers' text-only swap                                          |
| Detail guard placement          | **OfferDetail** (all guards top-level, `:1087-1091`)                                         | Flattest; no `if (!dataReady)` buried inside render branches                               |
| Customer/supplier picker        | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`)                         | Keyboard-accessible, searchable, Material-standard                                         |
| Boolean fields                  | **MUI `CheckboxField` kit** (as in OrdersReceived modal)                                     | Themed, accessible; replaces raw `<input type=checkbox>`                                   |
| Notes fields                    | **RichEditor** (Offers/Orders already)                                                       | Uniform editing; future-proof for formatting                                               |
| `issued_by`                     | **Read-only, auto-filled** (Invoices, `:1881-1885`)                                          | Immutable provenance tied to the issuing user                                              |
| Currency options                | **Company settings** (Offers/Invoices)                                                       | Admin-configurable without code change                                                     |
| Line-item labels/widths         | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier                                           |
| Amount column header            | **"Celkem"** (all but ReceivedInvoices' "Částka")                                            | Single label for the same concept                                                          |

## 3. Inconsistencies by Dimension

### A. Tables & Lists

**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.

**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.

**MED — Orders missing list features that Invoices/Offers have.**

- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.

**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.

**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.

**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.

**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.

_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._

### B. Detail Buttons & Layout

**HIGH — Delete-button safety bugs + Orders feature gap.**

- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
  Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.

**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.

**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.

**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.

**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.

**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.

**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.

_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._

### C. Forms & Fields

**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.

**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.

**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.

**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.

**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.

**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.

_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._

### D. Line-Item Editors

Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).

**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem` → `align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
**LOW — Remove-button cell width.** Offers `3rem` → `2.5rem`. Effort: **S**.

_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._

## 4. Suggested Fix Order

**Batch 1 — Mechanical quick wins (low judgment, low risk).**

1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem`→`5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem`→`2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right`→`center`; "%DPH"→"DPH").
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.

**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.

**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.

**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).

## 5. Already Consistent — Do Not Touch

- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.

**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.

---

# Part 2 — Status/State Models + Draft Persistence (follow-up audit)

> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).

## Summary

The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.

## State-model comparison

| Document                             | Storage                    | Key language                                                 | #States | Transitions                      | Auto-transitions                         | Edit-lock                              | Color consistency                                      |
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
| Offer (quotations)                   | `String(30)` def `active`  | mixed (`active`/`ordered`/`invalidated`)                     | 3       | none (manual; `invalidateOffer`) | none                                     | locked only when `invalidated`         | **no label/color map** (tabs only)                     |
| Customer Order (orders)              | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4       | `VALID_TRANSITIONS`              | → project status sync                    | items lock in `dokoncena`/`stornovana` | consistent list↔detail                                 |
| Issued Order (issued_orders)         | **enum** def `draft`       | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5       | `VALID_TRANSITIONS`              | none                                     | full lock outside `draft`/`sent`       | consistent                                             |
| Issued Invoice (invoices)            | `String(30)` def `issued`  | English (`issued`/`overdue`/`paid`)                          | 3       | `VALID_TRANSITIONS`              | `markOverdueInvoices()` + auto paid_date | full lock in `paid`                    | **DRIFTED**: `issued`=warning in list, =info in detail |
| Received Invoice (received_invoices) | **enum** def `unpaid`      | English (`unpaid`/`paid`)                                    | 2       | implicit (no revert)             | auto paid_date, month/year               | locks in `paid`                        | consistent (list only)                                 |

## Intentional (KEEP) vs Accidental (FIX)

**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.

**Accidental — fix:**

- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
- Divergent semantic color for the same status (`issued` = warning vs info).
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
- Arbitrary draft-banner visibility heuristics.

## Recommendations

- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata`→`received`, `v_realizaci`→`in_progress`, `dokoncena`→`completed`, `stornovana`→`cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**

## Effort + risk

- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.
