Cluster B report — beauty parity restyle of the ReviewData engine
Status: DONE
Commit: (see bottom)
Tests: frontend npx vitest run → 597/597 pass (103 files). bunx tsc --noEmit (frontend) → clean. Backend bun run test → 1970 pass / 1 skip / 0 fail (247 files) — untouched, run as the drift guard the brief asked for.
Scope discipline
All styling/behavior went into the shared engine components (frontend/src/components/review-engine/*) plus one new engine-adjacent hooks file; PpPendirianReviewPageV2.tsx only gained layout/wiring. Did not touch contract/review.ts, flow-engine/**, akuisisi/peleburan/laporan-rups, or the legacy PpPendirianReviewPage.tsx's behavior (only its displayValueFor was refactored to import the new shared formatRupiah, output-identical, per the brief's explicit read-only-refactor allowance).
Work items 1–9 — what shipped
- FieldListSection.tsx — full legacy
field-review-card.tsxlanguage:StatusIcon(CheckCircle2 green LOCKED / BadgeCheck blue confirmed / AlertTriangle red-if-<70-amber-otherwise unconfirmed) wrapped inConfidenceTooltip(adapted at the call site —confidence / 100— per the brief,confidenceTier/ConfidenceTooltipitself untouched, still 0–1 for its other 7 consumers); card shell (bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md, green-50/30 LOCKED tint, blue-50/30 confirmed tint);EditHistoryIndicatorreuse (documented limitation: the contract'sReviewFieldVMhas nooriginalValue, unlike legacyReviewField, so that row always reads "(tidak ada)" until the contract gains one — flagged below, not fixed, out of scope); icon-only ghost confirm/edit buttons with legacy titles/hover classes; rowtitle="Klik untuk lihat posisi di dokumen";data-unconfirmed="true"mirrors submit-gate.ts's per-field rule exactly. LOCKED fields undermanualConfirmAllkeep the confirm affordance (existing behavior, preserved + tested). frontend/src/lib/currency.ts—formatRupiah, digit-string tolerant, null/no-digits → null. DedupesPpPendirianReviewPage.tsxdisplayValueFor,pp-asal-card.tsxformatModal,pp-perubahan-diff-section.tsxfmt— all three now import it; their existing test suites stayed green untouched (pure refactor). Wired intoRosterSection'scol.format === "currency"path too (previously dead code per the survey).ReviewProgressHeader.tsx(new, 216 lines — under the 400-line budget) — sticky bar, SVG donut of the current segment, "Bagian i/n" + name +x/y selesaipill, segmented per-section bars (emerald done / amber partial / zinc empty, current segment halo via inline styles), "Isian berikutnya" scrolling to the first[data-unconfirmed="true"]in the current section with a 2s amber ring flash (falls back to the genericscrollToFirstUnconfirmed()), CTA collapses to exactly 2 states per the per-field model: amber "Isian berikutnya" → emerald "Finalisasi" oncesubmitGate(data).ready. Segments = every measurabledata.sectionsentry (field-list/roster) plus a synthetic "pernyataan" segment wheneverdata.declarationsis non-empty — this is generic (declarations is a first-classReviewDatafield, not PP-specific), not a PP special case. Newsection-progress.tsfactors the confirmed/total math (mirrorssubmit-gate.ts's exemption rules exactly) so the header,ReviewEngine's badges, and any future consumer can't drift from the actual finalize gate.- Steps —
StepIndicator+stepperProps(PP_STEP_LABELS.PENDIRIAN_PP, isCompleted ? 4 : 2)at the top of the V2 page, same API the legacy page uses (PpPendirianReviewPage.tsx:331-336). - ValidationBanner.tsx — adopted legacy
validation-results.tsxdesign (STATUS_CONFIG for all 4 statuses, left-accent border + tint, collapsed to WARNING/FAIL by default with the"{n} lulus, {m} dilewati — tampilkan semua"toggle, green "Semua validasi lulus" caption when clean) while keeping the engine-onlyoverridden/overrideReasonstrike-through (legacy never had an override concept — this is new-model logic layered on legacy's visuals, not a straight port). Added optionalonRevalidate—ReviewEnginenow takes and forwards it; the V2 page wires it to auseMutationPOSTing/api/submissions/:id/revalidate(endpoint already existed, confirmed atbackend/src/routes/submissions.ts:2554) then invalidating["review-engine", id]. - Pernyataan — interactive per-declaration Checkbox cards (legacy
pp-attestation-block.tsxidiom) directly on the V2 page. Localatteststate seeded fromsectionApprovals.pernyataanvia the same all-or-nothinguseEffectpattern as the legacy page (documented inline: per-item state isn't persisted — everydeclarations[i].checkedalready carries the same section-level flag from the projector). Checking all →approveSection("pernyataan", true); unchecking any while approved →approveSection("pernyataan", false). Decision: droppedSectionApprovalBarfrom this card (legacy's pernyataan card never had a manual approve button either — "Bagian ini otomatis disetujui setelah semua dicentang") in favor of a staticx/y+ "Disetujui" indicator;SectionApprovalBar.tsxitself is untouched and still covered by its own test inapproval-and-validation.test.tsx. - RosterSection.tsx — humanized Indonesian
DETAIL_LABELSfor known BO detail keys; kriteria rendered as resolved group-label chips ("Grup A: <label>") fromdetail.kriteriaCatalogagainstdetail.kriteriaIds, never a bare count (this was the literal survey §9 ask); boolean detail values render "Ya"/"Tidak"; a status Badge per row (Pendiri secondary badge whendetail.isFounder, emerald "Dikonfirmasi" / amber "Perlu konfirmasi" outline badge);data-unconfirmed="true"on the<tr>for unconfirmed rows (flag-independent, matchingsubmit-gate.ts's roster rule — needed so the progress header's advance-scroll actually finds unconfirmed KBLI/BO rows, not just fields). - Polish —
dark:variants added everywhere new literal-color classes were introduced (zinc/green/blue/amber/emerald, mirroring legacy's palette); token-based classes (bg-card,bg-muted,border) left alone since they're already theme-aware. V2 page's error state now rendersExtractionErrorState(existing shared component) instead of a bespoke Card.ReviewEngine.tsx's section wrapper now carriesid="review-section-<key>"(so the header'sIntersectionObserver+ advance-scroll can find it), an emeraldborder-emerald-200 dark:border-emerald-900oncesection-progress.tsreports the section fully confirmed, and a livex/yBadge. - Optimistic updates — new
frontend/src/hooks/use-pp-review-engine-mutations.ts(usePpOptimisticConfirmField,usePpOptimisticConfirmRosterRow), each a realuseMutationwithonMutate(cache-patch["review-engine", submissionId]),onError(rollback to the captured snapshot),onSettled(invalidate). These replaceusePpConfirmField/usePpUnconfirmField/useSetKbliConfirmed/usePpSetBoConfirmedon the V2 page only — the legacy page keeps using the original (non-optimistic) hooks fromuse-pp-pendirian-review.ts, untouched. Extracted into their own file (rather than page-local closures) specifically so they're unit-testable against a realQueryClientwithout fighting the page'suseReviewEngineDatamock.
RED → GREEN evidence (TDD, selected)
FieldListSection.test.tsx: exact-name button queries ("Konfirmasi","Batal Konfirmasi") broke immediately against the new icon-only titles ("Konfirmasi nilai ini","Terkonfirmasi — klik untuk batalkan konfirmasi") — updated to the new title strings, addeddata-unconfirmedassertions (incl. one provingmanualConfirmAlldrops the LOCKED exemption) and an empty-value placeholder test.- Same file: initial rewrite reused legacy's
field.value != nullas the empty-check gate, which broke the pre-existing "Rp 50.000.000" fixture (value: null, displayValue: "Rp 50.000.000") — legacyReviewFieldnever had that displayValue-without-value shape. Fixed by gating emptiness ondisplayValue ?? valueinstead ofvaluealone. RosterSection.test.tsx: the old "2 kriteria" count assertion broke by design (item 7's explicit ask); fixture'skriteriaCatalogwas also broken ({id:1}with nogroup/label— a pre-existing test-fixture gap the old bare-count renderer never exercised) — fixed the fixture to the real projector shape and asserted"Grup A: ..."/"Grup B: ..."chips. Added currency-format, status-badge, anddata-unconfirmedcoverage.approval-and-validation.test.tsx: "renders nothing when there are no FAIL/WARNING" broke by design (item 5 — now shows the "Semua validasi lulus" caption instead of an empty banner); split into "no results at all → null" + "results but none need attention → caption" and added toggle/revalidate coverage.ReviewEngine.test.tsx: added id/badge/border/onRevalidate-forwarding coverage; no existing assertion broke (badge/border are additive).use-pp-review-engine-mutations.test.tsx(new): 6 tests against a realQueryClient(seeded cache, controllableapiFetchpromise) proving the onMutate patch lands before the network call resolves, rollback on rejection, and correct confirm/unconfirm + kbli/pemilik-manfaat endpoint selection. All passed first run.PpPendirianReviewPageV2.test.tsx: the two hook-mutate assertions (setKbliConfirmedMutate, pernyataanSectionApprovalBarclick) broke by design — replaced withconfirmRosterRowMutate/confirmFieldMutatewiring assertions and an interactive-checkbox flow (click 1 of 2 → no approve call; click both →approveSectionfires). Added StepIndicator/progress-header presence and a revalidate-button integration test (initially failed on a bare synchronous assertion — the mutation dispatchesapiFetchafter a microtask tick even against a mocked hook wiring the realuseMutation; fixed withawait waitFor(...), same pattern the dedicated mutations-hook test already used).
New files
frontend/src/lib/currency.ts+ testfrontend/src/components/review-engine/section-progress.ts+ test (fieldListProgress,rosterProgress,declarationsProgress,sectionProgress,segmentSectionIds)frontend/src/components/review-engine/ReviewProgressHeader.tsx+ testfrontend/src/hooks/use-pp-review-engine-mutations.ts+ test
Concerns
EditHistoryIndicator's "Nilai asli OCR" row always shows "(tidak ada)" in the engine now —ReviewFieldVM(contract) has nooriginalValuefield, unlike legacyReviewField. Not fixed (would mean touchingcontract/review.ts, explicitly out of scope). Flagging for whoever picks up the contract next.- Dropped
SectionApprovalBarusage from the V2 page's pernyataan card (kept the component itself, still tested standalone) in favor of matching legacy's actual no-manual-button pernyataan UX now that checking-all auto-approves. This is a deliberate behavior change from cluster A's state, not a regression — flagging since the ReviewEngine.tsx doc-comment ("the engine still uses [SectionApprovalBar] for... pernyataan") now describes the component's continued existence/test coverage, not the V2 page's current usage of it. use-pp-review-engine-mutations.tsduplicates the kbli/pemilik-manfaat endpoint-selection branching that already lives inuse-pp-pendirian-review.ts'suseSetKbliConfirmed/usePpSetBoConfirmed— deliberate (optimistic onMutate can't be attached at.mutate()call time in TanStack Query v5, only at hook-definition time, and the brief was explicit that legacy page behavior must not change), but it's real duplication if a third consumer shows up; worth collapsing behind a shared endpoint-resolver function then.- Did not extend dark-mode coverage to every literal class in every review-engine file exhaustively — applied it wherever this pass touched/added markup (which is effectively all of it, since every component in scope was rewritten), consistent with "mirror legacy's zinc-900 palette" rather than a line-by-line audit.