Files
app/docs/cross-module-consistency-audit-2026-06-09.md
2026-06-09 16:05:00 +02:00

22 KiB

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.

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 sortKeys 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 5remalign="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 3rem2.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 5rem5.5rem, align="center" on qty/unit-price headers, remove-button cell 3rem2.5rem. Plus in-Invoices fixes (paid-view unit-price rightcenter; "%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 (prijatareceived, v_realizaciin_progress, dokoncenacompleted, stornovanacancelled) + 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.