THR UX Guide

← Main THR Reference

1. Intro & Legend

Panduan ni jelaskan apa user setiap access level nampak bila guna THR — landing page, sidebar menu, feature yang dia boleh buat atau tak boleh buat, dan flow penggunaan harian. Source of truth: scan terus dari /home/lanccc/projects/THR/src/.

TL;DR — cuma 4 level ada gating sebenar dalam code

Walaupun accessLevelUtils.js define 9 nama (0-8), kod cuma check pada level 0, 5, 7, 8. Level 1, 2, 3, 4, 6 wujud sebagai label HR tapi tak ada perbezaan behavior — mereka fall back ke L0 (Employee). Level 70 & 80 reserved tapi bukan dalam enum.

Colour legend

Employee tier (L0–L4) — self-service only
Manager tier (L5–L6) — team approvals
HR tier (L7) — org-scoped HR tools
Admin tier (L8) — system-wide control
Reserved (L70/L80) — not in enum

Population snapshot

LevelNameHeadcountGated?Theme colour in UI
0Employee584 (95%)Yes (default)Green
1Senior Employee0NoGreen (inherits L0)
2Supervisor / Team Lead1NoGreen (inherits L0)
3Assistant Manager0NoGreen (inherits L0)
4Manager0NoGreen (inherits L0)
5Senior Manager0YesBlue
6Director0NoBlue (inherits L5)
7HR Admin22Yes (org-scoped)Orange
8Super Admin5Yes (system-wide)Red
70Reserved0Not in enumGray
80Reserved0Not in enumGray

Note pasal Level 2

Memory record sebut "Level 2 Team Lead (1)" dalam DB tapi kod gating guna access_level >= 5 untuk team features. Jadi orang yang ada L2 tu tak boleh approve leave walaupun namanya "Team Lead" — dia dapat view sama macam Employee biasa.

2. Access Matrix

Bird's-eye view — 9 level aktif (row 70/80 skip sebab reserved) lawan feature major. Klik row untuk jump ke journey card yang berkenaan.

Full access Partial / read-only / self-only Hidden / blocked N/A
Feature / Page L0 L1 L2 L3 L4 L5 L6 L7 L8
Self-service (Personal)
Dashboard (personal stats)
My Profile (edit)
Clock in / out (attendance)
Apply leave
Submit claim + upload receipt
View own payslip
Read memo / announcement
Messaging / internal chat
Documents (view org docs)
Team management
View "My Team" list
Approve / reject team leave
Team reports & analytics
Team attendance overview
HR tools (org-scoped)
Employee directory (browse)
Organizations (manage)
Leave balance admin
Attendance admin (override)
Payroll management
Change request approval
Broadcast memo / announcement
System admin
Admin Settings (system config)
Org-admin assignments
User / auth management
Activity logs / audit trail
System health checkup
Access-level switcher (impersonate)
Sofia AI assistant (if enabled)

Org scoping untuk L7 (HR Admin)

HR Admin bukan dapat akses seluruh data — dia hanya nampak employee & applications dari organizations yang dia di-assign dalam thr_admin_org_assignments.admin_user_id. Contoh: HR TCSB cuma boleh approve leave employee TCSB. Kalau multi-org (e.g., TCSB + TASB), nampak gabungan kedua-duanya. Full list = hanya L8.

Persona · Level 0 · Employee

0

Employee (Self-Service)

584 employees (95% of workforce) Theme: Green Landing: /dashboard

Level default untuk semua employee biasa. Fokus self-service — apply leave, clock in, submit claim, baca memo. Tak boleh tengok orang lain punya data, tak boleh approve apa-apa.

Dashboard mockup

THR
A
Personal
🏠 Dashboard
👤 My Profile
🏖️ My Leave
My Attendance
💳 My Claims
Good morning, Ahmad 👋
Annual Leave
12 / 14
This Month
18 days
Claims
RM 340
⏰ Today's Attendance
Checked in08:42 AM
Status● Working
Clock Out
📢 Latest Memo
Raya holidays announcement2 days ago
Q2 performance review schedule5 days ago

L0 dashboard · personal widgets only · green accents

Signature flows

Clock-in flow Login (Google OAuth) → Land on /dashboard → Click "Clock In" button → Permission prompt: camera + geolocation → Selfie captured + lat/lng recorded → Insert into thr_attendance → UI: badge turns green "● Working"
Apply leave flow Menu → My Leave → "New Leave Application" → Select leave type (Annual / MC / Unpaid / Compassionate) → Pick date range (calendar) → Reason (optional) → Upload MC certificate (if MC) → Submit → Row created in thr_leave_applications (status=pending) → Notification fires to L5 manager OR L7 HR Admin → Employee sees "Pending approval" chip
Submit claim flow Menu → My Claims → "New Claim" → Category (Medical / Travel / Meal / Training) → Amount (MYR/IDR auto ikut org) → Date of expense → Upload receipt photo (goes to claims storage bucket) → OpenAI GPT-4 auto-fills fields from receipt → Review & submit → Row in thr_claims (status=pending) → L7 HR Admin notification

What L0 can vs cannot do

Edit own profile (name, phone, emergency contact)
Apply leave (own)
Clock in/out with selfie
Submit expense claim
View own payslips (Phase 2 — may be disabled)
Read memos & announcements
See other employees' data
Approve anything
See attendance reports
Access /employees or /organizations
Any /admin/* route

Persona · Level 1 · Senior Employee

1

Senior Employee (Unused)

0 employees assigned Theme: Green (inherits L0) Status: Defined but not gated

No behavioural difference from L0

Wujud dalam accessLevelUtils.js:6 sebagai label "Senior Employee" tapi tiada satu pun komponen yang check access_level === 1 atau access_level >= 1. Kalau user di-assign L1, dia lihat interface identical dengan L0 Employee. Essentially a tier for HR record-keeping — untuk sign seniority dalam HR record tanpa beri power extra.

Kalau nak activate: perlu tambah gating seperti if (level >= 1) show seniorEmployeeBadge atau if (level >= 1) allow priority leave booking.

Persona · Level 2 · Supervisor

2

Supervisor / Team Lead (Unused gating)

1 employee currently assigned Theme: Green (inherits L0) Status: Label only

⚠ Trap: Level ≠ Power

Walaupun DB ada 1 orang dengan L2 (and label dia "Team Lead"), semua gating code guna access_level >= 5 untuk team features. Jadi orang ni buka THR, dia tak nampak Leave Approvals, Team Reports, atau My Team — dia fall-through ke Employee view.

Expected behaviour (ikut nama "Supervisor") ialah boleh approve leave, tapi actually tidak. Ini kemungkinan tech debt atau mis-configuration — perlu promote ke L5 kalau nak dia functional sebagai supervisor.

Persona · Level 3 · Assistant Manager

3

Assistant Manager (Unused)

0 employees Theme: Green (inherits L0) Status: Label only

Sama macam L1/L2 — tiada gating code. Behaviour = Employee. Notifikasi ke manager mungkin trigger pada access_level >= 3 (NotificationBell.jsx:171) tapi tu routing logic, bukan feature gate.

Persona · Level 4 · Manager

4

Manager (Unused)

0 employees Theme: Green (inherits L0) Status: Label only

Tier label "Manager" tetapi behavior-wise sama dengan L0. Untuk dapat power approve, perlu upgrade ke L5.

Persona · Level 5 · Senior Manager

5

Senior Manager (Team Lead tier)

0 employees (currently empty but gating active) Theme: Blue Landing: /dashboard (team widgets)

First level dengan team powers. Boleh approve leave, tengok team reports, buka attendance overview untuk team. Tak ada akses ke HR tools macam employee directory atau payroll — itu L7.

Dashboard mockup

THR · Manager
M
Personal
🏠 Dashboard
Team
Leave Approvals 3
👥 My Team
📊 Team Reports
Pending Leave Approvals (3)
Ahmad Faiz · Annual · 3 daysPending
Nursyuhada · MC · 2 daysPending
Kairul Pazli · Unpaid · 1 dayPending
Approve All Review Individual
📊 Team Attendance Today
Clocked in12 / 15
Late2
On leave1

L5 dashboard · approval queue + team stats · blue accents

Signature flows

Approve leave flow Login → Dashboard shows pending approvals badge → Click Leave Approvals → List filtered: LeaveApprovals.jsx:486 — "your team's leave requests" → Check leave_type, dates, balance remaining → Click Approve (or Reject with reason) → thr_leave_applications.status = 'approved' → Trigger: notify employee + deduct from thr_leave_balances → Audit trail row inserted
Team analytics flow Menu → Team Reports → Date range picker (default: this month) → Charts: leave by type, attendance trend, late arrival frequency → Filter by employee → Export CSV

What L5 adds on top of L0

Approve / reject team leave applications
View team member list (filtered directory)
Team attendance overview tab
Team reports & analytics (Phase 2 — may be partial)
Cannot see employees outside own team
Cannot edit employee data
Cannot access payroll or claims admin
Cannot broadcast memos

Persona · Level 6 · Director

6

Director (Inherits L5)

0 employees Theme: Blue (inherits L5) Status: Label only

Wujud sebagai label dalam accessLevelUtils.js:11 tapi tak ada gating unik. Kerana semua manager-tier gates guna >= 5, L6 dapat exactly same view macam L5 Senior Manager. Tiada feature tambahan khas untuk Director level.

Persona · Level 7 · HR Admin

7

HR Admin (Organization-scoped)

22 HR admins Theme: Orange Scope: via thr_admin_org_assignments

Role utama untuk jalankan HR operations — manage employees, approve change requests, configure leave balances, run payroll (Phase 2). Bukan global access: dia hanya nampak data dari org(s) yang dia di-assign dalam thr_admin_org_assignments. Kalau assign pada 2 org, dia nampak gabungan dua.

Dashboard mockup

THR · HR Dashboard
H
HR Tools
🏠 Dashboard
👥 Employees
🏢 Organizations
🗓️ Leave Mgmt
Attendance Admin
📋 Change Requests 12
💰 Payroll (Phase 2)
TCSB · Todak Culture 149 employees
Pending Leave
8
Change Req
12
Active Today
87 / 149
On Leave
5
📋 Recent Change Requests
Nasreena · Salary revisionPending
Abdur Rawi · Position changePending
Ahmad Luqman · Department transferApproved
📈 This Month Leave Trend
Annual leave24 days
MC11 days
Unpaid3 days

L7 dashboard · org-scoped (TCSB only) · orange accents · HR queues

Signature flows

Change request approval Employee / manager submit change (salary, position, dept) → Row in thr_change_requests (status=pending, org_id=X) → L7 sees filtered list: hrOrganizationService.getChangeRequestScope() → Query: WHERE organization_id IN (assignedOrgs) → Review: old value vs proposed value diff → Approve → thr_employees updated + audit trail → Reject → notify requester with reason
Leave balance config Menu → Leave Management (admin) → Select org → select leave type (Annual / MC / Compassionate) → Set entitlement per year (e.g., 14 days Annual) → Set carry-over rules (max 7 days) → Save → thr_leave_entitlements updated → Cron recalculates thr_leave_balances for all active employees in org
Broadcast memo (limited) Menu → Memos → Create → Select audience: org members (default: own assigned orgs) → Compose title + body + attachments → Set publish date → Publish → thr_memos insert → Notification fan-out to employees in target orgs → Note: L7 limited to own orgs. For all-hands memo → L8 only.

What L7 adds on top of L5

Browse full employee directory (org-scoped)
Edit employee records (own orgs)
Approve change requests (org-scoped)
Configure leave entitlements & balances
Admin attendance (manual correction, override)
Run payroll (Phase 2 — disabled now)
Broadcast memo (own orgs)
Cannot see employees from other orgs
Cannot access /admin (system config)
Cannot create new organizations (L8)
Cannot manage auth users / access levels
Cannot view system-wide activity logs

Admin-Org assignment mechanism

L7 users di-assign ke orgs oleh L8 via /admin/org-assignments. Row dalam thr_admin_org_assignments: { admin_user_id, organization_id, role, created_at }. Scope query:

// hrOrganizationService.js
const scope = {
  canViewAll: false,
  organizationIds: [...user's assigned orgs]
};
query.in('organization_id', scope.organizationIds);

Dec 2025 bug: column admin_user_id pernah tersalah set sebagai employee_id → 509 HR admin dapat scope salah. Pastikan join pakai admin_user_id.

Persona · Level 8 · Super Admin

8

Super Admin (System-wide)

5 super admins Theme: Red Scope: canViewAll: true

Role paling tinggi — full system access. Manage users, levels, system config, view semua activity logs, impersonate level lain untuk test, run system health check. Tiada org scoping — dia nampak semua 24 orgs.

Dashboard mockup

THR · System Admin
S
System
🏠 Dashboard
⚙️ Admin Settings
🗝️ Access Control
🔗 Org Assignments
📝 Activity Logs
🩺 System Checkup
🔄 Switch View As
System Overview
Total Users
245
Orgs
24
Employees
612
Pending Adm
47
🩺 System Health
DB latency● 18ms
Storage usage2.1 GB / 10 GB
Email queue● Idle 30+ days
Cron status● Running
📝 Recent Activity (last 24h)
ahmadfaiz · Login2m ago
nursyuhadah · Apply leave8m ago
hr.tcsb · Approve change_request15m ago

L8 dashboard · system-wide · red accents · health monitoring + audit

Signature flows

Assign HR admin to org Menu → Org Assignments → Select employee (with L7 access_level) → Select target org (from 24 orgs) → Save → insert thr_admin_org_assignments → Next login, employee sees new org's data in HR dashboard
Promote employee to HR Admin Menu → Admin Settings → Users → Search employee → Edit → Change access_level from 0 → 7 → COLUMN update (NOT JSONB — Dec 2025 lesson) → UPDATE thr_employees SET access_level = 7 WHERE id = X → Assign org(s) via Org Assignments page → Trigger refreshes user's JWT on next login
Impersonate other level (test) Menu → Switch View As… → Dropdown: L0 Employee / L5 Manager / L7 HR Admin → Select L5 → sidebar rebuilds with L5 menu → getEffectiveAccessLevel() returns 5 (actual still 8) → Test flows as Manager → Exit impersonation → back to L8 view
Activity audit Menu → Activity Logs → Filter by user / action / date range / org → View diffs (old_value vs new_value from JSONB) → Export CSV for compliance → 4 parallel audit systems in DB (tech debt): • thr_activity_logs (11.4k rows) • thr_audit_logs (10.3k) • thr_audit_trail (7.8k) • change_audit_log

What L8 exclusively controls

All L7 features (cross all orgs)
Create / delete organizations
Assign HR admins to orgs
Change user access levels
System config (AI settings, email, integrations)
Impersonate other levels via AccessLevelSwitcher
View / export all activity & audit logs
System health checkup
Broadcast company-wide memo (all orgs)
Toggle feature flags (Sofia AI, etc.)

Power without RLS

L8 sekarang ni effectively god mode — tapi tanpa Postgres RLS. Semua gating app-level. Maksudnya kalau ada bug UI atau direct API call dari browser devtools, user boleh bypass access_level check. Critical tables yang RLS disabled: thr_employees, thr_organizations, thr_attendance, thr_leave_*, thr_notifications, thr_activity_logs, thr_audit_trail, thr_admin_org_assignments. Rujuk RLS section dalam main docs.

Persona · Level 70 · Reserved

70

Reserved (Not in enum)

0 employees Theme: Gray Status: Numeric value only

Level ini wujud sebagai nilai numerik yang sah dalam column access_level (constraint: integer) tapi tidak didefinisikan dalam accessLevelUtils.js (yang senaraikan label sampai 8 sahaja). Kalau ada row dengan access_level = 70, UI akan tunjuk "Unknown Level" atau fallback ke L8 behavior (sebab >= 8 gating triggers).

Kemungkinan intent: untuk special accounts macam service account, audit user, atau super-super-admin (tertiary tier). Tapi takde code yang handle ini secara explicit sekarang.

Persona · Level 80 · Reserved

80

Reserved (Not in enum)

0 employees Theme: Gray Status: Numeric value only

Sama dengan L70 — valid numeric value tapi tiada label atau gating khusus. Kemungkinan untuk root-level system account (e.g., automated provisioning user) yang perlu bypass semua checks. Untuk aktivasi, perlu:

  1. Tambah label dalam accessLevelUtils.js
  2. Tambah case dalam getAccessLevelTheme()
  3. Tambah section dalam sidebar getMenuStructure()
  4. Decide behavior (super-override vs restricted service account)

4. Cross-Role Signature Flows

Flow yang merentas level — tunjuk bagaimana interaksi antara persona.

Flow A · Leave approval end-to-end

[L0 Employee] Apply leave → status=pending ↓ [L5 Manager] (if assigned as approver) Receives notification → Reviews → Approve/Reject ↓ approved [Trigger] Deduct balance · send notification ↓ [L0 Employee] Sees green "Approved" chip ↓ (optional override path) [L7 HR Admin] Can reverse or cancel approved leave Can adjust balance manually

Flow B · New employee onboarding

[L8 Super Admin] Create org-admin assignment (if new org) ↓ [L7 HR Admin] Add employee record (thr_employees) Set access_level = 0 (default) Assign to organization ↓ [L7 HR Admin / L8] Generate Supabase auth user (Google OAuth email) ↓ [L0 New Employee] First login → EmployeeProfileCompletion page Fill personal_info (IC, DOB, emergency contact, bank) Save → thr_employees JSONB updated ↓ ready to work [Optional: GAM7 integration — provision @todak.com]

Flow C · Change request escalation

[L0 Employee] or [L5 Manager] Submit change request (salary, position, dept) ↓ [L7 HR Admin] (scoped to org) Review → compare old vs new ↓ approve [Trigger] UPDATE thr_employees (correct JSONB path) [Audit] thr_audit_trail row (4 systems fire!) ↓ [L0 Employee] Notification received Next login → updated profile

Flow D · System admin task (L8 only)

[L8 Super Admin] Menu → Admin Settings ↓ Toggle feature flag (e.g., enable Sofia AI) ↓ save → config table updated Create new org ↓ insert thr_organizations Assign HR admin to org ↓ insert thr_admin_org_assignments [Then] HR admin (L7) picks up access next session

5. Gaps & Ambiguities

1 · 7 of 11 access levels have no distinct behavior

L1, L2, L3, L4, L6, L70, L80 — defined or permissible but no code branches on them. Effectively 7 tiers are decorative. If you need graduated permissions (e.g., "can view team but not approve"), current code can't express it without new gating.

2 · Theme switching is hard-coded

getAccessLevelTheme() hardcodes ranges 0-4 green, 5-6 blue, 7 orange, 8 red. Kalau tambah L9 atau activate L70, theme default ke green — dia tak stand out.

3 · Phase 2 features cluttering nav

Items macam /reports, /team, /myClaims, /myDocuments, /myPayslips semua disabled (phaseDisabled: true) tapi tetap render dalam sidebar. User boleh klik tapi dapat "Phase 2" overlay. Adakah better kalau sembunyi terus sehingga siap?

4 · No JWT claim for access_level

Access level fetched dari thr_employees setiap page load. Takde JWT claim, so kalau access_level change mid-session, user masih dapat cached level sampai next refresh. Potentially security lag.

5 · Impersonation audit gap?

AccessLevelSwitcher membolehkan L8 impersonate level lain. Perlu check: adakah action yang dibuat semasa impersonate direkod dengan actual_user_id=L8 dan effective_level=N, atau seolah-olah dilakukan oleh L8 biasa? Kalau tak direkod, audit trail boleh dipertikaikan.

Recommendations

ActionBenefitEffort
Collapse L1-L4 → single "Employee" tier OR activate gatesRemove confusionS
Replace >= 5 with named roles (ROLE_TEAM_LEAD, ROLE_HR_ADMIN)Self-documenting codeM
Enable Postgres RLS matching app-layer scopeDefense-in-depthL
Add JWT claim for access_level + tenantProper auth, real-time revokeM
Hide Phase 2 items from nav completelyCleaner UXS
Audit impersonation actions explicitlyComplianceS

How to get real screenshots to replace mockups

Mockups ni replicate MUI theme tapi bukan real screenshots. Kalau nak real:

  1. Install playwright + chromium on lan-claude
  2. Enable demo_mode localStorage flag (bypass auth)
  3. Script: loop setiap access_level, navigate to key routes, page.screenshot()
  4. Save to /home/lanccc/thr-docs/screenshots/ and swap .mockup sections dengan <img>

Kalau ko confirm, aku boleh setup script ni.