Skip to main content

Staff Hub — HR & Attendance Module (Design Spec)

Date: 2026-06-14 Status: Approved (brainstorm) → ready for implementation plan Owner: ssh Module key: staff_hr · Label: “Staff Hub” · Tier: Pro+ (bundled, auto-enabled by plan sync)

1. Summary

A self-contained HR module for clinics, sitting on top of the existing employees/payroll chain. It delivers three capabilities in one v1 release:
  • A. Attendance / time-clock — staff clock in/out at a shared clinic kiosk, with on-premises enforcement by IP allowlist (no biometric hardware, no GPS games in v1). Punches roll up to daily/period summaries that feed payroll.
  • B. Leave management — BreatheHR-style leave types, balances, accrual, carry-over and encashment, seeded with Pakistan statutory defaults. Admin records and approves leave.
  • C. Pakistan hiring/termination document pack — fillable, printable HR letters (offer, appointment, probation, warning, show-cause, termination, dismissal, relieving, experience/service certificate, salary certificate, full & final settlement) rendered on clinic letterhead, auto-filled from the employee record, and filed against the employee.

Decisions locked during brainstorm

  • Biometric is deferred. v1 presence enforcement = IP allowlist (clock-in must originate from the clinic’s registered network/public IP). A personal PIN identifies the staffer at a shared kiosk. The punch-capture path is built behind a pluggable “punch source” interface so selfie/biometric/fingerprint-device can be added later with zero rework.
  • Access = clinic admin only for all management screens. The clock-in kiosk is a separate locked station used by all staff but exposes no dashboards. No employee self-service portal in v1.
  • Packaging = bundled into Pro+ (same tier as Expenses/Analytics), auto-enabled via plan sync.
  • Legal framing = templates, not legal advice. Every document carries a footer recommending the clinic’s own legal counsel review before use.

Non-goals (v1)

Employee self-service portal · Network Hub multi-branch HR roll-up (org_admin) · AI face-match on selfies · physical fingerprint-device bridge · automated EOBI/SESSI/PESSI filing · shift-roster planning/scheduling · overtime auto-calculation rules engine.
Templates and leave defaults are grounded in the Industrial & Commercial Employment (Standing Orders) Ordinance, 1968 and the provincial Shops & Establishments statutes. These inform defaults and field requirements only; they are not legal advice and every clinic can edit them.
Statutory itemDefault encodedSource
Casual leave10 days/yr paid, ≤3 consecutive, no carry-overShops & Establishments
Sick leave8 days/yr paid, carry-forward, cap 16Shops & Establishments
Annual/earned leave14 days/yr after 12 mo service, accrue to 30, encashableShops & Establishments
Festival holidays10 days/yr paidShops & Establishments
Maternityprovincial (e.g. Sindh 16 weeks) — configurableprovincial Maternity Benefit laws
Probation≤3 months (non-managerial), no notice on termination during probationStanding Orders SO 1
Notice (permanent)1 month notice or pay in lieu; reasons stated in writingStanding Orders
Misconduct dismissalwritten charge + opportunity to explain (show-cause/inquiry)Standing Orders SO 15
Service certificatestatutory right at end of serviceStanding Orders
Sources: Standing Orders Ordinance 1968 (punjablaws.gov.pk/laws/222.html) · West Pakistan Shops & Establishments Ordinance 1969 (commonlii.org) · WageIndicator PK leave · Lexology PK termination.

3. Roles, access & packaging

  • Module gating: add { key: 'staff_hr', label: 'Staff Hub', category: 'admin', minPlan: 'Pro+' } to AVAILABLE_MODULES (server/src/constants/modules.ts). Gated by module-guard middleware + clinic_modules. Auto-enabled by plan sync at Pro+.
  • Permission keys (new nodes in PERMISSION_TREE, server + UI parity enforced by the CI tripwire):
    • staff.hr.view / staff.hr.manage (umbrella; admin-default-on)
    • staff.attendance.view / staff.attendance.manage
    • staff.leave.view / staff.leave.manage
    • staff.documents.view / staff.documents.manage
    • Defaults grant all to the clinic admin role only (per “admin only” decision); reception/doctor get none in v1.
  • Routes follow the established pattern: requireClinicContext + requirePermissionByMethod(...).
  • Kiosk endpoints are an exception — they are not behind the admin permission (staff aren’t admins). They authenticate via a kiosk device token (see §4.2), scoped to one clinic, and are the only un-admin-gated surface in the module.

4. Part A — Attendance / time-clock

4.1 Integrity model (software-only, IP-enforced)

Clock-in is accepted only if all pass:
  1. Kiosk device token present and valid (admin-registered device; reuses the device_trust_tokens hashing pattern → new attendance_kiosks table).
  2. Request IP ∈ clinic IP allowlist (clinic.hrSettings.allowedIps). The server reads the real client IP from the Cloudflare CF-Connecting-IP header (not a spoofable body field).
  3. Personal PIN matches the staffer’s hashed attendance_pin (bcrypt/scrypt via existing lib/password). PIN identifies which employee is punching.
  4. (deferred-ready) optional punch-source attestation — interface allows a future selfie blob or fingerprint-device payload; v1 ships the pin source only.
Honest residual risk: PIN sharing is still possible; v1 mitigations are the IP lock (can’t punch off-site) + an immutable, admin-reviewable ledger. Selfie/biometric is the documented next upgrade.

4.2 Data model

attendance_kiosks            -- a registered clock-in station
  id (text pk)
  clinic_id (fk clinics, cascade)
  label                       -- "Reception iPad"
  token_hash                  -- hashed kiosk token (device_trust_tokens pattern)
  is_active (bool)
  last_used_at, created_at, revoked_at

attendance_punches           -- append-only ledger (NEVER updated/deleted)
  id (text pk)
  clinic_id (fk)
  employee_id (fk employees, cascade)
  kiosk_id (fk attendance_kiosks, set null)
  punch_type                  -- 'in' | 'out'
  punched_at (timestamptz)    -- server time, stored UTC
  source                      -- 'pin' (v1) | 'selfie' | 'device' (future)
  source_ref                  -- nullable R2 key for future selfie
  client_ip                   -- CF-Connecting-IP, for audit
  created_at

attendance_adjustments       -- admin corrections (separate from ledger)
  id, clinic_id, employee_id, adjusted_by (fk users)
  date, field, old_value, new_value, reason, created_at

attendance_day_summary       -- derived rollup per employee per day (recomputable)
  id, clinic_id, employee_id, work_date (date)
  first_in (timestamptz), last_out (timestamptz)
  worked_minutes (int), late_minutes (int)
  status                      -- 'present' | 'absent' | 'leave' | 'holiday' | 'partial'
  leave_record_id (fk, nullable)
  computed_at
TZ rule (per [[project_wa_reminder_pkt_bug_2026_06_09]] and [[project_no_show_manual_only_2026_06_13]]): all timestamps stored UTC timestamptz; all day-boundary/lateness math done with explicit AT TIME ZONE 'Asia/Karachi' (canonical via lib/pkt). Never compare a timestamptz instant to a naive wall-clock recast. Clinic work-start time lives in hrSettings.workHours for lateness calc.

4.3 Clinic hrSettings (new jsonb column on clinics)

hrSettings: {
  allowedIps: string[],                 // CIDR or exact IPs for kiosk punches
  workHours: { start: "09:00", end: "18:00", graceMinutes: 15, workDays: [1,2,3,4,5,6] },
  leavePolicyVersion: number            // bump to re-seed defaults
}

4.4 Endpoints

Admin (clinic-context + staff.attendance.*):
  • GET /hr/kiosks · POST /hr/kiosks (register → returns one-time token) · DELETE /hr/kiosks/:id
  • GET /hr/attendance?from&to&employeeId (summaries + punch detail)
  • POST /hr/attendance/adjustments (correction with reason)
  • POST /hr/employees/:id/pin (set/reset a staffer’s clock-in PIN)
Kiosk (kiosk-token auth, IP-checked, no admin permission):
  • GET /hr/kiosk/roster (clinic staff list w/ photos for the tap screen)
  • POST /hr/kiosk/punch { employeeId, pin, type } → validates token+IP+PIN, appends a punch, recomputes that day’s summary.

5. Part B — Leave management

5.1 Data model

leave_types                  -- per clinic, seeded with PK defaults, editable
  id, clinic_id (fk)
  key                         -- 'casual' | 'sick' | 'annual' | 'festival' | 'maternity' | 'unpaid'
  label, is_paid (bool)
  annual_quota (int)          -- days/year (null = uncapped, e.g. unpaid)
  max_consecutive (int null)  -- e.g. casual = 3
  carry_forward (bool), carry_cap (int null), encashable (bool)
  accrual                     -- 'upfront' | 'monthly' | 'after_12mo'
  is_active, sort_order

leave_balances               -- per employee per type per leave-year
  id, clinic_id, employee_id (fk), leave_type_id (fk)
  year (int)
  entitled (numeric), accrued (numeric), taken (numeric)
  carried_in (numeric), encashed (numeric)
  -- available = accrued + carried_in - taken - encashed  (computed)
  updated_at

leave_records                -- a recorded/approved leave
  id, clinic_id, employee_id (fk), leave_type_id (fk)
  start_date, end_date (date), days (numeric, supports half-day 0.5)
  is_paid (bool, copied from type at record time)
  reason, status             -- 'approved' | 'cancelled' (admin-recorded ⇒ default approved)
  recorded_by (fk users), created_at

5.2 Behaviour

  • Seeding: on module enable (and on leavePolicyVersion bump), seed leave_types from the §2 PK defaults via an idempotent upsert keyed on (clinic_id, key). Admin can edit quotas/flags.
  • Balances: created lazily per employee per leave-year; accrual respects accrual mode (after_12mo for annual). A recorded leave decrements taken and writes attendance_day_summary.status = 'leave' for covered work-days.
  • Encashment (annual): admin action that moves balance → encashed and emits a payroll line item reference for the next run / full & final settlement.
  • Payroll link: unpaid (LWOP) days produce a deduction reference consumed by the payroll run (does not auto-edit payroll; surfaces a suggested deduction the admin confirms).

5.3 Endpoints (admin, staff.leave.*)

  • GET/POST/PATCH /hr/leave-types
  • GET /hr/leave-balances?employeeId&year
  • GET/POST /hr/leave-records · POST /hr/leave-records/:id/cancel
  • POST /hr/leave-records/encash (annual encashment)

6. Part C — Pakistan HR document pack

6.1 Approach

Server-side template definitions (code, versioned) + clinic letterhead. Reuses the existing letterhead/react-pdf export path (document-letterhead route + R2 + lib/r2). Each template declares merge fields auto-filled from the employee record, plus editable free-text blocks the admin fills in a form before issuing. Issuing renders a PDF, stores it in R2, and files an hr_documents row against the employee. Documents print directly (browser print) or download.

6.2 Document set (v1)

  1. Offer letter
  2. Appointment letter / employment contract — category (permanent/probationer/temporary/contract), probation (≤3 mo), salary breakdown, leave entitlement, notice terms, working hours
  3. Probation confirmation letter
  4. Warning letter (1st/2nd)
  5. Show-cause notice (misconduct: written charge + deadline to explain — SO 15)
  6. Termination letter (simpliciter) — 1 month notice or pay-in-lieu, reasons stated
  7. Dismissal letter (misconduct) — post-inquiry, grounds stated
  8. Resignation acceptance / relieving letter
  9. Experience / service certificate (statutory end-of-service right)
  10. Salary certificate
  11. Full & final settlement — leave encashment, EOBI/gratuity references, dues

6.3 Data model

employees  (EXTEND existing table — additive columns, all nullable/defaulted)
  + cnic, designation
  + employment_category        -- 'permanent'|'probationer'|'temporary'|'contract'|'apprentice'
  + date_of_joining (date)
  + probation_end_date (date)
  + notice_period_days (int default 30)
  + address, emergency_contact (jsonb)
  + eobi_number, social_security_number
  (existing: name, role, email, phone, employmentType, baseSalary, hourlyRate, status, userId)

hr_documents                 -- issued document trail
  id, clinic_id, employee_id (fk)
  doc_type                    -- enum of the 11 above
  template_version (int)
  merge_data (jsonb)          -- snapshot of fields at issue time
  r2_key                      -- stored PDF
  issued_by (fk users), issued_at, created_at

6.4 Disclaimer

Every generated PDF carries a small footer:
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.

6.5 Endpoints (admin, staff.documents.*)

  • GET /hr/documents/templates (list + required fields per type)
  • POST /hr/documents { employeeId, docType, fields } → render + store + file row
  • GET /hr/documents?employeeId · GET /hr/documents/:id (download/print) · DELETE /hr/documents/:id

7. UI surfaces (admin-only, gated by module + permission)

New “Staff Hub” section in the sidebar (admin role; respects [[project_sidebar_ordering]]):
  • People — employee roster (extends the payroll employees view): profile, HR fields, set PIN, leave balances, document history.
  • Attendance — calendar/grid of daily summaries, late/absent flags, punch detail, corrections, kiosk registration.
  • Leave — leave-type config, per-employee balances, record/approve leave, encashment.
  • Documents — issue a letter (pick type → fill form → preview → print/PDF), document history.
  • Kiosk — a separate full-screen, no-chrome route (/kiosk) the clinic opens on the reception device: roster tap → PIN → in/out. Visual bar per [[feedback_ui_visual_bar]] — get a real visual pass, not just tsc.
Avoid raw IDs/SQL/jargon in any customer-facing surface (per [[feedback_enterprise_human_ux]]). No ?action=/?tab= URL garbage — use useUrlState/useDeepNav (per [[project_setsearchparams_clobber_2026_06_10]] and [[feedback_clean_urls_no_trigger_params]]).

8. Cross-cutting concerns

  • Migrations: new tables + additive employees/clinics columns via drizzle migration files, idempotent (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS) — isolated/enterprise DBs drift behind code (per [[project_dentocorrect_enterprise_2026_06_12]]); introspect before any SQL on a live tenant and never run against prod without per-action confirmation ([[feedback_no_live_tenant_execution]]).
  • Security: kiosk token hashed at rest; PIN hashed; kiosk endpoints rate-limited (reuse middleware/rate-limit); IP read from CF-Connecting-IP only; punches append-only.
  • Privacy: no patient PHI involved (staff data only), but HR data is sensitive — scope strictly to clinic context; future selfie blobs get a retention/purge policy when that source lands.
  • Money icons: use Banknote, never DollarSign ([[feedback_no_dollar_icons]]). Currency = PKR.
  • Superadmin parity ([[feedback_superadmin_ui_parity]]): add a read-only inspect view of a clinic’s HR module usage in the superadmin tenant panel (list employees/attendance counts) — can be a thin Phase-1.5 follow-up, but tracked, not forgotten.
  • API docs: update docs/api-reference.md with the new /hr/* endpoints ([[feedback_api_documentation_discipline]]).

9. Testing

  • Unit: attendance summary computation (TZ correctness w/ Asia/Karachi), leave balance math (accrual/carry-over/encashment), document merge-field fill.
  • Integration: kiosk punch rejected when IP not allow-listed / token invalid / PIN wrong; admin permission gating; module-guard off when not Pro+.
  • Visual: kiosk screen + each admin tab + at least one rendered PDF eyeballed (per [[feedback_ui_visual_bar]] and [[project_urdu_pdf_rendering]] — render a real PDF, don’t trust tsc).

10. Rollout

  1. Schema + module key + permissions (with CI parity tripwire green).
  2. Attendance (kiosk + IP + PIN + summaries) → leave engine → document pack.
  3. Enable on Pro+ via plan sync; smoke-test on the canonical test tenant ssh & Associates ([[feedback_test_tenant_ssh_associates]]) before any other clinic.
  4. Commit + deploy via odontox-commit-deploy; force-promote CF canonical; verify fresh dist hash ([[project_ui_build_stale_dist_2026_06_09]]).

11. Open questions (resolve during planning, sensible defaults chosen)

  • Leave-year basis: calendar year (default) vs employee anniversary — default calendar, configurable later.
  • Half-day leave granularity: support 0.5-day records in v1 (cheap, expected).
  • Kiosk auth lifetime: kiosk token long-lived but revocable; re-auth only if revoked.