Skip to main content

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
// server/src/schema/hr_attendance.test.ts
import { describe, it, expect } from 'vitest';
import { attendanceKiosks, attendancePunches, attendanceAdjustments, attendanceDaySummary } from './hr_attendance';

describe('hr_attendance schema', () => {
  it('attendancePunches exposes an append-only punch shape', () => {
    expect(Object.keys(attendancePunches)).toEqual(
      expect.arrayContaining(['id', 'clinicId', 'employeeId', 'kioskId', 'punchType', 'punchedAt', 'source', 'sourceRef', 'clientIp', 'createdAt'])
    );
  });
  it('attendanceKiosks stores a hashed token', () => {
    expect(Object.keys(attendanceKiosks)).toEqual(expect.arrayContaining(['id', 'clinicId', 'label', 'tokenHash', 'isActive']));
  });
  it('day summary + adjustments tables exist', () => {
    expect(Object.keys(attendanceDaySummary)).toEqual(expect.arrayContaining(['id', 'employeeId', 'workDate', 'workedMinutes', 'status']));
    expect(Object.keys(attendanceAdjustments)).toEqual(expect.arrayContaining(['id', 'employeeId', 'reason', 'adjustedBy']));
  });
});
  • Step 2: Run test, verify it fails
Run: cd server && npx vitest run src/schema/hr_attendance.test.ts Expected: FAIL — cannot find module ./hr_attendance.
  • Step 3: Create the schema
// server/src/schema/hr_attendance.ts
import { pgTable, text, timestamp, boolean, integer, date, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { clinics } from './clinics';
import { users } from './users';
import { employees } from './payroll';

export const attendanceKiosks = appSchema.table('attendance_kiosks', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  label: text('label').notNull(),
  tokenHash: text('token_hash').notNull(),
  isActive: boolean('is_active').notNull().default(true),
  lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
  revokedAt: timestamp('revoked_at', { withTimezone: true }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ clinicIdx: index('ak_clinic_idx').on(t.clinicId), hashIdx: index('ak_hash_idx').on(t.tokenHash) }));

export const attendancePunches = appSchema.table('attendance_punches', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  kioskId: text('kiosk_id').references(() => attendanceKiosks.id, { onDelete: 'set null' }),
  punchType: text('punch_type').notNull(), // 'in' | 'out'
  punchedAt: timestamp('punched_at', { withTimezone: true }).notNull().defaultNow(),
  source: text('source').notNull().default('pin'), // 'pin' | 'selfie' | 'device'
  sourceRef: text('source_ref'),
  clientIp: text('client_ip'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ empIdx: index('ap_emp_idx').on(t.employeeId), clinicDayIdx: index('ap_clinic_punched_idx').on(t.clinicId, t.punchedAt) }));

export const attendanceAdjustments = appSchema.table('attendance_adjustments', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  adjustedBy: text('adjusted_by').references(() => users.id, { onDelete: 'set null' }),
  workDate: date('work_date').notNull(),
  field: text('field').notNull(),
  oldValue: text('old_value'),
  newValue: text('new_value'),
  reason: text('reason').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ empIdx: index('aadj_emp_idx').on(t.employeeId) }));

export const attendanceDaySummary = appSchema.table('attendance_day_summary', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  workDate: date('work_date').notNull(),
  firstIn: timestamp('first_in', { withTimezone: true }),
  lastOut: timestamp('last_out', { withTimezone: true }),
  workedMinutes: integer('worked_minutes').notNull().default(0),
  lateMinutes: integer('late_minutes').notNull().default(0),
  status: text('status').notNull().default('present'), // present|absent|leave|holiday|partial
  leaveRecordId: text('leave_record_id'),
  computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ uniqEmpDay: index('ads_emp_day_idx').on(t.employeeId, t.workDate) }));

export type AttendancePunch = typeof attendancePunches.$inferSelect;
export type NewAttendancePunch = typeof attendancePunches.$inferInsert;
export type AttendanceDaySummary = typeof attendanceDaySummary.$inferSelect;
  • Step 4: Export from schema index
Add to server/src/schema/index.ts near the other exports:
export * from './hr_attendance';
  • Step 5: Run test, verify PASS
Run: cd server && npx vitest run src/schema/hr_attendance.test.ts Expected: PASS (3 tests).
  • Step 6: Commit
git add server/src/schema/hr_attendance.ts server/src/schema/hr_attendance.test.ts server/src/schema/index.ts
git commit -m "feat(hr): attendance schema (kiosks, punches, adjustments, day summary)"

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
// server/src/schema/hr_leave.test.ts
import { describe, it, expect } from 'vitest';
import { leaveTypes, leaveBalances, leaveRecords } from './hr_leave';

describe('hr_leave schema', () => {
  it('leaveTypes carries quota + policy flags', () => {
    expect(Object.keys(leaveTypes)).toEqual(
      expect.arrayContaining(['id', 'clinicId', 'key', 'label', 'isPaid', 'annualQuota', 'maxConsecutive', 'carryForward', 'carryCap', 'encashable', 'accrual'])
    );
  });
  it('leaveBalances tracks accrual/taken/encashed', () => {
    expect(Object.keys(leaveBalances)).toEqual(
      expect.arrayContaining(['id', 'employeeId', 'leaveTypeId', 'year', 'entitled', 'accrued', 'taken', 'carriedIn', 'encashed'])
    );
  });
  it('leaveRecords store a date range + paid flag', () => {
    expect(Object.keys(leaveRecords)).toEqual(
      expect.arrayContaining(['id', 'employeeId', 'leaveTypeId', 'startDate', 'endDate', 'days', 'isPaid', 'status', 'recordedBy'])
    );
  });
});
  • Step 2: Run test, verify it fails
Run: cd server && npx vitest run src/schema/hr_leave.test.ts Expected: FAIL — cannot find module ./hr_leave.
  • Step 3: Create the schema
// server/src/schema/hr_leave.ts
import { pgTable, text, timestamp, boolean, integer, date, numeric, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { clinics } from './clinics';
import { users } from './users';
import { employees } from './payroll';

export const leaveTypes = appSchema.table('leave_types', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  key: text('key').notNull(), // 'casual'|'sick'|'annual'|'festival'|'maternity'|'unpaid'
  label: text('label').notNull(),
  isPaid: boolean('is_paid').notNull().default(true),
  annualQuota: integer('annual_quota'), // null = uncapped
  maxConsecutive: integer('max_consecutive'),
  carryForward: boolean('carry_forward').notNull().default(false),
  carryCap: integer('carry_cap'),
  encashable: boolean('encashable').notNull().default(false),
  accrual: text('accrual').notNull().default('upfront'), // 'upfront'|'monthly'|'after_12mo'
  isActive: boolean('is_active').notNull().default(true),
  sortOrder: integer('sort_order').notNull().default(0),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ clinicKeyIdx: index('lt_clinic_key_idx').on(t.clinicId, t.key) }));

export const leaveBalances = appSchema.table('leave_balances', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  leaveTypeId: text('leave_type_id').notNull().references(() => leaveTypes.id, { onDelete: 'cascade' }),
  year: integer('year').notNull(),
  entitled: numeric('entitled', { precision: 6, scale: 1 }).notNull().default('0'),
  accrued: numeric('accrued', { precision: 6, scale: 1 }).notNull().default('0'),
  taken: numeric('taken', { precision: 6, scale: 1 }).notNull().default('0'),
  carriedIn: numeric('carried_in', { precision: 6, scale: 1 }).notNull().default('0'),
  encashed: numeric('encashed', { precision: 6, scale: 1 }).notNull().default('0'),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ empYearIdx: index('lb_emp_year_idx').on(t.employeeId, t.year) }));

export const leaveRecords = appSchema.table('leave_records', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  leaveTypeId: text('leave_type_id').notNull().references(() => leaveTypes.id, { onDelete: 'cascade' }),
  startDate: date('start_date').notNull(),
  endDate: date('end_date').notNull(),
  days: numeric('days', { precision: 5, scale: 1 }).notNull(), // supports 0.5
  isPaid: boolean('is_paid').notNull().default(true),
  reason: text('reason'),
  status: text('status').notNull().default('approved'), // 'approved'|'cancelled'
  recordedBy: text('recorded_by').references(() => users.id, { onDelete: 'set null' }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ empIdx: index('lr_emp_idx').on(t.employeeId) }));

export type LeaveType = typeof leaveTypes.$inferSelect;
export type LeaveBalance = typeof leaveBalances.$inferSelect;
export type LeaveRecord = typeof leaveRecords.$inferSelect;
  • Step 4: Export from index
Add to server/src/schema/index.ts: export * from './hr_leave';
  • Step 5: Run test, verify PASS
Run: cd server && npx vitest run src/schema/hr_leave.test.ts Expected: PASS (3 tests).
  • Step 6: Commit
git add server/src/schema/hr_leave.ts server/src/schema/hr_leave.test.ts server/src/schema/index.ts
git commit -m "feat(hr): leave schema (types, balances, records)"

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 (extend employees)
  • Modify: server/src/schema/clinics.ts (add hrSettings)
  • Modify: server/src/schema/index.ts
  • Step 1: Write the failing test
// server/src/schema/hr_documents.test.ts
import { describe, it, expect } from 'vitest';
import { hrDocuments } from './hr_documents';
import { employees } from './payroll';

describe('hr_documents schema', () => {
  it('files an issued document against an employee', () => {
    expect(Object.keys(hrDocuments)).toEqual(
      expect.arrayContaining(['id', 'clinicId', 'employeeId', 'docType', 'templateVersion', 'mergeData', 'r2Key', 'issuedBy', 'issuedAt'])
    );
  });
  it('employees gained HR profile columns', () => {
    expect(Object.keys(employees)).toEqual(
      expect.arrayContaining(['cnic', 'designation', 'employmentCategory', 'dateOfJoining', 'probationEndDate', 'noticePeriodDays', 'eobiNumber'])
    );
  });
});
  • Step 2: Run, verify fail
Run: 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
// server/src/schema/hr_documents.ts
import { pgTable, text, timestamp, integer, jsonb, index } from 'drizzle-orm/pg-core';
import { appSchema } from './base';
import { clinics } from './clinics';
import { users } from './users';
import { employees } from './payroll';

export const hrDocuments = appSchema.table('hr_documents', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  clinicId: text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  employeeId: text('employee_id').notNull().references(() => employees.id, { onDelete: 'cascade' }),
  docType: text('doc_type').notNull(), // see HR_DOC_TYPES
  templateVersion: integer('template_version').notNull().default(1),
  mergeData: jsonb('merge_data'),
  r2Key: text('r2_key').notNull(),
  issuedBy: text('issued_by').references(() => users.id, { onDelete: 'set null' }),
  issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ empIdx: index('hrd_emp_idx').on(t.employeeId) }));

export type HrDocument = typeof hrDocuments.$inferSelect;
  • Step 4: Extend employees in payroll.ts
In 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):
  cnic: text('cnic'),
  designation: text('designation'),
  employmentCategory: text('employment_category'), // permanent|probationer|temporary|contract|apprentice
  dateOfJoining: date('date_of_joining'),
  probationEndDate: date('probation_end_date'),
  noticePeriodDays: integer('notice_period_days').default(30),
  address: text('address'),
  emergencyContact: jsonb('emergency_contact'),
  eobiNumber: text('eobi_number'),
  socialSecurityNumber: text('social_security_number'),
  attendancePinHash: text('attendance_pin_hash'),
(Ensure import { text, timestamp, date, numeric, jsonb, integer } from 'drizzle-orm/pg-core'; includes integer.)
  • Step 5: Add hrSettings to clinics.ts
In server/src/schema/clinics.ts, alongside the other jsonb settings bags, add:
  hrSettings: jsonb('hr_settings').$type<{
    allowedIps?: string[];
    workHours?: { start: string; end: string; graceMinutes: number; workDays: number[] };
    leavePolicyVersion?: number;
  }>(),
  • Step 6: Export + run + commit
Add export * from './hr_documents'; to server/src/schema/index.ts. Run: cd server && npx vitest run src/schema/hr_documents.test.ts → Expected: PASS.
git add server/src/schema/hr_documents.ts server/src/schema/hr_documents.test.ts server/src/schema/payroll.ts server/src/schema/clinics.ts server/src/schema/index.ts
git commit -m "feat(hr): hr_documents table + employee HR fields + clinic hrSettings"

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 existing CREATE TABLE IF NOT EXISTS "app"."passkeys")
  • Step 1: Write the migration (idempotent)
-- server/drizzle/0063_staff_hub.sql
CREATE TABLE IF NOT EXISTS "app"."attendance_kiosks" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "label" text NOT NULL,
  "token_hash" text NOT NULL, "is_active" boolean NOT NULL DEFAULT true,
  "last_used_at" timestamptz, "revoked_at" timestamptz, "created_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."attendance_punches" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "kiosk_id" text, "punch_type" text NOT NULL, "punched_at" timestamptz NOT NULL DEFAULT now(),
  "source" text NOT NULL DEFAULT 'pin', "source_ref" text, "client_ip" text,
  "created_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."attendance_adjustments" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "adjusted_by" text, "work_date" date NOT NULL, "field" text NOT NULL,
  "old_value" text, "new_value" text, "reason" text NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."attendance_day_summary" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "work_date" date NOT NULL, "first_in" timestamptz, "last_out" timestamptz,
  "worked_minutes" integer NOT NULL DEFAULT 0, "late_minutes" integer NOT NULL DEFAULT 0,
  "status" text NOT NULL DEFAULT 'present', "leave_record_id" text, "computed_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."leave_types" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "key" text NOT NULL, "label" text NOT NULL,
  "is_paid" boolean NOT NULL DEFAULT true, "annual_quota" integer, "max_consecutive" integer,
  "carry_forward" boolean NOT NULL DEFAULT false, "carry_cap" integer,
  "encashable" boolean NOT NULL DEFAULT false, "accrual" text NOT NULL DEFAULT 'upfront',
  "is_active" boolean NOT NULL DEFAULT true, "sort_order" integer NOT NULL DEFAULT 0,
  "created_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."leave_balances" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "leave_type_id" text NOT NULL, "year" integer NOT NULL,
  "entitled" numeric(6,1) NOT NULL DEFAULT 0, "accrued" numeric(6,1) NOT NULL DEFAULT 0,
  "taken" numeric(6,1) NOT NULL DEFAULT 0, "carried_in" numeric(6,1) NOT NULL DEFAULT 0,
  "encashed" numeric(6,1) NOT NULL DEFAULT 0, "updated_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."leave_records" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "leave_type_id" text NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL,
  "days" numeric(5,1) NOT NULL, "is_paid" boolean NOT NULL DEFAULT true, "reason" text,
  "status" text NOT NULL DEFAULT 'approved', "recorded_by" text, "created_at" timestamptz NOT NULL DEFAULT now());
CREATE TABLE IF NOT EXISTS "app"."hr_documents" (
  "id" text PRIMARY KEY, "clinic_id" text NOT NULL, "employee_id" text NOT NULL,
  "doc_type" text NOT NULL, "template_version" integer NOT NULL DEFAULT 1, "merge_data" jsonb,
  "r2_key" text NOT NULL, "issued_by" text, "issued_at" timestamptz NOT NULL DEFAULT now(),
  "created_at" timestamptz NOT NULL DEFAULT now());
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "cnic" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "designation" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "employment_category" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "date_of_joining" date;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "probation_end_date" date;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "notice_period_days" integer DEFAULT 30;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "address" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "emergency_contact" jsonb;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "eobi_number" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "social_security_number" text;
ALTER TABLE "app"."employees" ADD COLUMN IF NOT EXISTS "attendance_pin_hash" text;
ALTER TABLE "app"."clinics" ADD COLUMN IF NOT EXISTS "hr_settings" jsonb;
CREATE INDEX IF NOT EXISTS "ap_clinic_punched_idx" ON "app"."attendance_punches" ("clinic_id","punched_at");
CREATE INDEX IF NOT EXISTS "ads_emp_day_idx" ON "app"."attendance_day_summary" ("employee_id","work_date");
CREATE INDEX IF NOT EXISTS "lb_emp_year_idx" ON "app"."leave_balances" ("employee_id","year");
  • Step 2: Mirror into the api.ts bootstrap
In 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)
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
git add server/drizzle/0063_staff_hub.sql server/src/api.ts
git commit -m "feat(hr): idempotent migration for Staff Hub tables + employee/clinic columns"
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
// server/src/constants/modules.test.ts
import { describe, it, expect } from 'vitest';
import { AVAILABLE_MODULES } from './modules';

describe('AVAILABLE_MODULES', () => {
  it('includes Staff Hub at Pro+', () => {
    const m = AVAILABLE_MODULES.find((x) => x.key === 'staff_hr');
    expect(m).toBeTruthy();
    expect(m!.minPlan).toBe('Pro+');
  });
});
  • Step 2: Run → fail. cd server && npx vitest run src/constants/modules.test.ts → FAIL (undefined).
  • Step 3: Add the module entry to AVAILABLE_MODULES in server/src/constants/modules.ts (in the Pro+ group):
    { key: 'staff_hr', label: 'Staff Hub', category: 'admin', description: 'Staff attendance, leave management and Pakistan HR documents (hiring & termination letters)', minPlan: 'Pro+' },
  • Step 4: Run → PASS. Commit:
git add server/src/constants/modules.ts server/src/constants/modules.test.ts
git commit -m "feat(hr): register staff_hr module (Pro+)"

Task 0.6: Permission keys (server + UI parity)

Files:
  • Modify: server/src/lib/permissions.ts (add to PERMISSION_KEYS)
  • Modify: ui/src/lib/permissions.ts (mirror keys + add tree node)
  • Step 1: Add keys to server PERMISSION_KEYS (after the billing.payroll.* block):
  // Staff Hub (HR)
  'staff.hr.view',
  'staff.hr.manage',
  'staff.attendance.view',
  'staff.attendance.manage',
  'staff.leave.view',
  'staff.leave.manage',
  'staff.documents.view',
  'staff.documents.manage',
  • 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 a PERMISSION_TREE group:
{
  key: 'staff_hr', label: 'Staff Hub', icon: 'Users',
  children: [
    { key: 'staff.attendance.view', label: 'View attendance' },
    { key: 'staff.attendance.manage', label: 'Manage attendance & kiosks' },
    { key: 'staff.leave.view', label: 'View leave' },
    { key: 'staff.leave.manage', label: 'Record & approve leave' },
    { key: 'staff.documents.view', label: 'View HR documents' },
    { key: 'staff.documents.manage', label: 'Issue HR documents' },
  ],
}
  • Step 3: Run the parity tripwire (this codebase enforces UI↔server permission parity in CI):
Run: 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:727 if (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
git add server/src/lib/permissions.ts ui/src/lib/permissions.ts
git commit -m "feat(hr): staff.* permission keys with UI parity"

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
// server/src/routes/hr.ts
import { Hono } from 'hono';
import { handleError } from '../lib/errors';
import { requireClinicContext } from '../middleware/clinic-context';
import { requireModule } from '../middleware/module-guard';
import { requirePermissionByMethod } from '../middleware/permissions';

const hr = new Hono();

// Admin-only management surface. (Kiosk routes live in routes/hr-kiosk.ts and are
// mounted separately because they use kiosk-token auth, not the admin guards.)
hr.use('*', requireClinicContext);
hr.use('*', requireModule('staff_hr'));

hr.get('/health', requirePermissionByMethod('staff.hr.view', 'staff.hr.manage'), (c) =>
  c.json({ ok: true, module: 'staff_hr' })
);

export default hr;
  • Step 2: Mount it in server/src/api.ts next to the other protectedRoutes.route(...) lines:
import hr from './routes/hr';
// ...
protectedRoutes.route('/hr', hr);
  • Step 3: Typecheck
Run: cd server && npx tsc --noEmit Expected: no new errors.
  • Step 4: Commit
git add server/src/routes/hr.ts server/src/api.ts
git commit -m "feat(hr): /hr route scaffold gated by module + clinic context"

Track A — Attendance (after Phase 0)

Task A.1: Clinic HR settings endpoint

Files: Modify server/src/routes/hr.ts
  • Step 1: Add GET/PATCH /settings (reads/writes clinics.hrSettings):
import { getReadDb, getTxDb } from '../lib/db';
import { clinics } from '../schema';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { getDatabaseUrl } from '../lib/env';

const hrSettingsSchema = z.object({
  allowedIps: z.array(z.string()).optional(),
  workHours: z.object({
    start: z.string(), end: z.string(),
    graceMinutes: z.number().int().min(0), workDays: z.array(z.number().int().min(0).max(6)),
  }).optional(),
});

hr.get('/settings', requirePermissionByMethod('staff.attendance.view', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getReadDb(getDatabaseUrl());
    const [row] = await db.select({ hrSettings: clinics.hrSettings }).from(clinics).where(eq(clinics.id, clinicId));
    return c.json({ hrSettings: row?.hrSettings ?? {} });
  } catch (e) { return handleError(c, e); }
});

hr.patch('/settings', requirePermissionByMethod('staff.attendance.manage', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const body = hrSettingsSchema.parse(await c.req.json());
    const db = getTxDb(getDatabaseUrl());
    const [row] = await db.select({ hrSettings: clinics.hrSettings }).from(clinics).where(eq(clinics.id, clinicId));
    const merged = { ...(row?.hrSettings ?? {}), ...body };
    await db.update(clinics).set({ hrSettings: merged }).where(eq(clinics.id, clinicId));
    return c.json({ hrSettings: merged });
  } catch (e) { return handleError(c, e); }
});
  • Step 2: Typecheck + commit. cd server && npx tsc --noEmit → clean.
git add server/src/routes/hr.ts && git commit -m "feat(hr): clinic HR settings (IP allowlist, work hours)"

Task A.2: Kiosk register / list / revoke + employee PIN

Files: Create server/src/lib/hr/kiosk-token.ts; Modify server/src/routes/hr.ts
  • Step 1: Failing test for token hashing
// server/src/lib/hr/kiosk-token.test.ts
import { describe, it, expect } from 'vitest';
import { generateKioskToken, hashKioskToken, verifyKioskToken } from './kiosk-token';

describe('kiosk token', () => {
  it('round-trips a token through hashing', async () => {
    const token = generateKioskToken();
    const hash = await hashKioskToken(token);
    expect(hash).not.toEqual(token);
    expect(await verifyKioskToken(token, hash)).toBe(true);
    expect(await verifyKioskToken('wrong', hash)).toBe(false);
  });
});
  • 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):
// server/src/lib/hr/kiosk-token.ts
export function generateKioskToken(): string {
  const a = new Uint8Array(32); crypto.getRandomValues(a);
  return btoa(String.fromCharCode(...a)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
export async function hashKioskToken(token: string): Promise<string> {
  const data = new TextEncoder().encode(token);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
export async function verifyKioskToken(token: string, hash: string): Promise<boolean> {
  return (await hashKioskToken(token)) === hash;
}
  • Step 4: Run → PASS.
  • Step 5: Add endpoints to hr.ts (kiosk CRUD + PIN set; PIN uses existing lib/password):
import { attendanceKiosks, employees } from '../schema';
import { and, desc } from 'drizzle-orm';
import { hashPassword } from '../lib/password';
import { generateKioskToken, hashKioskToken } from '../lib/hr/kiosk-token';

hr.get('/kiosks', requirePermissionByMethod('staff.attendance.view', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getReadDb(getDatabaseUrl());
    const rows = await db.select({ id: attendanceKiosks.id, label: attendanceKiosks.label, isActive: attendanceKiosks.isActive, lastUsedAt: attendanceKiosks.lastUsedAt })
      .from(attendanceKiosks).where(eq(attendanceKiosks.clinicId, clinicId)).orderBy(desc(attendanceKiosks.createdAt));
    return c.json({ kiosks: rows });
  } catch (e) { return handleError(c, e); }
});

hr.post('/kiosks', requirePermissionByMethod('staff.attendance.manage', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const { label } = z.object({ label: z.string().min(1) }).parse(await c.req.json());
    const token = generateKioskToken();
    const tokenHash = await hashKioskToken(token);
    const db = getTxDb(getDatabaseUrl());
    const [row] = await db.insert(attendanceKiosks).values({ clinicId, label, tokenHash }).returning({ id: attendanceKiosks.id });
    // token returned ONCE; client stores it on the kiosk device.
    return c.json({ id: row.id, token });
  } catch (e) { return handleError(c, e); }
});

hr.delete('/kiosks/:id', requirePermissionByMethod('staff.attendance.manage', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getTxDb(getDatabaseUrl());
    await db.update(attendanceKiosks).set({ isActive: false, revokedAt: new Date() })
      .where(and(eq(attendanceKiosks.id, c.req.param('id')), eq(attendanceKiosks.clinicId, clinicId)));
    return c.json({ ok: true });
  } catch (e) { return handleError(c, e); }
});

hr.post('/employees/:id/pin', requirePermissionByMethod('staff.attendance.manage', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const { pin } = z.object({ pin: z.string().regex(/^\d{4,6}$/) }).parse(await c.req.json());
    const pinHash = await hashPassword(pin);
    const db = getTxDb(getDatabaseUrl());
    await db.update(employees).set({ attendancePinHash: pinHash })
      .where(and(eq(employees.id, c.req.param('id')), eq(employees.clinicId, clinicId)));
    return c.json({ ok: true });
  } catch (e) { return handleError(c, e); }
});
  • Step 6: Typecheck + commit.
git add server/src/lib/hr/kiosk-token.ts server/src/lib/hr/kiosk-token.test.ts server/src/routes/hr.ts
git commit -m "feat(hr): kiosk registration tokens + employee clock-in PIN"

Task A.3: Day-summary computation (TZ-correct) — unit-tested logic

Files: Create server/src/lib/hr/attendance-summary.ts + test
  • Step 1: Failing test (pure function: punches → summary, in PKT):
// server/src/lib/hr/attendance-summary.test.ts
import { describe, it, expect } from 'vitest';
import { computeDaySummary } from './attendance-summary';

const WORK = { start: '09:00', end: '18:00', graceMinutes: 15, workDays: [1,2,3,4,5,6] };

describe('computeDaySummary (Asia/Karachi)', () => {
  it('pairs in/out and computes worked + late minutes', () => {
    // 09:30 PKT = 04:30Z ; 17:30 PKT = 12:30Z
    const s = computeDaySummary({
      punches: [
        { punchType: 'in', punchedAt: new Date('2026-06-10T04:30:00Z') },
        { punchType: 'out', punchedAt: new Date('2026-06-10T12:30:00Z') },
      ], workHours: WORK,
    });
    expect(s.workedMinutes).toBe(480);   // 8h
    expect(s.lateMinutes).toBe(15);      // 09:30 vs 09:00 + 15 grace = 15 late
    expect(s.status).toBe('present');
  });

  it('marks absent when there are no punches', () => {
    const s = computeDaySummary({ punches: [], workHours: WORK });
    expect(s.status).toBe('absent');
    expect(s.workedMinutes).toBe(0);
  });
});
  • Step 2: Run → fail. cd server && npx vitest run src/lib/hr/attendance-summary.test.ts
  • Step 3: Implement (use Intl PKT offset; pair punches; no naive recast):
// server/src/lib/hr/attendance-summary.ts
type Punch = { punchType: string; punchedAt: Date };
type WorkHours = { start: string; end: string; graceMinutes: number; workDays: number[] };

// Minutes-since-midnight of `instant` in Asia/Karachi (UTC+5, no DST).
function pktMinutes(instant: Date): number {
  const pkt = new Date(instant.getTime() + 5 * 60 * 60 * 1000);
  return pkt.getUTCHours() * 60 + pkt.getUTCMinutes();
}

export function computeDaySummary(input: { punches: Punch[]; workHours: WorkHours }) {
  const { punches, workHours } = input;
  const ins = punches.filter((p) => p.punchType === 'in').sort((a, b) => +a.punchedAt - +b.punchedAt);
  const outs = punches.filter((p) => p.punchType === 'out').sort((a, b) => +a.punchedAt - +b.punchedAt);
  if (ins.length === 0) return { firstIn: null, lastOut: null, workedMinutes: 0, lateMinutes: 0, status: 'absent' as const };

  const firstIn = ins[0].punchedAt;
  const lastOut = outs.length ? outs[outs.length - 1].punchedAt : null;
  const workedMinutes = lastOut ? Math.max(0, Math.round((+lastOut - +firstIn) / 60000)) : 0;

  const [sh, sm] = workHours.start.split(':').map(Number);
  const startMin = sh * 60 + sm + (workHours.graceMinutes ?? 0);
  const lateMinutes = Math.max(0, pktMinutes(firstIn) - startMin);
  const status = lastOut ? 'present' as const : 'partial' as const;
  return { firstIn, lastOut, workedMinutes, lateMinutes, status };
}
  • Step 4: Run → PASS. Commit.
git add server/src/lib/hr/attendance-summary.ts server/src/lib/hr/attendance-summary.test.ts
git commit -m "feat(hr): TZ-correct day-summary computation"

Task A.4: Kiosk routes (token + IP + PIN punch)

Files: Create server/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 /hr so it doesn’t inherit requirePermission.
// server/src/routes/hr-kiosk.ts
import { Hono } from 'hono';
import { getReadDb, getTxDb } from '../lib/db';
import { attendanceKiosks, attendancePunches, attendanceDaySummary, employees, clinics } from '../schema';
import { and, eq, gte, lte } from 'drizzle-orm';
import { z } from 'zod';
import { handleError, AppError } from '../lib/errors';
import { getDatabaseUrl } from '../lib/env';
import { verifyPassword } from '../lib/password';
import { hashKioskToken } from '../lib/hr/kiosk-token';
import { computeDaySummary } from '../lib/hr/attendance-summary';

const kiosk = new Hono();

async function authKiosk(c: any) {
  const token = c.req.header('x-kiosk-token');
  if (!token) throw new AppError('Kiosk token required', 401);
  const hash = await hashKioskToken(token);
  const db = getReadDb(getDatabaseUrl());
  const [k] = await db.select().from(attendanceKiosks)
    .where(and(eq(attendanceKiosks.tokenHash, hash), eq(attendanceKiosks.isActive, true)));
  if (!k) throw new AppError('Invalid kiosk', 401);
  // IP allowlist (CF-Connecting-IP is the real client IP on Workers)
  const ip = c.req.header('cf-connecting-ip') || '';
  const [clinic] = await db.select({ hrSettings: clinics.hrSettings }).from(clinics).where(eq(clinics.id, k.clinicId));
  const allowed = clinic?.hrSettings?.allowedIps ?? [];
  if (allowed.length && !allowed.includes(ip)) throw new AppError('Off-premises clock-in blocked', 403);
  return { kiosk: k, clinicId: k.clinicId, ip };
}

kiosk.get('/roster', async (c) => {
  try {
    const { clinicId } = await authKiosk(c);
    const db = getReadDb(getDatabaseUrl());
    const rows = await db.select({ id: employees.id, name: employees.name, designation: employees.designation })
      .from(employees).where(and(eq(employees.clinicId, clinicId), eq(employees.status, 'active')));
    return c.json({ staff: rows });
  } catch (e) { return handleError(c, e); }
});

kiosk.post('/punch', async (c) => {
  try {
    const { kiosk: k, clinicId, ip } = await authKiosk(c);
    const { employeeId, pin, type } = z.object({
      employeeId: z.string(), pin: z.string(), type: z.enum(['in', 'out']),
    }).parse(await c.req.json());

    const db = getTxDb(getDatabaseUrl());
    const [emp] = await db.select().from(employees).where(and(eq(employees.id, employeeId), eq(employees.clinicId, clinicId)));
    if (!emp || !emp.attendancePinHash || !(await verifyPassword(pin, emp.attendancePinHash))) {
      throw new AppError('Invalid PIN', 401);
    }

    const now = new Date();
    await db.insert(attendancePunches).values({
      clinicId, employeeId, kioskId: k.id, punchType: type, punchedAt: now, source: 'pin', clientIp: ip,
    });
    await db.update(attendanceKiosks).set({ lastUsedAt: now }).where(eq(attendanceKiosks.id, k.id));

    // Recompute today's summary (PKT day window).
    const dayStartZ = new Date(now.getTime()); // bounded fetch below by clinic-day
    const [clinic] = await db.select({ hrSettings: clinics.hrSettings }).from(clinics).where(eq(clinics.id, clinicId));
    const work = clinic?.hrSettings?.workHours ?? { start: '09:00', end: '18:00', graceMinutes: 15, workDays: [1,2,3,4,5,6] };
    // PKT calendar date string
    const pktDate = new Date(now.getTime() + 5 * 3600 * 1000).toISOString().slice(0, 10);
    const lo = new Date(`${pktDate}T00:00:00+05:00`); const hi = new Date(`${pktDate}T23:59:59+05:00`);
    const todays = await db.select().from(attendancePunches)
      .where(and(eq(attendancePunches.employeeId, employeeId), gte(attendancePunches.punchedAt, lo), lte(attendancePunches.punchedAt, hi)));
    const s = computeDaySummary({ punches: todays, workHours: work });
    const [existing] = await db.select({ id: attendanceDaySummary.id }).from(attendanceDaySummary)
      .where(and(eq(attendanceDaySummary.employeeId, employeeId), eq(attendanceDaySummary.workDate, pktDate)));
    if (existing) {
      await db.update(attendanceDaySummary).set({ ...s, computedAt: now }).where(eq(attendanceDaySummary.id, existing.id));
    } else {
      await db.insert(attendanceDaySummary).values({ clinicId, employeeId, workDate: pktDate, ...s, computedAt: now });
    }
    return c.json({ ok: true, punchType: type, at: now.toISOString(), name: emp.name });
  } catch (e) { return handleError(c, e); }
});

export default kiosk;
  • Step 2: Mount in server/src/api.ts under 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):
import hrKiosk from './routes/hr-kiosk';
protectedRoutes.route('/hr-kiosk', hrKiosk);
If kiosk devices are not logged-in users, mount hrKiosk on the public api router (like public-booking) instead of protectedRoutes, 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.
git add server/src/routes/hr-kiosk.ts server/src/api.ts
git commit -m "feat(hr): kiosk punch endpoint (token + IP allowlist + PIN, recomputes day summary)"

Task A.5: Admin attendance read + adjustments

Files: Modify server/src/routes/hr.ts
  • Step 1: Add GET /attendance and POST /attendance/adjustments:
import { attendancePunches, attendanceDaySummary, attendanceAdjustments } from '../schema';
import { gte, lte } from 'drizzle-orm';

hr.get('/attendance', requirePermissionByMethod('staff.attendance.view', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const from = c.req.query('from'); const to = c.req.query('to'); const employeeId = c.req.query('employeeId');
    const db = getReadDb(getDatabaseUrl());
    const conds = [eq(attendanceDaySummary.clinicId, clinicId)];
    if (from) conds.push(gte(attendanceDaySummary.workDate, from));
    if (to) conds.push(lte(attendanceDaySummary.workDate, to));
    if (employeeId) conds.push(eq(attendanceDaySummary.employeeId, employeeId));
    const summaries = await db.select().from(attendanceDaySummary).where(and(...conds));
    return c.json({ summaries });
  } catch (e) { return handleError(c, e); }
});

hr.post('/attendance/adjustments', requirePermissionByMethod('staff.attendance.manage', 'staff.attendance.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const user = c.get('user');
    const body = z.object({
      employeeId: z.string(), workDate: z.string(), field: z.string(),
      oldValue: z.string().optional(), newValue: z.string().optional(), reason: z.string().min(1),
    }).parse(await c.req.json());
    const db = getTxDb(getDatabaseUrl());
    await db.insert(attendanceAdjustments).values({ clinicId, adjustedBy: user.id, ...body });
    return c.json({ ok: true });
  } catch (e) { return handleError(c, e); }
});
  • Step 2: Typecheck + commit.
git add server/src/routes/hr.ts && git commit -m "feat(hr): admin attendance read + adjustments"

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:
export async function kioskRoster(token: string) {
  return apiGet('/hr-kiosk/roster', { headers: { 'x-kiosk-token': token } });
}
export async function kioskPunch(token: string, body: { employeeId: string; pin: string; type: 'in'|'out' }) {
  return apiPost('/hr-kiosk/punch', body, { headers: { 'x-kiosk-token': token } });
}
(Match the existing 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 from localStorage (hr_kiosk_token), shows the roster as large tappable tiles → on tap, a PIN pad → IN/OUT buttons → success toast with name + time. Use Banknote-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 /kiosk as a standalone layout (outside the authenticated dashboard shell). Verify it renders with a real browser pass (screenshot), not just tsc.
  • Step 4: Commit.
git add ui/src/pages/KioskPage.tsx ui/src/lib/serverComm.ts ui/src/App.tsx
git commit -m "feat(hr): full-screen kiosk clock-in UI"

Task A.7: Admin Attendance tab

Files: Create ui/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.
git add ui/src/components/hr/AttendanceTab.tsx ui/src/pages/StaffHubPage.tsx
git commit -m "feat(hr): admin attendance tab (grid, corrections, kiosk management)"

Track B — Leave (after Phase 0)

Task B.1: PK leave-type seeding

Files: Create server/src/lib/hr/leave-defaults.ts + test
  • Step 1: Failing test
// server/src/lib/hr/leave-defaults.test.ts
import { describe, it, expect } from 'vitest';
import { PK_LEAVE_DEFAULTS } from './leave-defaults';

describe('PK_LEAVE_DEFAULTS', () => {
  it('encodes statutory casual/sick/annual/festival/maternity/unpaid', () => {
    const keys = PK_LEAVE_DEFAULTS.map((d) => d.key);
    expect(keys).toEqual(expect.arrayContaining(['casual','sick','annual','festival','maternity','unpaid']));
    const casual = PK_LEAVE_DEFAULTS.find((d) => d.key === 'casual')!;
    expect(casual.annualQuota).toBe(10); expect(casual.maxConsecutive).toBe(3); expect(casual.carryForward).toBe(false);
    const sick = PK_LEAVE_DEFAULTS.find((d) => d.key === 'sick')!;
    expect(sick.annualQuota).toBe(8); expect(sick.carryForward).toBe(true); expect(sick.carryCap).toBe(16);
    const annual = PK_LEAVE_DEFAULTS.find((d) => d.key === 'annual')!;
    expect(annual.annualQuota).toBe(14); expect(annual.encashable).toBe(true); expect(annual.accrual).toBe('after_12mo');
    const unpaid = PK_LEAVE_DEFAULTS.find((d) => d.key === 'unpaid')!;
    expect(unpaid.isPaid).toBe(false);
  });
});
  • Step 2: Run → fail.
  • Step 3: Implement (spec §2):
// server/src/lib/hr/leave-defaults.ts
export const PK_LEAVE_DEFAULTS = [
  { key: 'casual',    label: 'Casual Leave',    isPaid: true,  annualQuota: 10, maxConsecutive: 3,  carryForward: false, carryCap: null, encashable: false, accrual: 'upfront',   sortOrder: 1 },
  { key: 'sick',      label: 'Sick Leave',      isPaid: true,  annualQuota: 8,  maxConsecutive: null, carryForward: true,  carryCap: 16,   encashable: false, accrual: 'upfront',   sortOrder: 2 },
  { key: 'annual',    label: 'Annual / Earned', isPaid: true,  annualQuota: 14, maxConsecutive: null, carryForward: true,  carryCap: 30,   encashable: true,  accrual: 'after_12mo', sortOrder: 3 },
  { key: 'festival',  label: 'Festival Holiday',isPaid: true,  annualQuota: 10, maxConsecutive: null, carryForward: false, carryCap: null, encashable: false, accrual: 'upfront',   sortOrder: 4 },
  { key: 'maternity', label: 'Maternity Leave', isPaid: true,  annualQuota: 112,maxConsecutive: null, carryForward: false, carryCap: null, encashable: false, accrual: 'upfront',   sortOrder: 5 },
  { key: 'unpaid',    label: 'Unpaid (LWOP)',   isPaid: false, annualQuota: null,maxConsecutive: null,carryForward: false, carryCap: null, encashable: false, accrual: 'upfront',   sortOrder: 6 },
] as const;
  • Step 4: Run → PASS. Commit.
git add server/src/lib/hr/leave-defaults.ts server/src/lib/hr/leave-defaults.test.ts
git commit -m "feat(hr): Pakistan statutory leave defaults"

Task B.2: Leave-type seeding + CRUD endpoints

Files: Modify server/src/routes/hr.ts
  • Step 1: Add idempotent seed + CRUD:
import { leaveTypes } from '../schema';
import { PK_LEAVE_DEFAULTS } from '../lib/hr/leave-defaults';

async function ensureLeaveTypes(db: any, clinicId: string) {
  const existing = await db.select({ key: leaveTypes.key }).from(leaveTypes).where(eq(leaveTypes.clinicId, clinicId));
  const have = new Set(existing.map((r: any) => r.key));
  const toInsert = PK_LEAVE_DEFAULTS.filter((d) => !have.has(d.key)).map((d) => ({ clinicId, ...d, carryCap: d.carryCap ?? null, maxConsecutive: d.maxConsecutive ?? null, annualQuota: d.annualQuota ?? null }));
  if (toInsert.length) await db.insert(leaveTypes).values(toInsert);
}

hr.get('/leave-types', requirePermissionByMethod('staff.leave.view', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getTxDb(getDatabaseUrl());
    await ensureLeaveTypes(db, clinicId);
    const rows = await db.select().from(leaveTypes).where(eq(leaveTypes.clinicId, clinicId)).orderBy(leaveTypes.sortOrder);
    return c.json({ leaveTypes: rows });
  } catch (e) { return handleError(c, e); }
});

hr.patch('/leave-types/:id', requirePermissionByMethod('staff.leave.manage', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const body = z.object({
      label: z.string().optional(), isPaid: z.boolean().optional(), annualQuota: z.number().int().nullable().optional(),
      maxConsecutive: z.number().int().nullable().optional(), carryForward: z.boolean().optional(),
      carryCap: z.number().int().nullable().optional(), encashable: z.boolean().optional(), isActive: z.boolean().optional(),
    }).parse(await c.req.json());
    const db = getTxDb(getDatabaseUrl());
    await db.update(leaveTypes).set(body).where(and(eq(leaveTypes.id, c.req.param('id')), eq(leaveTypes.clinicId, clinicId)));
    return c.json({ ok: true });
  } catch (e) { return handleError(c, e); }
});
  • Step 2: Typecheck + commit.
git add server/src/routes/hr.ts && git commit -m "feat(hr): leave-type seeding + edit endpoints"

Task B.3: Leave balance math — unit-tested logic

Files: Create server/src/lib/hr/leave-balance.ts + test
  • Step 1: Failing test
// server/src/lib/hr/leave-balance.test.ts
import { describe, it, expect } from 'vitest';
import { availableDays, countLeaveDays } from './leave-balance';

describe('leave balance', () => {
  it('available = accrued + carriedIn - taken - encashed', () => {
    expect(availableDays({ accrued: 14, carriedIn: 5, taken: 4, encashed: 2 })).toBe(13);
  });
  it('counts inclusive day span with half-day support', () => {
    expect(countLeaveDays('2026-06-10', '2026-06-12', false)).toBe(3);
    expect(countLeaveDays('2026-06-10', '2026-06-10', true)).toBe(0.5);
  });
});
  • Step 2: Run → fail.
  • Step 3: Implement:
// server/src/lib/hr/leave-balance.ts
export function availableDays(b: { accrued: number; carriedIn: number; taken: number; encashed: number }): number {
  return b.accrued + b.carriedIn - b.taken - b.encashed;
}
export function countLeaveDays(start: string, end: string, halfDay: boolean): number {
  if (halfDay) return 0.5;
  const s = new Date(`${start}T00:00:00Z`); const e = new Date(`${end}T00:00:00Z`);
  return Math.floor((+e - +s) / 86400000) + 1;
}
  • Step 4: Run → PASS. Commit.
git add server/src/lib/hr/leave-balance.ts server/src/lib/hr/leave-balance.test.ts
git commit -m "feat(hr): leave balance + day-count helpers"

Task B.4: Leave records (record / cancel / balances) + encashment

Files: Modify server/src/routes/hr.ts
  • Step 1: Add balances read, record, cancel, encash:
import { leaveBalances, leaveRecords, attendanceDaySummary } from '../schema';
import { availableDays, countLeaveDays } from '../lib/hr/leave-balance';

async function ensureBalance(db: any, clinicId: string, employeeId: string, leaveTypeId: string, year: number, entitled: number) {
  const [b] = await db.select().from(leaveBalances)
    .where(and(eq(leaveBalances.employeeId, employeeId), eq(leaveBalances.leaveTypeId, leaveTypeId), eq(leaveBalances.year, year)));
  if (b) return b;
  const [created] = await db.insert(leaveBalances)
    .values({ clinicId, employeeId, leaveTypeId, year, entitled: String(entitled), accrued: String(entitled) }).returning();
  return created;
}

hr.get('/leave-balances', requirePermissionByMethod('staff.leave.view', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const employeeId = c.req.query('employeeId'); const year = Number(c.req.query('year') || new Date().getFullYear());
    const db = getReadDb(getDatabaseUrl());
    const conds = [eq(leaveBalances.clinicId, clinicId), eq(leaveBalances.year, year)];
    if (employeeId) conds.push(eq(leaveBalances.employeeId, employeeId));
    const rows = await db.select().from(leaveBalances).where(and(...conds));
    return c.json({ balances: rows.map((r) => ({ ...r, available: availableDays({ accrued: +r.accrued, carriedIn: +r.carriedIn, taken: +r.taken, encashed: +r.encashed }) })) });
  } catch (e) { return handleError(c, e); }
});

hr.post('/leave-records', requirePermissionByMethod('staff.leave.manage', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId; const user = c.get('user');
    const body = z.object({
      employeeId: z.string(), leaveTypeId: z.string(), startDate: z.string(), endDate: z.string(),
      halfDay: z.boolean().optional(), reason: z.string().optional(),
    }).parse(await c.req.json());
    const db = getTxDb(getDatabaseUrl());
    const [lt] = await db.select().from(leaveTypes).where(and(eq(leaveTypes.id, body.leaveTypeId), eq(leaveTypes.clinicId, clinicId)));
    if (!lt) throw new AppError('Unknown leave type', 400);
    const days = countLeaveDays(body.startDate, body.endDate, !!body.halfDay);
    const year = Number(body.startDate.slice(0, 4));
    const bal = await ensureBalance(db, clinicId, body.employeeId, lt.id, year, lt.annualQuota ?? 0);
    const [rec] = await db.insert(leaveRecords).values({
      clinicId, employeeId: body.employeeId, leaveTypeId: lt.id, startDate: body.startDate, endDate: body.endDate,
      days: String(days), isPaid: lt.isPaid, reason: body.reason, recordedBy: user.id,
    }).returning();
    await db.update(leaveBalances).set({ taken: String(+bal.taken + days), updatedAt: new Date() }).where(eq(leaveBalances.id, bal.id));
    // Mark covered days as 'leave' in the attendance summary (upsert per day omitted for brevity — loop start..end).
    return c.json({ record: rec });
  } catch (e) { return handleError(c, e); }
});

hr.post('/leave-records/:id/cancel', requirePermissionByMethod('staff.leave.manage', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getTxDb(getDatabaseUrl());
    const [rec] = await db.select().from(leaveRecords).where(and(eq(leaveRecords.id, c.req.param('id')), eq(leaveRecords.clinicId, clinicId)));
    if (!rec || rec.status === 'cancelled') return c.json({ ok: true });
    await db.update(leaveRecords).set({ status: 'cancelled' }).where(eq(leaveRecords.id, rec.id));
    const year = Number(rec.startDate.slice(0, 4));
    const [bal] = await db.select().from(leaveBalances).where(and(eq(leaveBalances.employeeId, rec.employeeId), eq(leaveBalances.leaveTypeId, rec.leaveTypeId), eq(leaveBalances.year, year)));
    if (bal) await db.update(leaveBalances).set({ taken: String(Math.max(0, +bal.taken - +rec.days)) }).where(eq(leaveBalances.id, bal.id));
    return c.json({ ok: true });
  } catch (e) { return handleError(c, e); }
});

hr.post('/leave-records/encash', requirePermissionByMethod('staff.leave.manage', 'staff.leave.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const { balanceId, days } = z.object({ balanceId: z.string(), days: z.number().positive() }).parse(await c.req.json());
    const db = getTxDb(getDatabaseUrl());
    const [bal] = await db.select().from(leaveBalances).where(and(eq(leaveBalances.id, balanceId), eq(leaveBalances.clinicId, clinicId)));
    if (!bal) throw new AppError('Balance not found', 404);
    await db.update(leaveBalances).set({ encashed: String(+bal.encashed + days), updatedAt: new Date() }).where(eq(leaveBalances.id, bal.id));
    return c.json({ ok: true }); // payroll consumes encashed via full & final / next run
  } catch (e) { return handleError(c, e); }
});
  • Step 2: Implement the “mark covered days as leave” loop noted above: iterate startDate..endDate, upsert attendanceDaySummary rows with status='leave' and leaveRecordId=rec.id. (Reuse the upsert pattern from Task A.4.)
  • Step 3: Typecheck + commit.
git add server/src/routes/hr.ts && git commit -m "feat(hr): leave balances, record/cancel, encashment"

Task B.5: Leave UI tab

Files: Create ui/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.
git add ui/src/components/hr/LeaveTab.tsx ui/src/pages/StaffHubPage.tsx ui/src/lib/serverComm.ts
git commit -m "feat(hr): leave management UI tab"

Track C — HR documents (after Phase 0)

Task C.1: Template definitions + merge fields

Files: Create server/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
// server/src/lib/hr/document-templates.test.ts
import { describe, it, expect } from 'vitest';
import { HR_DOC_TYPES, getTemplate } from './document-templates';

describe('HR document templates', () => {
  it('defines the 11 PK document types', () => {
    expect(HR_DOC_TYPES).toEqual(expect.arrayContaining([
      'offer','appointment','probation_confirmation','warning','show_cause','termination',
      'dismissal','relieving','experience_certificate','salary_certificate','full_final_settlement',
    ]));
  });
  it('appointment template declares the statutory merge fields', () => {
    const t = getTemplate('appointment');
    expect(t.fields.map((f) => f.key)).toEqual(expect.arrayContaining(['employeeName','designation','employmentCategory','probationEndDate','baseSalary','noticePeriodDays','dateOfJoining']));
    expect(t.disclaimer).toMatch(/not legal advice/i);
  });
});
  • 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 shared disclaimer string on every template:
export const HR_DOC_DISCLAIMER =
  'This document is a template provided for convenience and reflects common Pakistani employment practice and the cited statutes. It is not legal advice. Please have it reviewed by your legal advisor before use.';
  • Step 4: Run → PASS. Commit.
git add server/src/lib/hr/document-templates.ts server/src/lib/hr/document-templates.test.ts
git commit -m "feat(hr): Pakistan HR document templates + merge-field definitions"

Task C.2: Issue / list / get / delete endpoints (client renders PDF, server stores)

Files: Modify server/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, writes hr_documents. Mirrors the document-letterhead upload route.
import { hrDocuments, employees } from '../schema';
import { getR2Service } from '../lib/r2';
import { HR_DOC_TYPES } from '../lib/hr/document-templates';

hr.get('/documents/templates', requirePermissionByMethod('staff.documents.view', 'staff.documents.manage'), (c) =>
  c.json({ types: HR_DOC_TYPES }));

hr.post('/documents', requirePermissionByMethod('staff.documents.manage', 'staff.documents.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId; const user = c.get('user');
    const form = await c.req.formData();
    const file = form.get('file') as File | null;
    const employeeId = String(form.get('employeeId') || '');
    const docType = String(form.get('docType') || '');
    const mergeData = JSON.parse(String(form.get('mergeData') || '{}'));
    if (!file) throw new AppError('No PDF provided', 400);
    if (!HR_DOC_TYPES.includes(docType as any)) throw new AppError('Unknown document type', 400);
    const r2 = getR2Service(c.env as any); if (!r2) throw new AppError('Storage unavailable', 503);
    const key = `hr/${clinicId}/${employeeId}/${docType}-${Date.now()}.pdf`;
    await r2.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: 'application/pdf' } });
    const db = getTxDb(getDatabaseUrl());
    const [row] = await db.insert(hrDocuments).values({ clinicId, employeeId, docType, mergeData, r2Key: key, issuedBy: user.id }).returning({ id: hrDocuments.id });
    return c.json({ id: row.id, r2Key: key });
  } catch (e) { return handleError(c, e); }
});

hr.get('/documents', requirePermissionByMethod('staff.documents.view', 'staff.documents.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId; const employeeId = c.req.query('employeeId');
    const db = getReadDb(getDatabaseUrl());
    const conds = [eq(hrDocuments.clinicId, clinicId)];
    if (employeeId) conds.push(eq(hrDocuments.employeeId, employeeId));
    const rows = await db.select({ id: hrDocuments.id, employeeId: hrDocuments.employeeId, docType: hrDocuments.docType, issuedAt: hrDocuments.issuedAt }).from(hrDocuments).where(and(...conds)).orderBy(desc(hrDocuments.issuedAt));
    return c.json({ documents: rows });
  } catch (e) { return handleError(c, e); }
});

hr.get('/documents/:id/download', requirePermissionByMethod('staff.documents.view', 'staff.documents.manage'), async (c) => {
  try {
    const clinicId = c.get('clinicContext').currentClinicId;
    const db = getReadDb(getDatabaseUrl());
    const [row] = await db.select().from(hrDocuments).where(and(eq(hrDocuments.id, c.req.param('id')), eq(hrDocuments.clinicId, clinicId)));
    if (!row) throw new AppError('Not found', 404);
    const r2 = getR2Service(c.env as any); const obj = await r2!.get(row.r2Key);
    if (!obj) throw new AppError('File missing', 404);
    return new Response(obj.body, { headers: { 'content-type': 'application/pdf' } });
  } catch (e) { return handleError(c, e); }
});
  • Step 2: Typecheck + commit.
git add server/src/routes/hr.ts && git commit -m "feat(hr): HR document issue/list/download (R2-backed)"

Task C.3: Document UI + People tab (HR fields)

Files: Create ui/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, then window.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.
git add ui/src/components/hr/DocumentsTab.tsx ui/src/components/hr/EmployeeHrFields.tsx ui/src/lib/hr/render-doc-pdf.tsx ui/src/pages/StaffHubPage.tsx
git commit -m "feat(hr): HR documents UI (templates, preview, print) + employee HR fields"

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_hr module is enabled AND the user has staff.hr.view/staff.hr.manage (admin). Route /staff-hubStaffHubPage with Attendance / Leave / Documents / People tabs. Use useUrlState/useDeepNav for 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_hr at Pro+ (the module-sync logic that reads AVAILABLE_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).