0011 — Detail-page shared components
- Status: accepted
- Date: 2026-05-13
- Deciders: Ramon Martinez (frontend)
- Tags: frontend, ui, design-system
Context
Budget detail (backend/app/modules/budget/frontend/pages/budgets/[id].vue) and invoice detail (backend/app/modules/billing/frontend/pages/invoices/[id]/index.vue) had grown two parallel implementations of the same UI structure: page header with title + status chips, action bar with overflow, totals card, sidebar metadata card.
The two implementations had drifted: invoice header used a no-wrap flex container (chips overlapped on mobile), had 8 inline buttons with no overflow grouping, and inlined a status→colour map already present in frontend/app/config/severity.ts. Budget had been redesigned with mobile-first patterns but didn't ship reusable primitives. Any new "document" detail page (rectificativa, recibo, treatment-plan view) would copy whichever was closer.
We already had the right substrate: frontend/app/config/severity.ts (semantic roles + entity status maps), frontend/app/components/shared/StatusBadge.vue (semantic badge), frontend/app/composables/useBreakpoint.ts and useCurrency.ts. What was missing were a few wrapper components and a composable that bind them to the per-entity context.
Decision
All "detail" pages (budget, invoice, future documents) render through a fixed set of shared components living under frontend/app/components/shared/:
DetailPageHeaderEntityStatusChipsEntityActionBarEntityTotalsCardEntityInfoCardEntityCriticalBanner
…and a single composable useEntityStatus that maps a status string to { role, label, uiColor } using the entity maps already declared in severity.ts.
Status→colour maps live in severity.ts only. Status→label translations live in i18n only. No module-local colour maps, no inline getXxxBadgeColor() helpers, no hardcoded chip arrays in templates.
Consequences
Good
- One place to fix bugs in chip wrapping, focus styles, action collapsing, etc.
- Compliance modules (Verifactu and future country-specific ones) have a stable contract: inject a chip via the existing slot, or a banner via
EntityCriticalBanner. No need to touch billing. - New document detail pages have a ~30-line template instead of ~1000.
- Visual language is enforced by code, not by reviewer vigilance.
Bad / accepted trade-offs
- One more indirection between the page and the markup; reading a detail page now requires opening 2–3 component files to understand the visual output.
- The shared components live in
frontend/app/components/shared/, which is a host-level directory consumed by module layers. A future module that wanted to ship its own detail page from outside the monorepo would need access to these primitives (acceptable: our modules ship as Nuxt layers under the same workspace).
Alternatives considered
- Leave both pages as-is, fix invoice in-place. Cheapest but guarantees the drift continues; the next document we add (credit note, payment receipt) starts from yet another copy.
- Build a heavyweight "DocumentPage" component that owns the whole layout including main content. Too rigid — the body of each detail page diverges (items vs. payments vs. compliance) and forcing a single template would push module-specific logic into shared/.
- Use Nuxt UI v4's
<UPageHeader>directly. It doesn't cover status chips, version pill, or the responsive action collapsing we need; would still require wrappers.
How to verify the rule still holds
- Grep:
rg "getStatusBadgeColor|getStatusColor" backend/app/modules/*/frontend/must return zero results (after migration PRs land). - Grep:
rg "<h1[^>]*>" backend/app/modules/*/frontend/pages/**/[id]*should match only files that immediately enclose the title inside<DetailPageHeader>. - Tests:
frontend/tests/composables/useEntityStatus.test.tscovers the role/label/uiColor contract; expand component tests as components evolve.
References
frontend/app/components/shared/DetailPageHeader.vuefrontend/app/components/shared/EntityStatusChips.vuefrontend/app/components/shared/EntityActionBar.vuefrontend/app/components/shared/EntityTotalsCard.vuefrontend/app/components/shared/EntityInfoCard.vuefrontend/app/components/shared/EntityCriticalBanner.vuefrontend/app/composables/useEntityStatus.tsfrontend/app/config/severity.tsdocs/technical/detail-page-shared-components.md