think
16px
820px

PERALIHAN_PP_KE_PT — Task 2 (V2 review page, frontend) report

Status: DONE

Commits (branch refactor/tidyup-foundation, not pushed):
- 3310f01test(peralihan): T2 mutations hook — asal-PP picker + bespoke finalize (TDD)
- c109524feat(peralihan): T2 — PpPeralihanPtReviewPageV2 + route (ReviewData engine)

What was built

  • frontend/src/hooks/use-peralihan-review-engine-mutations.tsusePeralihanMatchPp (POST /api/pp/peralihan-pt/:id/match-pp, invalidates the engine cache key), usePeralihanFinalizeEngine (POST /api/pp/peralihan-pt/:id/finalize, maps WRONG_STATUS/PP_ASAL_NOT_MATCHED/PP_ASAL_INACTIVE/VALIDATION_FAILED to Indonesian toasts, VALIDATION_FAILED's bare ruleCodes resolved to human labels via the cached ReviewData), and usePeralihanRegistrySearch (a verbatim alias of the legacy usePpPeralihanPtRegistry — engine-agnostic, no new logic). Per-field/roster confirm and field edit (usePtOptimisticConfirmField/usePtOptimisticConfirmRosterRow/usePtEditField) are imported directly from use-pt-review-engine-mutations.ts in the page — verified byte-identical against the projector's rosterKeys/detail.role literals (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 from ReviewProgressHeader + ReviewEngine (65/35 split with ReviewPdfViewer, no PtDocumentPicker — 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 route pt/peralihan-pp/:submissionId/review-v2PpPeralihanPtReviewPageV2, added alongside (not replacing) the legacy .../review route.

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

  1. No document picker. pdfSources.length === 1 always for this flow (one akta) — PtDocumentPicker exists to group multiple per-person docs (PT's KTP/NPWP set), which this flow has none of, so the viewer renders directly (same as PpPendirianReviewPageV2).
  2. 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.
  3. No identity section, no phone/contact-entry hooks, no bukti_setor roster branch — none of these exist for this flow (survey §3/§7), so the PT page's corresponding logic was dropped rather than ported unused.
  4. Completed-state banner mirrors the legacy PeralihanCompletedBanner copy/destination (Unduh PacketGET /api/pp/peralihan-pt/:id/packet), with CARD_DONE_GLOW_STRONG added 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) while pp_asal is still unmatched — clicking it will 409 PP_ASAL_NOT_MATCHED and 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 be gate.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.