Staff Hub — HR & Attendance Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Ship a Pro+ “Staff Hub” HR module: IP-gated software clock-in, leave management seeded with Pakistan statutory defaults, and a fillable/printable Pakistan hiring-termination document pack — all admin-only, built on the existing employees/payroll chain.
Architecture: New tables under appSchema, a single /hr Hono route file gated by requireModule('staff_hr') + requireClinicContext + requirePermissionByMethod, plus a kiosk sub-surface authenticated by a hashed device token (the only non-admin surface). Attendance integrity = kiosk token + clinic IP allowlist + per-employee PIN, behind a pluggable “punch source” so biometric can drop in later. PDFs render client-side (react-pdf, reusing the letterhead export path) and upload the blob to the server for R2 storage + an audit row.
Tech Stack: Hono + Drizzle (Neon Postgres, app schema, text IDs), Cloudflare Workers, Vitest, React + react-pdf, R2, lib/pkt for timezone-correct day math.
Spec: docs/superpowers/specs/2026-06-14-staff-hub-hr-design.md
Execution order: Phase 0 (Foundation) is a sequential prerequisite. Tracks A / B / C are independent after Phase 0 and may be parallelized across subagents.
Conventions for every task: tables live under appSchema; IDs are text with $defaultFn(() => crypto.randomUUID()); FKs reference users.id/clinics.id as text; timestamps are timestamptz stored UTC; all day-boundary math uses AT TIME ZONE 'Asia/Karachi' (never compare a timestamptz instant to a naive recast — see spec §4.2). Run server tests with cd server && npx vitest run <path>.
Phase 0 — Foundation (sequential)
Task 0.1: Attendance schema
Files:-
Create:
server/src/schema/hr_attendance.ts -
Create:
server/src/schema/hr_attendance.test.ts -
Modify:
server/src/schema/index.ts(add export) - Step 1: Write the failing schema test
- Step 2: Run test, verify it fails
cd server && npx vitest run src/schema/hr_attendance.test.ts
Expected: FAIL — cannot find module ./hr_attendance.
- Step 3: Create the schema
- Step 4: Export from schema index
server/src/schema/index.ts near the other exports:
- Step 5: Run test, verify PASS
cd server && npx vitest run src/schema/hr_attendance.test.ts
Expected: PASS (3 tests).
- Step 6: Commit
Task 0.2: Leave schema
Files:-
Create:
server/src/schema/hr_leave.ts -
Create:
server/src/schema/hr_leave.test.ts -
Modify:
server/src/schema/index.ts - Step 1: Write the failing schema test
- Step 2: Run test, verify it fails
cd server && npx vitest run src/schema/hr_leave.test.ts
Expected: FAIL — cannot find module ./hr_leave.
- Step 3: Create the schema
- Step 4: Export from index
server/src/schema/index.ts: export * from './hr_leave';
- Step 5: Run test, verify PASS
cd server && npx vitest run src/schema/hr_leave.test.ts
Expected: PASS (3 tests).
- Step 6: Commit
Task 0.3: HR documents schema + employee/clinic extensions
Files:-
Create:
server/src/schema/hr_documents.ts -
Create:
server/src/schema/hr_documents.test.ts -
Modify:
server/src/schema/payroll.ts(extendemployees) -
Modify:
server/src/schema/clinics.ts(addhrSettings) -
Modify:
server/src/schema/index.ts - Step 1: Write the failing test
- Step 2: Run, verify fail
cd server && npx vitest run src/schema/hr_documents.test.ts
Expected: FAIL — cannot find module ./hr_documents (and employees columns missing).
- Step 3: Create hr_documents.ts
- Step 4: Extend
employeesin payroll.ts
server/src/schema/employees definition (server/src/schema/payroll.ts), add these columns before createdAt (import date, jsonb, integer are already partly present — add any missing to the import):
import { text, timestamp, date, numeric, jsonb, integer } from 'drizzle-orm/pg-core'; includes integer.)
- Step 5: Add
hrSettingsto clinics.ts
server/src/schema/clinics.ts, alongside the other jsonb settings bags, add:
- Step 6: Export + run + commit
export * from './hr_documents'; to server/src/schema/index.ts.
Run: cd server && npx vitest run src/schema/hr_documents.test.ts → Expected: PASS.
Task 0.4: Idempotent migration
Files:-
Create:
server/drizzle/0063_staff_hub.sql -
Modify:
server/src/api.ts(add to the idempotent bootstrap block near the existingCREATE TABLE IF NOT EXISTS "app"."passkeys") - Step 1: Write the migration (idempotent)
- Step 2: Mirror into the api.ts bootstrap
server/src/api.ts, find the array of bootstrap statements containing CREATE TABLE IF NOT EXISTS "app"."passkeys" and append the same CREATE TABLE IF NOT EXISTS/ALTER TABLE ... ADD COLUMN IF NOT EXISTS statements from Step 1 (so isolated/enterprise DBs that drift get the tables at boot — see spec §8). Keep each as its own array entry.
- Step 3: Verify SQL parses (local dry run)
cd server && node -e "const fs=require('fs');const s=fs.readFileSync('drizzle/0063_staff_hub.sql','utf8');if(!/attendance_punches/.test(s))process.exit(1);console.log('migration present, '+s.split(';').length+' statements')"
Expected: prints statement count, exit 0.
- Step 4: Commit
Do NOT run this against any live tenant. Migrations apply through the normal deploy path; never execute SQL on prod/live DBs without explicit per-action confirmation (memory: no-live-tenant-execution).
Task 0.5: Register the module
Files:-
Modify:
server/src/constants/modules.ts -
Create:
server/src/constants/modules.test.ts(if absent; else add a case) - Step 1: Failing test
-
Step 2: Run → fail.
cd server && npx vitest run src/constants/modules.test.ts→ FAIL (undefined). -
Step 3: Add the module entry to
AVAILABLE_MODULESinserver/src/constants/modules.ts(in the Pro+ group):
- Step 4: Run → PASS. Commit:
Task 0.6: Permission keys (server + UI parity)
Files:-
Modify:
server/src/lib/permissions.ts(add toPERMISSION_KEYS) -
Modify:
ui/src/lib/permissions.ts(mirror keys + add tree node) -
Step 1: Add keys to server
PERMISSION_KEYS(after thebilling.payroll.*block):
- Step 2: Mirror in UI + add a tree group. In
ui/src/lib/permissions.ts, add the same 8 keys to the UI key list and aPERMISSION_TREEgroup:
- Step 3: Run the parity tripwire (this codebase enforces UI↔server permission parity in CI):
cd server && npx vitest run src/lib/permissions.test.ts (or the parity test; if the parity test lives in UI, run cd ui && npx vitest run src/__tests__/lib/permissions.test.ts).
Expected: PASS — server and UI key sets match. Fix any mismatch by aligning the two lists exactly.
-
Step 4: Verify admin default. Admin role returns all defaults (
permissions.ts:727if (role === 'admin') return defaults), so admin gets these automatically; reception/doctor get none unless explicitly granted. No change needed beyond confirming the keys appear in the default set for admin. - Step 5: Commit
Task 0.7: Route scaffold + mount
Files:-
Create:
server/src/routes/hr.ts -
Modify:
server/src/api.ts(mount under protected routes) - Step 1: Create the route file with guards + a health check
- Step 2: Mount it in
server/src/api.tsnext to the otherprotectedRoutes.route(...)lines:
- Step 3: Typecheck
cd server && npx tsc --noEmit
Expected: no new errors.
- Step 4: Commit
Track A — Attendance (after Phase 0)
Task A.1: Clinic HR settings endpoint
Files: Modifyserver/src/routes/hr.ts
- Step 1: Add GET/PATCH
/settings(reads/writesclinics.hrSettings):
- Step 2: Typecheck + commit.
cd server && npx tsc --noEmit→ clean.
Task A.2: Kiosk register / list / revoke + employee PIN
Files: Createserver/src/lib/hr/kiosk-token.ts; Modify server/src/routes/hr.ts
- Step 1: Failing test for token hashing
-
Step 2: Run → fail.
cd server && npx vitest run src/lib/hr/kiosk-token.test.ts→ FAIL. - Step 3: Implement (SHA-256 via WebCrypto, matching the Workers runtime):
- Step 4: Run → PASS.
-
Step 5: Add endpoints to hr.ts (kiosk CRUD + PIN set; PIN uses existing
lib/password):
- Step 6: Typecheck + commit.
Task A.3: Day-summary computation (TZ-correct) — unit-tested logic
Files: Createserver/src/lib/hr/attendance-summary.ts + test
- Step 1: Failing test (pure function: punches → summary, in PKT):
-
Step 2: Run → fail.
cd server && npx vitest run src/lib/hr/attendance-summary.test.ts -
Step 3: Implement (use
IntlPKT offset; pair punches; no naive recast):
- Step 4: Run → PASS. Commit.
Task A.4: Kiosk routes (token + IP + PIN punch)
Files: Createserver/src/routes/hr-kiosk.ts; Modify server/src/api.ts
- Step 1: Create the kiosk route (own auth: kiosk token + IP allowlist; no admin permission). Mount OUTSIDE the admin-gated
/hrso it doesn’t inheritrequirePermission.
- Step 2: Mount in
server/src/api.tsunder protected routes (it still needs the staff to be in a clinic context only insofar as the kiosk token resolves the clinic — mount as its own top-level route so it is NOT behind admin permission):
If kiosk devices are not logged-in users, mounthrKioskon the publicapirouter (likepublic-booking) instead ofprotectedRoutes, since auth is the kiosk token, not a user session. Confirm during implementation which router enforces user-JWT; choose the one that does NOT require a user session.
- Step 3: Typecheck + commit.
Task A.5: Admin attendance read + adjustments
Files: Modifyserver/src/routes/hr.ts
- Step 1: Add GET
/attendanceand POST/attendance/adjustments:
- Step 2: Typecheck + commit.
Task A.6: Kiosk UI (full-screen /kiosk)
Files: Create ui/src/pages/KioskPage.tsx; Modify the router (ui/src/App.tsx or routes file) + ui/src/lib/serverComm.ts
- Step 1: Add serverComm helpers in
ui/src/lib/serverComm.ts:
apiGet/apiPost signatures in that file; if they don’t accept per-call headers, add a thin fetch wrapper that includes x-kiosk-token.)
-
Step 2: Build
KioskPage.tsx— full-screen, no app chrome: reads kiosk token fromlocalStorage(hr_kiosk_token), shows the roster as large tappable tiles → on tap, a PIN pad → IN/OUT buttons → success toast with name + time. UseBanknote-free icons; premium visual bar (per memory). Store the token via a one-time?token=query param then strip it (clean-URL rule), or an admin “pair this device” action. -
Step 3: Register the route at
/kioskas a standalone layout (outside the authenticated dashboard shell). Verify it renders with a real browser pass (screenshot), not just tsc. - Step 4: Commit.
Task A.7: Admin Attendance tab
Files: Createui/src/components/hr/AttendanceTab.tsx; wire into the Staff Hub page (created in Task C.5 shell — if building A before C, create a minimal ui/src/pages/StaffHubPage.tsx with tabs here and extend later).
- Step 1: Build a month grid of
attendanceDaySummary(present/absent/leave/late chips), per-employee filter, punch detail drawer, an “Add correction” dialog (POST/hr/attendance/adjustments), and a “Kiosks” panel (register → show one-time token as a copyable value + simple instructions, list, revoke). Keep IDs/tokens human-friendly (memory: no raw-ID/SQL jargon). - Step 2: Visual pass in a real browser. Commit.
Track B — Leave (after Phase 0)
Task B.1: PK leave-type seeding
Files: Createserver/src/lib/hr/leave-defaults.ts + test
- Step 1: Failing test
- Step 2: Run → fail.
- Step 3: Implement (spec §2):
- Step 4: Run → PASS. Commit.
Task B.2: Leave-type seeding + CRUD endpoints
Files: Modifyserver/src/routes/hr.ts
- Step 1: Add idempotent seed + CRUD:
- Step 2: Typecheck + commit.
Task B.3: Leave balance math — unit-tested logic
Files: Createserver/src/lib/hr/leave-balance.ts + test
- Step 1: Failing test
- Step 2: Run → fail.
- Step 3: Implement:
- Step 4: Run → PASS. Commit.
Task B.4: Leave records (record / cancel / balances) + encashment
Files: Modifyserver/src/routes/hr.ts
- Step 1: Add balances read, record, cancel, encash:
-
Step 2: Implement the “mark covered days as leave” loop noted above: iterate
startDate..endDate, upsertattendanceDaySummaryrows withstatus='leave'andleaveRecordId=rec.id. (Reuse the upsert pattern from Task A.4.) - Step 3: Typecheck + commit.
Task B.5: Leave UI tab
Files: Createui/src/components/hr/LeaveTab.tsx; wire into StaffHubPage.tsx; add serverComm helpers.
- Step 1: Build: leave-type config table (edit quotas/flags inline-save, no separate Save-button trap per memory), per-employee balances cards (entitled/taken/available), a “Record leave” dialog (employee + type + date range + half-day + reason), encashment action on annual.
- Step 2: Visual pass; commit.
Track C — HR documents (after Phase 0)
Task C.1: Template definitions + merge fields
Files: Createserver/src/lib/hr/document-templates.ts + test; mirror the type list in ui/src/lib/hr/document-templates.ts (or share via a constants module).
- Step 1: Failing test
- Step 2: Run → fail.
-
Step 3: Implement — each template =
{ type, title, fields: {key,label,source:'employee'|'manual',required}[], body: (data)=>string, disclaimer }. Auto-source fields pull from the employee record; manual fields are admin-entered. Encode the §2 statutory facts (probation ≤3mo, 1-month notice, SO-15 show-cause language, service-certificate right). Include the shareddisclaimerstring on every template:
- Step 4: Run → PASS. Commit.
Task C.2: Issue / list / get / delete endpoints (client renders PDF, server stores)
Files: Modifyserver/src/routes/hr.ts
- Step 1: Add endpoints. The client renders the PDF (react-pdf, reusing the letterhead path) and POSTs the blob as multipart; server validates, stores to R2 via
getR2Service, writeshr_documents. Mirrors thedocument-letterheadupload route.
- Step 2: Typecheck + commit.
Task C.3: Document UI + People tab (HR fields)
Files: Createui/src/components/hr/DocumentsTab.tsx, ui/src/components/hr/EmployeeHrFields.tsx, ui/src/lib/hr/render-doc-pdf.tsx; wire into StaffHubPage.tsx.
- Step 1: People tab — extend the employee form with the HR fields from Task 0.3 (CNIC, designation, category, joining/probation dates, notice period, EOBI/SS numbers, set-PIN button).
- Step 2: Documents tab — pick doc type → form auto-fills employee-sourced fields + collects manual fields → live preview (react-pdf, clinic letterhead, disclaimer footer) → “Issue & Print”: render to a Blob, POST to
/hr/documents, thenwindow.print()/ download. List issued docs with download links. - Step 3: Render at least one real PDF and eyeball it (memory: react-pdf needs a real visual check; if any Urdu is added later, follow the Urdu-shaping recipe). Commit.
Wiring, parity & rollout
Task W.1: Sidebar nav + module-gated visibility
Files: the sidebar/nav config + module-flag hook (ui/src/components/providers/ModuleProvider.tsx is already in the working tree).
- Add a “Staff Hub” sidebar entry visible only when the
staff_hrmodule is enabled AND the user hasstaff.hr.view/staff.hr.manage(admin). Route/staff-hub→StaffHubPagewith Attendance / Leave / Documents / People tabs. UseuseUrlState/useDeepNavfor tab state (no?tab=clobber — memory). Commit.
Task W.2: Superadmin read-only inspect (parity)
Files: superadmin tenant panel.- Add a read-only “HR” panel in the superadmin tenant view: employee count, last-30-day attendance count, leave records count, issued-document count for the selected clinic (per superadmin-parity memory). Commit.
Task W.3: API docs
Files:docs/api-reference.md
- Document all
/hr/*and/hr-kiosk/*endpoints (memory: api-documentation-discipline). Commit.
Task W.4: Plan-sync enablement + smoke test
- Confirm plan sync auto-enables
staff_hrat Pro+ (the module-sync logic that readsAVAILABLE_MODULES.minPlan). - Enable on the canonical test tenant ssh & Associates and run an end-to-end smoke: register a kiosk → set a PIN → punch in/out from an allow-listed IP (and confirm a non-allow-listed IP is blocked) → record + cancel a leave → issue one appointment letter PDF. Capture screenshots.
Task W.5: Deploy
- Use the odontox-commit-deploy skill: stash foreign uncommitted files, build + deploy committed HEAD, force-promote the CF canonical, verify fresh dist hash, then pop the stash (memory: commit-deploy discipline, segregate-sessions, stale-dist landmine).
Self-Review (completed by plan author)
Spec coverage: §3 packaging/roles → Tasks 0.5/0.6/W.1; §4 attendance (IP/kiosk/PIN/summary/TZ) → A.1–A.7 + 0.1/0.4; §5 leave (PK defaults/balances/encash) → B.1–B.5; §6 documents (11 types/disclaimer/R2/print) → C.1–C.3; §7 UI surfaces → A.6/A.7/B.5/C.3/W.1; §8 cross-cutting (idempotent migration, security, money icon, superadmin parity, API docs) → 0.4/A.2/W.2/W.3; §9 testing → embedded TDD tasks; §10 rollout → W.4/W.5. No uncovered requirement. Placeholder scan: Two intentionally-condensed UI tasks (A.6/A.7/B.5/C.3) describe components rather than full JSX — acceptable because they depend on the not-yet-loaded existing design-system components; the executing agent should invoke the frontend-design skill and follow [[feedback_ui_visual_bar]]. All server logic/schema/migration/test steps contain complete code. The B.4 “mark covered days as leave” loop is called out as its own explicit Step 2 rather than left implicit. Type consistency:computeDaySummary, availableDays, countLeaveDays, hashKioskToken/verifyKioskToken, PK_LEAVE_DEFAULTS, HR_DOC_TYPES, ensureLeaveTypes, ensureBalance names are consistent across all referencing tasks. Column names match the schema in Tasks 0.1–0.3 (attendancePinHash, punchedAt, workDate, leaveTypeId, r2Key).
