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 mutedtext-xsline"<jenisIdentitas> · <nomorIdentitas> · <kewarganegaraan>"+ a "Pendiri"Badgewhendetail.isFounder+ a Lock icon whenrow.status === "LOCKED"+ the confirm icon-button rightmost — same icon language, samerowConfirmGuardcontract 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:
KriteriaPickerrendered inline, always visible (no toggle), wrapped in a bordered box that gets the amber "Wajib dipilih sebelum konfirmasi" treatment whenkriteriaIdsis 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 whensetRosterRowDetailis wired, plus a DetailGrid-idiom assertion on the Detail-expander),PpPendirianReviewPageV2.test.tsx(+1 test asserting thepemilik_manfaatsection 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", checkboxname), which the card preserves verbatim from the table's language, so they now exercise the card renderer instead ofRosterSectionand 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.