Console module section tables — columns + joins
reference· designer · updated 2026-06-27 · source
How each console module/section list page (/org/<slug>/<module>/<section>, effort #963)
presents its rows: readable columns, human names instead of FK ids, formatted numbers/dates,
and categorical badges. The reference is the shipped admin screen language —
packages/ui/src/admin/screens/enrollments-view.tsx and payments-view.tsx already solved
this; the console tables align to it (on the Amber/ink console matiz, color-scope.md).
Sibling: console-nav-ia-spec.md (nav/IA), console-structure-spec.md (IA model).
Frontend implements off this spec. Backend supplies the joined fields (see § Contract impact).
The problem this repeals
The seeded console renders ONE generic shape for every entity —
ConsoleEntityRow { id, label, secondary, meta, fields } → columns Nombre / Detalle / Meta / ID
(apps/console/src/components/datos-browser.tsx RegistrosTab + the SectionDataTable in #965/#966,
fed by apps/api/.../console-routes.ts). It is unreadable because:
enrollment/paymentsendlabel: <uuid>— a row leads with an id, not a person.secondarymixes free strings ("card · pending");metais"$"+amount(no grouping).- Headers
Detalle/Meta/IDname nothing — "¿Monto o amount? ID no sé a qué se refiere."
The cure is per-section columns (not one normalized row), with joins resolving FK ids to names
and Intl formatting everywhere. The DetailPanel (fields[]) is fine — keep it.
Cross-section rules (apply to every table)
| Rule | Decision |
|---|---|
| Column names | Real, per-section names. Kill generic Detalle / Meta. |
| ID placement | First column, de-emphasized: type-mono text-fg-muted, 8-char short (id.slice(0,8)), full id in title=. Never the row's headline. |
| Names over ids | Any FK column shows the joined human name; FK-id fallback only when the join is null (shortId). |
| Integers | new Intl.NumberFormat('es-MX') → 19610 renders 19,610. Counts in cards AND table cells. |
| Money | Intl.NumberFormat('es-MX',{style:'currency',currency:'MXN',maximumFractionDigits:0}) → $19,610. Right-aligned, type-mono (tabular). Centavos only if the source carries them. |
| Dates | Intl.DateTimeFormat('es-MX',{day:'numeric',month:'short',year:'numeric'}) → 27 jun 2026. type-mono text-fg-muted (tabular). |
| Badges / tags | Categorical fields render as Badge (@tedos/ui/admin ui/badge.tsx) — variants success info warn danger muted. Categories, never the accent (color-scope: console accent = Amber). |
| Accent budget | These are read-only glances → default zero Amber moments. If one is wanted, it lives in module nav (active section under-rule), never in a table cell (G17). |
| Row interaction | Row click → DetailPanel (keep). hover:bg-elevated, full-row <button>, focus-visible. |
| 4 states | loading (skeletal, layout-matched) · empty (honest, per § below) · error (ReadError) · success. Distinguish "no rows at all" from "no filter matches." |
Component pattern
Two valid hosts, pick per surface:
- #963 today ships
SectionDataTable(theDataTableTanStack family) — keep it; it gives search/sort/columns + the server pagination already wired (#965/#966). The redesign below changes the columns, not the host. - The admin grid-table (hand-rolled header+grid, as
enrollments-view/payments-view) is the alternative for a small capped sample. Either way the column language below is the contract.
Grid/tokens: bg-surface rows in a rounded-lg border border-border-default shell; text-fg-primary
headline, text-fg-secondary secondary, text-fg-muted for id/meta. Column headers = type-meta.
Headline cell = text-sm font-medium. Grid-over-flex (Grid v2 G1).
Educación
Alumnos — person (no join)
| # | Column | Source | Type | Notes |
|---|---|---|---|---|
| 1 | ID | id | id | First, muted mono, short8 |
| 2 | Nombre | full_name | primary | text-sm font-medium; email as the secondary line under it |
| 3 | Contacto | email ?? phone | secondary | em-dash when both null |
| 4 | Teléfono | phone_verified | badge | success "Verificado" · muted "Sin verificar" |
| 5 | Alta | created_at | date | es-MX, mono tabular, hide < sm |
Section count: total formatted es-MX (19,610).
Cursos — offering (no join)
| # | Column | Source | Type | Notes |
|---|---|---|---|---|
| 1 | ID | id | id | short8, muted mono |
| 2 | Curso | title | primary | |
| 3 | Tipo | kind | badge info | category (En vivo / Grabado / Posgrado) |
| 4 | Estado | status | badge | success activo · warn borrador · muted archivado |
Inscripciones — enrollment ⋈ person ⋈ offering
Joined — this is the headline fix ("en nombre solo veo un id").
| # | Column | Source | Type | Notes |
|---|---|---|---|---|
| 1 | ID | id | id | short8, muted mono |
| 2 | Alumno · Curso | person.full_name (join) + offering.title (join) | primary + secondary | name headline, course as the secondary line |
| 3 | Canal | channel | chip | label map (En vivo / Grabado / Posgrado) |
| 4 | Fecha | created_at | date | es-MX, hide < sm |
| 5 | Estado | state | badge | success active · warn pending (extend completed=info / cancelled=danger) |
Finanzas
Pagos — payment ⋈ person ⋈ offering (via enrollment) ⋈ advisor
The richest join; mirrors payments-view plus the new Asesor (#983).
| # | Column | Source | Type | Notes |
|---|---|---|---|---|
| 1 | ID | id | id | short8, muted mono |
| 2 | Alumno · Curso | person.full_name (join) + offering.title (join via enrollment_id) | primary + secondary | name headline, course secondary line |
| 3 | Asesor | advisor.name (join via advisor_id) | text | em-dash when null |
| 4 | Método | method | badge | card→Tarjeta · spei→SPEI · bank→Transferencia · cash→Efectivo |
| 5 | Conciliación | reconciliation_state | badge | success reconciled · info matched · warn pending · danger unmatched |
| 6 | Monto | amount | money | renames the ambiguous "Meta" → "Monto"; $19,610 MXN, right-aligned, mono tabular |
| 7 | Fecha | created_at | date | es-MX, hide < sm |
Optional CFDI badge (cfdi_state) per payments-view CFDI_META. Optional summary tiles above the
table derive from the page — label them as the page sample, not the full ledger, or omit until a
server aggregate exists (never imply a total we don't have).
Facturas / Conciliación — honest empty
No invoice/reconciliation source is wired yet. Render the empty state only (EmptyState) — never
a fake table. Copy: "Facturas — próximamente. La conciliación de CFDI por cliente aún no está conectada."
Contract impact (for Backend — drives the spec, not Designer's to build)
Today's ConsoleEntityRow { label, secondary, meta } can't carry typed columns or badges. Two paths;
recommend B:
- A (minimum): keep the shape but (1)
enrollment.label/payment.label= the joined person name, (2)secondary= the joined course title, (3)meta= the raw amount/date (FE formats), (4) move id out of the headline. Cheapest; loses per-column badges. - B (recommended): give each entity kind a typed column set — joins resolved server-side
(
person.full_name,offering.title,advisor.name) and abadges: {label, tone}[]field per row so categorical fields (state, method, reconciliation, phone-verified) carry tone, not strings. Theconsole-routes.tsqueries becomeleftJoins instead of rawselect().from(...). Formatting stays client-side (Frontend ownsIntl); Backend supplies raw values + resolved names + the badge taxonomy.
Either way the column language above is fixed; the contract just delivers names + tones instead of
ids + free strings. Files: console-routes.ts (the :resource handler), packages/shared/src/api/console.ts
(ConsoleEntityRow), reference designs enrollments-view.tsx / payments-view.tsx.
Module nav — readability quick-wins (full refactor = #969, not designed here)
- Active section = the screen's one Amber moment:
accent-softactive row + a 2pxaccentunder-rule (G17). Inactive =text-fg-secondary, hovertext-fg-primary. - Nav labels use
type-nav-label(mono, UPPER, 0.1em); active appendsfont-semibold. - Breadcrumb reads Org / Módulo / Sección; group sections under their module label, not a flat list.