PERALIHAN_PP_KE_PT — Task 2 (V2 review page, frontend) report
Status: DONE
Commits (branch refactor/tidyup-foundation, not pushed):
- 3310f01 — test(peralihan): T2 mutations hook — asal-PP picker + bespoke finalize (TDD)
- c109524 — feat(peralihan): T2 — PpPeralihanPtReviewPageV2 + route (ReviewData engine)
What was built
frontend/src/hooks/use-peralihan-review-engine-mutations.ts—usePeralihanMatchPp(POST/api/pp/peralihan-pt/:id/match-pp, invalidates the engine cache key),usePeralihanFinalizeEngine(POST/api/pp/peralihan-pt/:id/finalize, mapsWRONG_STATUS/PP_ASAL_NOT_MATCHED/PP_ASAL_INACTIVE/VALIDATION_FAILEDto Indonesian toasts,VALIDATION_FAILED's bare ruleCodes resolved to human labels via the cachedReviewData), andusePeralihanRegistrySearch(a verbatim alias of the legacyusePpPeralihanPtRegistry— engine-agnostic, no new logic). Per-field/roster confirm and field edit (usePtOptimisticConfirmField/usePtOptimisticConfirmRosterRow/usePtEditField) are imported directly fromuse-pt-review-engine-mutations.tsin the page — verified byte-identical against the projector's rosterKeys/detail.roleliterals (kbli/pengurus[direksi,komisaris]/pemegang_saham), so no wrapper or new test needed.frontend/src/hooks/__tests__/use-peralihan-review-engine-mutations.test.tsx— 9 tests.frontend/src/pages/pp/PpPeralihanPtReviewPageV2.tsx— the review page, assembled fromReviewProgressHeader+ReviewEngine(65/35 split withReviewPdfViewer, noPtDocumentPicker— this flow has exactly one document, so the grouped picker would add empty chrome) + finalize footer with the grouped-chip unconfirmed summary (groupUnconfirmedBySection, reused as-is).frontend/src/pages/pp/__tests__/PpPeralihanPtReviewPageV2.test.tsx— 18 tests.frontend/src/routes.tsx— new routept/peralihan-pp/:submissionId/review-v2→PpPeralihanPtReviewPageV2, added alongside (not replacing) the legacy.../reviewroute.
The bespoke piece: pp_asal sectionRenderer
A page-local override for the registry-entity kind (sectionRenderers={{ pp_asal: ... }}), porting the legacy PpAsalCard's two states:
- Matched: DetailGrid groups per the projector's Badan Usaha/Pemilik groups, with the NIK field re-masked client-side (maskNik, ported verbatim from pp-asal-card.tsx:38-42 — T1 review Minor), plus a "Dicocokkan otomatis"/"Dipilih manual" badge from entity.matchedBy.
- Not-found: candidate chips from entity.candidates (label already carries the fuzzy score — the T1 projector's contract-gap resolution) + a debounced registry search (usePeralihanRegistrySearch) — selecting either dispatches usePeralihanMatchPp, which invalidates the engine key so the projector re-reads the new snapshot. No engine/contract change (per spec: "pickers act via existing flow endpoints").
PpAsalRenderer is defined at module scope, wired into the page via a useCallback-memoized wrapper (same idiom as PpPembubaranReviewPageV2's pernyataan/alasan renderers) — an inline closure would get a fresh identity every page render and React would remount the subtree, dropping the search Input's keystrokes on every re-render (the same failure class the 2026-07-03 Berakhirnya fix addressed for ManualEditor/ClipCard). readOnly disables the not-found state's Input/chip/result buttons rather than hiding them, per SectionRendererProps' contract.
Key decisions
- No document picker.
pdfSources.length === 1always for this flow (one akta) —PtDocumentPickerexists to group multiple per-person docs (PT's KTP/NPWP set), which this flow has none of, so the viewer renders directly (same asPpPendirianReviewPageV2). - Finalize gating stays advisory-only client-side. The button is gated purely by
submitGate(data).ready(per-field/roster confirmation); the server's own 3 gates (asal-matched, live dissolution re-check, validation FAIL) are enforced on POST and surfaced via toasts — no client-side pre-check duplicates the asal-match gate. This matches the brief's literal wording; flagged below as a UX consideration. - No identity section, no phone/contact-entry hooks, no
bukti_setorroster branch — none of these exist for this flow (survey §3/§7), so the PT page's corresponding logic was dropped rather than ported unused. - Completed-state banner mirrors the legacy
PeralihanCompletedBannercopy/destination (Unduh Packet→GET /api/pp/peralihan-pt/:id/packet), withCARD_DONE_GLOW_STRONGadded per the done-glow idiom.
Gates
tsc --noEmit(frontend): clean, both after the hooks commit and after the page/route commit.- Full
npx vitest run: 882 pass / 120 files (baseline 855/118 + 9 hook tests + 18 page tests, no regressions). - Backend untouched (no spot-run needed per the brief).
Concerns / follow-ups
- Finalize-gate UX: the Finalisasi button can be enabled by
submitGate(all fields/rows confirmed) whilepp_asalis still unmatched — clicking it will 409PP_ASAL_NOT_MATCHEDand toast, rather than being pre-emptively disabled. This mirrors the brief's literal instruction ("submitGate all-confirmed client-side + the server's own 3 gates on POST") and keeps the frontend gate a pure mirror of PT's, but is a minor UX rough edge worth a look before this route flips live — a trivial follow-up would begate.ready && data.registryEntities.pp_asal?.matchedBy != null. notaris_pengganti(hidden-when-empty field-list, per T1) was not exercised in the page's own fixtures (it wasn't in T1's fixtures either) — low risk, same flag T1 already raised.- Route is net-new and NOT wired into any navigation/menu — reachable only by direct URL for now, consistent with "smoke-first, not flipped" for every other tenant's T2/T3 stage.