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
Stack & Arsitektur
| Layer | Pick |
|---|---|
| Frontend | React 19 + TanStack Start (SPA mode — client-only, NO SSR) + shadcn/ui + Tailwind v4 · preset b1MJ9vRwY6 |
| TanStack universe | Form + Query (client fetch) + Router + Table |
| Theme | Light mode only (iter-1) |
| API | Hono /api/* + @hono/zod-openapi |
| Auth + RBAC | Better Auth + Google OAuth · permission-list + Negation ACL |
| DB / ORM | Neon Postgres 18 + CF Hyperdrive · Drizzle · uuidv7 · timestamptz (UTC) |
| Storage | Cloudflare R2 + S3 presigned URL (full private ACL) |
| i18n | Paraglide JS (BI iter-1, EN iter-2) |
| Runtime / obs | Cloudflare Workers + Wrangler · SigNoz |
Design Language
Anchor map per surface (cegah implicit-hybrid relitigation):
| Surface | Anchor | Kenapa |
|---|---|---|
| Pricing matrix, audit, tabel finance | Stripe | Table chrome, finance accuracy, tabular-nums |
| Approval queue, state ops, Cmd+K | Linear | Keyboard-first, fast transition |
| Calendar | Cal.com | Calendar = own pattern |
| Login, CRUD, utility | shadcn default | Minimal 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)
| Section | ADMIN | OPS | FINANCE |
|---|---|---|---|
| Dashboard | all | ops widgets | finance widgets |
| Schedule | ✓ full + bulk | ✓ full | read-only |
| People (Tutor/Siswa) | ✓ full + kontrak | ✓ CRUD | read-only |
| Pricing (matrix/policy/config) | ✓ full | ✗ hidden | read-only |
| Reports → Tagihan | ✓ + bulk paid | ✓ + mark-paid | ✓ full + adjust |
| Reports → Honor | ✓ + bulk | ✗ hidden (privacy) | ✓ full |
| Master Data | ✓ CRUD | ✓ CRUD | ✗ hidden |
| HR (strike/terminate) | ✓ full | escalate only | ✗ hidden |
| Finance (period/refund/slip) | ✓ + reopen | ✗ hidden | ✓ (reopen=ADMIN) |
| Audit | ✓ full | scoped | finance scope |
| Settings (users/notif/cron) | ✓ full | ✗ | ✗ |
2 · Navigasi & Sidebar
- Sidebar: shadcn block 07 collapsible. Branding + role badge (no workspace-switch iter-1). Expanded ≥1280px, auto-collapse 1024-1279px, drawer <1024px. Cmd+B toggle, persist localStorage.
- Badge counts: Approval queue, FM pending (amber kalo SLA-flagged).
- Secondary nav: Breadcrumb + tabs (detail page) / breadcrumb only (list) / stepper (wizard).
- Breadcrumb-dropdown: audience switch (Siswa/Tutor) = breadcrumb segment dropdown
[Siswa ▾](sitemap-consistent path segment, GitHub switcher pattern). - Cmd+K palette: global nav + action + entity search + help (Linear). Lihat WF8.
- Keyboard global: Cmd+K palette · Cmd+B sidebar · G→S/P/R chord nav · ? cheatsheet · Esc close.
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).
| Epic | Pages (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 |
| 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)
| Recipe | Routes |
|---|---|
| A · List | admin-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 tabbed | tutors/: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 · Form | tutor create · student create · refund new · credit-note new · cancel-policy/:configId · profile · notifications edit |
| D · Wizard | period-close/:periodId · hr/termination/:tutorId · clone-TA · import flow |
| E · Calendar | schedule/calendar |
| F · Inline-edit table | grades · segments · categories (small master data) |
| G · Auth | login · forgot-password · reset-password/:token |
| Custom | pricing/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)
| Recipe | Komposisi |
|---|---|
| A · List page | Sidebar + Breadcrumb + Header (ButtonGroup + InputGroup search) + quick-filter chips + DataTable (TanStack) + ContextMenu row + Empty + Skeleton + Sheet (create) + Sonner |
| B · Detail tabbed | Breadcrumb + Header (Badge + DropdownMenu) + Tabs + Field/FieldGroup inline-edit + Sheet (edit) + AlertDialog |
| C · Form page | Sticky header (Cancel/Simpan) + Form (TanStack Form + Zod) + Card per section + Field + FieldError + async-validation Spinner |
| D · Wizard | Stepper (Tabs+Progress) + per-step Card + nav + AlertDialog (type-to-confirm) |
| E · Calendar | Custom grid (Tailwind) + ToggleGroup (week/month) + Sheet (event) + conflict resolver card |
| F · Inline-edit table | Table (NOT DataTable) + click-to-edit + AlertDialog delete + Empty |
| G · Auth | Centered 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
- 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_offeredper 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.
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
| Token | Value | Pakai |
|---|---|---|
| Row height — data-dense | 36px | Pricing matrix, audit, bulk tables |
| Row height — transactional | 44px | Approval queue, calendar event list |
| Click target min | ≥40px (44px mobile) | Fitts-safe — button/checkbox/inline-edit dalam row |
| Form row spacing | 16-20px (shadcn md) | Forms |
| Sidebar item | 36px | Nav |
Codify di Tailwind v4 @theme block.
Color tokens (light mode) — RESOLVED dari preset b1MJ9vRwY6
app/globals.css. Dark block ADA di preset tapi iter-1 = LIGHT ONLY (skip .dark render iter-1).#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 variable | oklch (light) | Pakai |
|---|---|---|
--background / --foreground | oklch(1 0 0) / oklch(0.153 0.006 107.1) | Page bg / text (near-black, AAA aman) |
--card / --card-foreground | oklch(1 0 0) / oklch(0.153 0.006 107.1) | Widget, table, sheet |
--primary / --primary-foreground | oklch(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 / --muted | oklch(0.967 0.001 286) / oklch(0.966 0.005 106.5) | Secondary, header row, bg muted |
--muted-foreground | oklch(0.58 0.031 107.3) | Secondary text |
--border / --input / --ring | oklch(0.93 0.007 106.5) / sama / oklch(0.737 0.021 106.9) | Border, input, focus ring |
--destructive | oklch(0.577 0.245 27.325) red | Delete, 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-primary | oklch(0.988 0.003 106.5) / oklch(0.681 0.162 75.834) gold | Sidebar bg / active item |
--radius | 0.45rem | Corner radius |
--success CUSTOM — preset gak punya | oklch(~0.6 0.17 145) green | Margin, lunas, COMPLETED — harus di-add |
--warning CUSTOM — add | oklch(~0.7 0.17 65) amber-orange | SLA flag, perlu-diisi, REQUESTED — harus di-add (beda dari primary amber) |
- 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).
--destructivered 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).
/accessibility-review pre-Sprint-4.--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
- Finance numbers:
tabular-nums· right-align · min14px· warna--foregroundselalu (near-black, AA+ aman). Status via badge/ikon, bukan warna digit. - Semua text/chrome target AA 4.5:1. Status = warna + ikon + teks (color-not-only).
- Icons: lucide-react (shadcn standard) — emoji di wireframe = ilustrasi doc only, JANGAN dipakai production (SR-label + render varies).
- 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)
/accessibility-review.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)
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
- Business-event definition (price
effective_from, cron fire, period boundary): admin input wall-clock di business tz (WIB) → convert UTC buat store. Display tetap viewer-local. - Cron schedule: anchored WIB (kapan fire — ops calendar). Run-timestamp display viewer-local.
- ⚠ Date-only business math = WIB-anchored (BUKAN viewer-local): aging day-count (0-30/31-60/61-90/90+), period membership (sesi masuk bulan mana),
effective_from/effective_untilcomparison, "hari ini" filter — semua hitung pakai WIB business-day boundary. Cuma DISPLAY timestamp yang viewer-local. Kalo aging di-compute per viewer-tz, admin Makassar vs WIB bisa beda "days overdue" 1 hari (finance correctness bug). Display = viewer-local, computation = WIB-anchor. (Indonesia no DST — gak ada edge case DST.) - Date format: DMY (
31 Mei 2026) · time 24-jam · NEVER US MDY.
7 · Accessibility (WCAG 2.2 AA target)
| Area | Requirement |
|---|---|
| Contrast | AA 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 nav | Semua 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 mgmt | Modal/Sheet focus-trap · return focus on close · skip-to-content link · logical tab order |
| Color-not-only | Status = warna + ikon + teks (state badge, calendar event, conflict). Color-blind safe. |
| Screen reader | aria-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) |
| Motion | prefers-reduced-motion respect (arm-pill pulse, transitions) |
| Forms | Label tiap field · error inline + aria · required indicator · parse-tolerant input (Rupiah/phone) |
/accessibility-review skill. Wireframe a11y = hi-level; component-level dari shadcn (Radix base = a11y-solid) + verifikasi contrast token.Keputusan & Cross-ref
Locked this session
- Pricing model (reshape 2026-05-31) = Base + Override cascade (per-subject 5D matrix dropped). Base (Tingkat×Seg+Gol) + sparse override + applicability default-OFF. Kategori demoted. Tingkat individual.
- Audience switch = breadcrumb-dropdown · applicability = is_offered per subject×tingkat (toggle, default OFF)
- Data model:
base_price+subject_price_override+subject_offer· price + cancel-policy LATE-BIND (no snapshot at approve; resolve at billing/cancel) · tahun_ajaran explicit table (CRUD, GIST overlap) - Cancel scheduling = 2 explicit button (Simpan-now confirm / Jadwalkan datetime) · %-bump cut · export/import workhorse
- Approval = arm-then-commit · Conflict = prevention-first + resolver card (global future scope) 4-layer · Calendar NO drag
- Period close = resolve pending (ops→admin→finance) dulu, generate tagihan/honor di akhir, lock
- Dashboard + hero = permission-gated (multi-role union)
- Timezone = store UTC, render viewer-local · Client-only SPA (no SSR) · Form = TanStack Form
Cross-ref docs (vault Rev 2 project-as-folder)
- Partner discussion (RBAC/cron/invite/pricing data-model/tz/SPA):
~/Workspaces/XPrivate/handover/2026-05-26-partner-discussion-rbac-cron-invite.md - Pricing impl lock (base+override cascade + TA + tz + SPA + late-bind): vault
XPrivate/Projects/admin-mvp-1/pricing-implementation-lock.md(REV 2026-05-31b RR1-RR8) - Architecture alignment: vault
XPrivate/Projects/admin-mvp-1/decisions/2026-05-26-architecture-alignment.md - Design review log: vault
XPrivate/Projects/admin-mvp-1/design-reviews/2026-05-26-wireframes.md+2026-05-31-ia-handover-doc.md(review ini) - Design anchor map · cancel policy v1 · scope direction · react-vs-svelte: vault
XPrivate/Projects/admin-mvp-1/(+/decisions/+/policies/)