think
16px
820px

Wave 4 — BO presentation architecture (dedicated cards vs. universal table)

Efran's ruling from his 4th smoke test (with screenshots): the Pemilik Manfaat rows rendered through the universal roster table had "very unclear separation" and a "bad detail dropdown." Directive: the universal table stays clean/standard; PP's BO roster gets its own component instead of forcing the table to bend to fit it. Design reference: legacy frontend/src/components/pp/pp-pemilik-manfaat-section.tsx (per-person card shell, header layout, badges, kriteria groups).

1 — engine hook: per-section-KEY renderer override

ReviewEngine.tsx resolved renderers by section kind only (renderers?: Partial<Record<SectionVM["kind"], SectionRenderer>>). Added a second, higher-priority resolution path: sectionRenderers?: Record<string, SectionRenderer>, keyed by section.key, checked before the kind registry (and before the kind-level renderers override). Resolution is now:

const Renderer = sectionRenderers?.[section.key] ?? registry[section.kind];

This lets a page give exactly one section a bespoke component while every other section of the same kind keeps the universal renderer — the mechanism itself carries no PP/BO knowledge.

Tests added: key-override wins over the kind fallback; key-override wins over a kind-level renderers override for the same section; a key override only affects the matching section, not every section of that kind.

2 — PpPemilikManfaatCards (new, PP-specific)

New file frontend/src/components/pp/pp-pemilik-manfaat-cards.tsx (NOT under review-engine/ — it's a flow-specific SectionRenderer, same shape as RosterSection/FieldListSection but consumed via the new override, not the kind registry). One card per BO row, legacy shell (rounded-lg border bg-white p-4 shadow-sm dark:bg-zinc-900, space-y-4 between cards for clear separation):

  • Header: name (font-medium, "Tanpa nama" italic fallback) + a muted text-xs line "<jenisIdentitas> · <nomorIdentitas> · <kewarganegaraan>" + a "Pendiri" Badge when detail.isFounder + a Lock icon when row.status === "LOCKED" + the confirm icon-button rightmost — same icon language, same rowConfirmGuard contract as the table (disabled + reason-as-title until kriteria chosen; unconfirm is a plain, never-gated toggle; data-unconfirmed="true" marker on the card).
  • Kriteria: KriteriaPicker rendered inline, always visible (no toggle), wrapped in a bordered box that gets the amber "Wajib dipilih sebelum konfirmasi" treatment when kriteriaIds is empty — same visual language the wave-3 always-visible region used, just moved here.
  • Identity details: a proper 2-column definition grid (NPWP, Tempat Lahir, Tanggal Lahir, Alamat [full-width], Negara Asal, Hubungan, NPWP Terverifikasi) via the new shared DetailGrid — always visible, no Detail toggle (the card has room).

Empty/missing-roster fallbacks mirror the table's language ("Tidak ada data" for a missing rosterKey, "Belum ada pemilik manfaat." for zero rows).

3 — shared DetailGrid.tsx (new, review-engine/)

Factored DETAIL_LABELS/detailLabel()/formatDetailValue() (humanized labels, em-dash for null, Ya/Tidak for booleans, now also comma-joins arrays) plus a new DetailGrid component out of RosterSection.tsx into their own module, so both the table's Detail-expander and the cards' identity grid render the exact same "muted text-[11px] label above a text-sm value" idiom instead of two independent implementations drifting apart.

4 — PpPendirianReviewPageV2.tsx wiring

const SECTION_RENDERERS = { pemilik_manfaat: PpPemilikManfaatCards };
...
<ReviewEngine data={data} actions={actions} sectionRenderers={SECTION_RENDERERS} onRevalidate={...} />

Every other section (KBLI, field-lists) is untouched and still resolves through the kind registry.

5 — RosterSection.tsx reverted to a clean, domain-agnostic standard table

Removed everything wave 3 added specifically for BO:
- The always-visible inline kriteria region (kriteria-region-<id> strip) + its amber empty-state, and the KriteriaPicker/group-label-chip rendering that lived inside it.
- The isFounder-driven "Pendiri" Badge in the Aksi cell — isFounder is PP-specific detail-key knowledge; a universal table shouldn't know it exists. (Lock stays — it's generic, keyed off row.status.)

Kept, unchanged in behavior: icon confirm button rightmost in Aksi, rowConfirmGuard support (still fully generic — any roster/page can use it), the Detail expander, col.format === "currency" cell formatting, the Lock indicator. detailEntries() no longer special-cases kriteriaIds — every detail key (including a stray kriteriaIds array, should any non-PP roster ever carry one) now renders through the same generic DetailGrid, restyled per item 3 above instead of the old flat label: value text lines. KBLI's own rendering (columns, cells, confirm, currency) is visually unchanged — it never carried a detail object with a kriteria catalog.

Tests

  • New: DetailGrid.test.tsx (label/value rendering, fullWidth span, formatting helpers), pp-pemilik-manfaat-cards.test.tsx (20 cases: per-row cards not a table, header pieces incl. Pendiri/Lock/Tanpa-nama, no Detail toggle anywhere in the card, DetailGrid values + fullWidth Alamat, kriteria picker present + wired + amber-when-empty, guard-disabled confirm / never-gated unconfirm, data-unconfirmed, multi-card separation, empty/missing-roster fallbacks).
  • Updated ReviewEngine.test.tsx (+3 override tests), RosterSection.test.tsx (removed the wave-2/wave-3 BO-picker describe blocks entirely; added regression tests proving the inline region/amber state/Pendiri badge/checkbox picker are all gone even when setRosterRowDetail is wired, plus a DetailGrid-idiom assertion on the Detail-expander), PpPendirianReviewPageV2.test.tsx (+1 test asserting the pemilik_manfaat section renders a [data-bo-card-id] card with no <table> inside it).
  • The pre-existing page-level BO wiring tests (checkbox toggle, kriteria-gated confirm) needed no changes — they assert on accessible names/roles (aria-label="konfirmasi baris bo-1", checkbox name), which the card preserves verbatim from the table's language, so they now exercise the card renderer instead of RosterSection and still pass.

Gates

cd frontend && npx vitest run    → 682/682 pass (107 files)
cd frontend && bunx tsc --noEmit → clean

Backend untouched. contract/review.ts, flow-engine/**, and legacy pages untouched.

One judgment call worth flagging

The brief left it open whether the amber-empty-state kriteria box in the card should require actions.setRosterRowDetail to be wired (falling back to a read-only chip display like wave-3's table region did) or just always assume it's wired. I went with "always assume wired" — PpPemilikManfaatCards calls actions.setRosterRowDetail?.(...) directly with no read-only fallback UI, since this component only exists for the one page (PpPendirianReviewPageV2) that always wires it. If a future consumer of this card needs the unwired read-only path, that's a small follow-up.