Information Architecture + Design Handover

XPrivate v2 Admin-MVP-1 · backoffice (greenfield) · Output 3 dari pre-sprint handover. Founder owns IA/UX, partner owns engineering implementation.

Ringkasan & Scope

Scope
8 epics admin-only (M1-M4 + A1 + A2 + M6 + M7)
Ship deadline
2026-06-30 (Tahun Ajaran Jul enrollment)
User
1-5 admin · ADMIN / OPS / FINANCE
Pages
52 (moderate trim) · routes English, UX writing Indonesian
Authority boundary: doc ini = founder product/IA/UX scope. Engineering implementation detail (DDL, lib config, query optimization) = partner authority. Saran impl di sini = inspiration, partner override freely.

Stack & Arsitektur

LayerPick
FrontendReact 19 + TanStack Start (SPA mode — client-only, NO SSR) + shadcn/ui + Tailwind v4 · preset b1MJ9vRwY6
TanStack universeForm + Query (client fetch) + Router + Table
ThemeLight mode only (iter-1)
APIHono /api/* + @hono/zod-openapi
Auth + RBACBetter Auth + Google OAuth · permission-list + Negation ACL
DB / ORMNeon Postgres 18 + CF Hyperdrive · Drizzle · uuidv7 · timestamptz (UTC)
StorageCloudflare R2 + S3 presigned URL (full private ACL)
i18nParaglide JS (BI iter-1, EN iter-2)
Runtime / obsCloudflare Workers + Wrangler · SigNoz
Pending partner reconcile: arch page partner masih tulis "React SSR" + "react-hook-form" → reconcile ke SPA mode + TanStack Form. Detail di partner-discussion doc.

Design Language

Anchor map per surface (cegah implicit-hybrid relitigation):

SurfaceAnchorKenapa
Pricing matrix, audit, tabel financeStripeTable chrome, finance accuracy, tabular-nums
Approval queue, state ops, Cmd+KLinearKeyboard-first, fast transition
CalendarCal.comCalendar = own pattern
Login, CRUD, utilityshadcn defaultMinimal opinion, ship fast

Density compact (power-user 4-8h/day), light default, mobile responsive (read-only + single-tap di ≤768px).

1 · Site Map per Role

3 role: ADMIN (super), OPS (day-to-day), FINANCE. Sidebar order (frequency-of-use):

🏠 Dashboard       📅 Schedule (M3)    👥 People (M2)
📊 Reports (M4)    💰 Pricing (M2)     📁 Master Data (M2)
🛡 HR (M6)         💼 Finance (M7)     🔍 Audit (A1)    ⚙ Settings (M1+A2)
SectionADMINOPSFINANCE
Dashboardallops widgetsfinance widgets
Schedule✓ full + bulk✓ fullread-only
People (Tutor/Siswa)✓ full + kontrak✓ CRUDread-only
Pricing (matrix/policy/config)✓ full✗ hiddenread-only
Reports → Tagihan✓ + bulk paid✓ + mark-paid✓ full + adjust
Reports → Honor✓ + bulk✗ hidden (privacy)✓ full
Master Data✓ CRUD✓ CRUD✗ hidden
HR (strike/terminate)✓ fullescalate only✗ hidden
Finance (period/refund/slip)✓ + reopen✗ hidden✓ (reopen=ADMIN)
Audit✓ fullscopedfinance scope
Settings (users/notif/cron)✓ full
Permission-gated, bukan role-gated: visibility resolved dari permission (Negation ACL). Multi-role = UNION otomatis. Dashboard widget + hero task auto-filter per permission, ter-grup per domain (Operasional/Keuangan). No role-switcher.

2 · Navigasi & Sidebar

3 · Page List (52 pages)

Routes English, UX writing Indonesian. Page-only routes — Sheet/Modal = component state (no own URL). Deep-link via query param kalo perlu (iter-2).

EpicPages (route)
M1 User Mgmt/login · /forgot-password · /reset-password/:token · /settings/admin-users · :id · /settings/profile
M2 People/tutors · /tutors/:id · /students · /students/:id
M2 Master Data/master-data/subjects (toggle has_levels + define levels di create/detail; ubah = restructure, warn kalo udah priced/booked) · :id · /grades · /segments · /categories · /tahun-ajaran + :id (CRUD, status DRAFT/ACTIVE/CLOSED, GIST overlap, delete restricted)
M2 Pricing reshaped/pricing/base (base matrix Tingkat×Seg+Gol) · /pricing/subjects/:id (applicability + override) · /cancel-policy · :configId · /app-config · /matrix/student|tutor/:subjectId (model lama, dropped)
M3 Schedule/schedule · /calendar · /approval-queue · /:id · /force-majeure · :claimId
M4 Reports/reports/student-progress + :id · /billing + :id/:month · /payouts + :id/:month · /aging + :id
A1 Audit/audit
A2 Notif/settings/notifications · /log
M6 HR/hr/discipline + :tutorId · /termination/:tutorId · /archive
M7 Finance/finance/period-close + :periodId · /refunds + :id · /credit-notes + :id · /payout-slips + :tutorId/:month
Settings/settings/cron-jobs + :jobName (ADMIN)
Dashboard/dashboard

Deferred iter-1.5: materi-visibility timeline, reminder-broadcast UI, write-off flow. 54 pages total (was 52 + tahun-ajaran 2).

Route → Recipe mapping (42 un-wireframed pages pakai recipe A-G)

RecipeRoutes
A · Listadmin-users · tutors · students · subjects · grades(F) · segments(F) · categories(F) · billing · payouts · aging · refunds · credit-notes · audit · notifications/log · hr/discipline · hr/archive · cron-jobs
B · Detail tabbedtutors/:id · students/:id · subjects/:id · schedule/:id · force-majeure/:claimId · billing/:id/:month · payouts/:id/:month · refunds/:id · credit-notes/:id · hr/discipline/:tutorId · admin-users/:id
C · Formtutor create · student create · refund new · credit-note new · cancel-policy/:configId · profile · notifications edit
D · Wizardperiod-close/:periodId · hr/termination/:tutorId · clone-TA · import flow
E · Calendarschedule/calendar
F · Inline-edit tablegrades · segments · categories (small master data)
G · Authlogin · forgot-password · reset-password/:token
Custompricing/matrix (2 shapes per has_levels) · dashboard (widget grid) · app-config (settings-list) · payout-slips (PDF preview)

4 · Component Mapping (shadcn/ui)

Page recipes (reusable)

RecipeKomposisi
A · List pageSidebar + Breadcrumb + Header (ButtonGroup + InputGroup search) + quick-filter chips + DataTable (TanStack) + ContextMenu row + Empty + Skeleton + Sheet (create) + Sonner
B · Detail tabbedBreadcrumb + Header (Badge + DropdownMenu) + Tabs + Field/FieldGroup inline-edit + Sheet (edit) + AlertDialog
C · Form pageSticky header (Cancel/Simpan) + Form (TanStack Form + Zod) + Card per section + Field + FieldError + async-validation Spinner
D · WizardStepper (Tabs+Progress) + per-step Card + nav + AlertDialog (type-to-confirm)
E · CalendarCustom grid (Tailwind) + ToggleGroup (week/month) + Sheet (event) + conflict resolver card
F · Inline-edit tableTable (NOT DataTable) + click-to-edit + AlertDialog delete + Empty
G · AuthCentered Card + Form + Better Auth Google OAuth button

shadcn primitives — USE

Sidebar · Command (⌘K) · DataTable · Table · Tabs · Card · Sheet · Dialog · AlertDialog · Drawer · Form/Field/FieldGroup · Input · InputGroup (Rp prefix, search) · ButtonGroup · Select · Combobox · Calendar · DatePicker · Checkbox · Switch · RadioGroup · Badge · Avatar · Breadcrumb · DropdownMenu · ContextMenu · Popover · HoverCard · Sonner (toast) · Progress · Skeleton · Empty · Kbd · Item · Typography · NativeSelect (mobile)

SKIP iter-1

Toast (→ Sonner) · Direction · AspectRatio · Carousel · InputOTP · Menubar · NavigationMenu · Slider · Toggle (standalone) · Resizable

5 · Wireframes (10 heavy screens)

File HTML terpisah (mid-fidelity + interactive). Buka di browser:

Status legend: CANONICAL = source of truth · ALIGNED = current supporting view · SUPERSEDED = pre-reshape, reference only

1
Pricing — Base+Override interactive (Excel-grade)ALIGNED
Rebuilt on base+override. Base matrix editable (fill-handle, ⌘D/⌘R, copy-paste, range-select) + Subject override & applicability (default OFF) + cascade glyph.
2
Approval Queue (+ Pricing static)ALIGNED
Linear-anchor · arm-then-commit A/X · SLA flag · bulk bar · WhatsApp cue · orthogonal row indicators
3-4
Schedule Detail + CalendarALIGNED
4-tab detail (state-machine action bar) · week+month · conflict resolver 4-layer · NO drag (explicit reschedule)
5-6
Pembatalan Sheet + Tutup PeriodeALIGNED
Cancel adaptif per initiator (α/β tutor) · period-close stepper blocker-aware (resolve pending → generate akhir → lock)
7-8
Dashboard + Command PaletteALIGNED
"Tugas Hari Ini" hero permission-gated · widget per domain · ⌘K fast-nav (typed/empty state)
9-10
Audit Log + Cron JobsALIGNED
Insert-only diff drill · cron monitor + manual trigger + log (ADMIN)
Pricing — BASE + OVERRIDE (MODEL FINAL, WAJIB buat M2)CANONICAL ★
Reshape 2026-05-31: per-subject 5D matrix DITINGGALKAN → Base price (Tingkat × Seg, subject-agnostic) + sparse Subject/SubjectLevel override + applicability (is_offered default OFF). Cascade resolution. Klik cell buat override.
Unified Matrix — subject = applicability variantALIGNED
Semua subject (tryout/IELTS/musik/CPNS/Matematika) = Subject biasa, beda cuma applicability (Tingkat di-offer). 1 engine, no special-case.
+
Pricing full flow — has_levels Type A/B + lifecycle (reference)ALIGNED
Tingkat vs SubjectLevel · creation flow · lifecycle. NB: per-subject matrix shape di sini = pre-reshape, model final = base+override ★.
+
Pricing ops — effective-dating + bulk + %-bumpALIGNED
Rebuilt on base+override. Berlaku Mulai (effective_from) · bulk fill-down · %-bump old→new preview · dirty-cue + escape guard. Late-bind.
+
Pricing admin — layout options A-DALIGNED
Rebuilt on base+override. 4 layouts compared · recommendation = split-view (C) · Q3 nav · anti-fatigue patterns.
+
Design plan — IA + build sequencingALIGNED
Companion design-plan doc (sitemap, screens, build order, tokens).
⚠ PRICING MODEL = Base + Override cascade (reshape 2026-05-31, SUPERSEDES per-subject matrix):
  • Base price (subject-agnostic): Tingkat × Segmentasi (+Golongan tutor). 1 matrix kecil, semua subject pakai default.
  • Override (sparse): subject (+optional SubjectLevel) override base. Absolute iter-1. Most subject inherit base (0 override).
  • Applicability: is_offered per Subject × Tingkat, default OFF (opt-in). Base price exist ≠ ditawarkan. Config = toggle di subject (preset SD-SMA/SMA+Umum/dll).
  • Resolusi: SubjectLevel override ?? Subject override ?? Base.
  • Price + cancel policy = LATE-BIND (resolve di billing/cancel pakai efektif tgl-les, no snapshot at approve). Policy float = legal-gated (counsel Jun wk2).
  • Kategori demoted (grouping only, bukan pricing). Tingkat ≠ SubjectLevel ≠ Kategori (3 dim beda).
  • Subject types unified — tryout/IELTS/musik = Subject + applicability, no special engine.
Data model: base_price + subject_price_override + subject_offer. Full lock: vault REV 2026-05-31b (RR1-RR8). Wireframe ★ base-override.

6 · Design Tokens + Locale / Timezone

Density tokens

TokenValuePakai
Row height — data-dense36pxPricing matrix, audit, bulk tables
Row height — transactional44pxApproval queue, calendar event list
Click target min≥40px (44px mobile)Fitts-safe — button/checkbox/inline-edit dalam row
Form row spacing16-20px (shadcn md)Forms
Sidebar item36pxNav

Codify di Tailwind v4 @theme block.

Color tokens (light mode) — RESOLVED dari preset b1MJ9vRwY6

✓ Preset resolved (founder export 2026-05-31). Nilai oklch aktual di bawah = source of truth. Penting: primary = AMBER/GOLD (hue ~92), BUKAN blue. Base = warm-neutral (hue 106-107, abu kekuningan). Charts = purple. radius 0.45rem. Paste full block ke app/globals.css. Dark block ADA di preset tapi iter-1 = LIGHT ONLY (skip .dark render iter-1).
⚠ Wireframe HTML = blue-accent PROTOTYPE, bukan theme. Semua wireframe di handover ini di-render dengan neutral-blue accent (#2563eb) sebagai layout/interaction prototype — JANGAN baca warna wireframe sebagai theme final. Theme produksi = amber tokens di tabel ini. (Decided 2026-05-31: skip recolor wireframe ke amber — prototype-blue OK, hemat effort; engineer apply amber tokens saat build.)
CSS variableoklch (light)Pakai
--background / --foregroundoklch(1 0 0) / oklch(0.153 0.006 107.1)Page bg / text (near-black, AAA aman)
--card / --card-foregroundoklch(1 0 0) / oklch(0.153 0.006 107.1)Widget, table, sheet
--primary / --primary-foregroundoklch(0.852 0.199 91.936) AMBER / oklch(0.421 0.095 57.708)Primary action/fill (amber light → dark-brown text). ⚠ amber = FILL only, JANGAN buat text (kontras rendah di white)
--secondary / --mutedoklch(0.967 0.001 286) / oklch(0.966 0.005 106.5)Secondary, header row, bg muted
--muted-foregroundoklch(0.58 0.031 107.3)Secondary text
--border / --input / --ringoklch(0.93 0.007 106.5) / sama / oklch(0.737 0.021 106.9)Border, input, focus ring
--destructiveoklch(0.577 0.245 27.325) redDelete, overdue, negative · ⚠ L=0.577 vs white card ≈ 4-5:1, GAK cukup AAA 7:1 buat angka finance — lihat note bawah
--chart-1..5 purple ramp oklch(0.827→0.438 ... 302-306)Charts (aging, analytics)
--sidebar / --sidebar-primaryoklch(0.988 0.003 106.5) / oklch(0.681 0.162 75.834) goldSidebar bg / active item
--radius0.45remCorner radius
--success CUSTOM — preset gak punyaoklch(~0.6 0.17 145) greenMargin, lunas, COMPLETED — harus di-add
--warning CUSTOM — addoklch(~0.7 0.17 65) amber-orangeSLA flag, perlu-diisi, REQUESTED — harus di-add (beda dari primary amber)
✓ Contrast rule (resolved — AA target): compliance = WCAG AA 4.5:1 (AAA = nice kalo gampang, bukan mandatory). Aturan biar gak ada masalah:
  • Angka finance SELALU --foreground (near-black, ~17:1 vs white) — gak peduli positif/negatif/overdue. Max-readable buat power-user 4-8h, AAA otomatis tanpa token khusus.
  • Status (overdue/negatif/lunas) = badge + ikon + teks (mis. badge merah "Telat 90h", row-tint, panah ↓) — BUKAN warnain digit-nya. Align color-not-only (color-blind safe).
  • --destructive red dipakai di badge/border/tint (lolos AA), BUKAN di angka. Gak perlu token --finance-negative.
  • Primary amber (L=0.852) = FILL only (button), JANGAN buat text (kontras parah di white).
Verify via /accessibility-review pre-Sprint-4.
Note: preset ship --destructive + --chart-1..5 (purple) + --sidebar-*. --success (green, buat margin/lunas) & --warning (SLA/perlu-diisi) = CUSTOM, harus di-add — preset cuma punya amber primary + red destructive, gak ada green/distinct-warning. Dark block ada di preset, defer iter-2.

Typography + status

⚠ Amber discipline (3 amber collision — design review P1): theme amber primary + custom warning amber + override-cell amber bisa tabrakan. Lock color→meaning 1:1:
  • amber FILL = pricing override-cell (rarer, high-signal)
  • amber BORDER + ikon wajib = warning/SLA/perlu-diisi (never color-alone)
  • amber BUTTON = primary action (fill only, no text)
Beda channel (fill/border/button) disambiguate hue yang sama. Verify 1-screen render dengan ke-3 amber co-present via /accessibility-review.
Cascade provenance (CONDITIONAL — design review P1): kalo subject has_levels=true (Mandarin/HSK) ship iter-1, cell pricing harus tampil resolution chain (glyph/hover: "HSK-3 override → Rp X" / "inherit Subject → Rp Y" / "inherit Base → Rp Z") biar operator (esp. OPS hire) tau harga dari layer mana. Cascade 3-deep (SubjectLevel ?? Subject ?? Base). Skip kalo gak ada has_levels subject di catalog iter-1 — verify catalog dulu.

Locale — id-ID formatter (single util lib/format.ts)

// Rupiah — "Rp 1.250.000" (titik ribuan, no desimal whole)
new Intl.NumberFormat('id-ID',{style:'currency',currency:'IDR',
  minimumFractionDigits:0}).format(1250000)        // → "Rp 1.250.000"

// Number input parse-tolerant: terima "1250000" & "1.250.000",
// display formatted, STORE raw integer.

// Phone normalize: terima "08xx" & "+628xx" → normalize sebelum validate.

Timezone — store UTC, render viewer-local (GLOBAL rule)

Prinsip: STORE semua timestamp UTC (timestamptz/ISO 8601). DISPLAY client-side via Intl.DateTimeFormat di timezone VIEWER (auto-detect browser + optional user-pref). Les Jakarta 19:00 → admin Makassar (WITA) liat 20:00. Uniform — semua timestamp termasuk sesi datetime = viewer-local.
// Render — viewer's resolved timezone, id-ID format
new Intl.DateTimeFormat('id-ID',{dateStyle:'long',timeStyle:'short'})
  .format(new Date(utcIsoString))   // "31 Mei 2026 pukul 20.00" (WITA viewer)

// tz auto-detect: Intl.DateTimeFormat().resolvedOptions().timeZone
// user-pref override: simpan di profile, fallback browser tz

7 · Accessibility (WCAG 2.2 AA target)

AreaRequirement
ContrastAA 4.5:1 text (hard target) · UI components 3:1 · breadcrumb sep ≥3:1. Angka finance = --foreground (near-black, AA+). Status pakai badge/ikon (bukan warna digit) → color-blind safe. AAA = nice-to-have, bukan mandatory.
Keyboard navSemua aksi keyboard-reachable. Cmd+K palette · J/K row · A/X arm-then-commit · ⌘D/⌘R fill · Tab/Enter cell · visible focus ring (2px)
Focus mgmtModal/Sheet focus-trap · return focus on close · skip-to-content link · logical tab order
Color-not-onlyStatus = warna + ikon + teks (state badge, calendar event, conflict). Color-blind safe.
Screen readeraria-label state transitions · aria-live region buat Sonner toast + undo + save status · form label+error association (aria-describedby) · table header scope
Touch target≥44px mobile (≤768px)
Motionprefers-reduced-motion respect (arm-pill pulse, transitions)
FormsLabel tiap field · error inline + aria · required indicator · parse-tolerant input (Rupiah/phone)
Deep audit pre-Sprint 4 polish via /accessibility-review skill. Wireframe a11y = hi-level; component-level dari shadcn (Radix base = a11y-solid) + verifikasi contrast token.

Keputusan & Cross-ref

Locked this session

Cross-ref docs (vault Rev 2 project-as-folder)

Pending sebelum Sprint 2 (P1-3): partner confirm via WhatsApp — (1) SSR→SPA mode reconcile, (2) RHF→TanStack Form, (3) data model (offer-matrix/snapshot/tahun_ajaran), (4) timezone store-UTC. Setelah confirm → Stack section "pending reconcile" jadi "locked".