think
16px
820px

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

  1. FieldListSection.tsx — full legacy field-review-card.tsx language: StatusIcon (CheckCircle2 green LOCKED / BadgeCheck blue confirmed / AlertTriangle red-if-<70-amber-otherwise unconfirmed) wrapped in ConfidenceTooltip (adapted at the call site — confidence / 100 — per the brief, confidenceTier/ConfidenceTooltip itself 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); EditHistoryIndicator reuse (documented limitation: the contract's ReviewFieldVM has no originalValue, unlike legacy ReviewField, 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; row title="Klik untuk lihat posisi di dokumen"; data-unconfirmed="true" mirrors submit-gate.ts's per-field rule exactly. LOCKED fields under manualConfirmAll keep the confirm affordance (existing behavior, preserved + tested).
  2. frontend/src/lib/currency.tsformatRupiah, digit-string tolerant, null/no-digits → null. Dedupes PpPendirianReviewPage.tsx displayValueFor, pp-asal-card.tsx formatModal, pp-perubahan-diff-section.tsx fmt — all three now import it; their existing test suites stayed green untouched (pure refactor). Wired into RosterSection's col.format === "currency" path too (previously dead code per the survey).
  3. ReviewProgressHeader.tsx (new, 216 lines — under the 400-line budget) — sticky bar, SVG donut of the current segment, "Bagian i/n" + name + x/y selesai pill, 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 generic scrollToFirstUnconfirmed()), CTA collapses to exactly 2 states per the per-field model: amber "Isian berikutnya" → emerald "Finalisasi" once submitGate(data).ready. Segments = every measurable data.sections entry (field-list/roster) plus a synthetic "pernyataan" segment whenever data.declarations is non-empty — this is generic (declarations is a first-class ReviewData field, not PP-specific), not a PP special case. New section-progress.ts factors the confirmed/total math (mirrors submit-gate.ts's exemption rules exactly) so the header, ReviewEngine's badges, and any future consumer can't drift from the actual finalize gate.
  4. StepsStepIndicator + 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).
  5. ValidationBanner.tsx — adopted legacy validation-results.tsx design (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-only overridden/overrideReason strike-through (legacy never had an override concept — this is new-model logic layered on legacy's visuals, not a straight port). Added optional onRevalidateReviewEngine now takes and forwards it; the V2 page wires it to a useMutation POSTing /api/submissions/:id/revalidate (endpoint already existed, confirmed at backend/src/routes/submissions.ts:2554) then invalidating ["review-engine", id].
  6. Pernyataan — interactive per-declaration Checkbox cards (legacy pp-attestation-block.tsx idiom) directly on the V2 page. Local attest state seeded from sectionApprovals.pernyataan via the same all-or-nothing useEffect pattern as the legacy page (documented inline: per-item state isn't persisted — every declarations[i].checked already carries the same section-level flag from the projector). Checking all → approveSection("pernyataan", true); unchecking any while approved → approveSection("pernyataan", false). Decision: dropped SectionApprovalBar from this card (legacy's pernyataan card never had a manual approve button either — "Bagian ini otomatis disetujui setelah semua dicentang") in favor of a static x/y + "Disetujui" indicator; SectionApprovalBar.tsx itself is untouched and still covered by its own test in approval-and-validation.test.tsx.
  7. RosterSection.tsx — humanized Indonesian DETAIL_LABELS for known BO detail keys; kriteria rendered as resolved group-label chips ("Grup A: <label>") from detail.kriteriaCatalog against detail.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 when detail.isFounder, emerald "Dikonfirmasi" / amber "Perlu konfirmasi" outline badge); data-unconfirmed="true" on the <tr> for unconfirmed rows (flag-independent, matching submit-gate.ts's roster rule — needed so the progress header's advance-scroll actually finds unconfirmed KBLI/BO rows, not just fields).
  8. Polishdark: 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 renders ExtractionErrorState (existing shared component) instead of a bespoke Card. ReviewEngine.tsx's section wrapper now carries id="review-section-<key>" (so the header's IntersectionObserver + advance-scroll can find it), an emerald border-emerald-200 dark:border-emerald-900 once section-progress.ts reports the section fully confirmed, and a live x/y Badge.
  9. Optimistic updates — new frontend/src/hooks/use-pp-review-engine-mutations.ts (usePpOptimisticConfirmField, usePpOptimisticConfirmRosterRow), each a real useMutation with onMutate (cache-patch ["review-engine", submissionId]), onError (rollback to the captured snapshot), onSettled (invalidate). These replace usePpConfirmField/usePpUnconfirmField/useSetKbliConfirmed/usePpSetBoConfirmed on the V2 page only — the legacy page keeps using the original (non-optimistic) hooks from use-pp-pendirian-review.ts, untouched. Extracted into their own file (rather than page-local closures) specifically so they're unit-testable against a real QueryClient without fighting the page's useReviewEngineData mock.

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, added data-unconfirmed assertions (incl. one proving manualConfirmAll drops the LOCKED exemption) and an empty-value placeholder test.
  • Same file: initial rewrite reused legacy's field.value != null as the empty-check gate, which broke the pre-existing "Rp 50.000.000" fixture (value: null, displayValue: "Rp 50.000.000") — legacy ReviewField never had that displayValue-without-value shape. Fixed by gating emptiness on displayValue ?? value instead of value alone.
  • RosterSection.test.tsx: the old "2 kriteria" count assertion broke by design (item 7's explicit ask); fixture's kriteriaCatalog was also broken ({id:1} with no group/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, and data-unconfirmed coverage.
  • 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 real QueryClient (seeded cache, controllable apiFetch promise) 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, pernyataan SectionApprovalBar click) broke by design — replaced with confirmRosterRowMutate/confirmFieldMutate wiring assertions and an interactive-checkbox flow (click 1 of 2 → no approve call; click both → approveSection fires). Added StepIndicator/progress-header presence and a revalidate-button integration test (initially failed on a bare synchronous assertion — the mutation dispatches apiFetch after a microtask tick even against a mocked hook wiring the real useMutation; fixed with await waitFor(...), same pattern the dedicated mutations-hook test already used).

New files

  • frontend/src/lib/currency.ts + test
  • frontend/src/components/review-engine/section-progress.ts + test (fieldListProgress, rosterProgress, declarationsProgress, sectionProgress, segmentSectionIds)
  • frontend/src/components/review-engine/ReviewProgressHeader.tsx + test
  • frontend/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 no originalValue field, unlike legacy ReviewField. Not fixed (would mean touching contract/review.ts, explicitly out of scope). Flagging for whoever picks up the contract next.
  • Dropped SectionApprovalBar usage 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.ts duplicates the kbli/pemilik-manfaat endpoint-selection branching that already lives in use-pp-pendirian-review.ts's useSetKbliConfirmed/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.