How StaffPortal works
Eight modules, one Next.js app, one Supabase project, RLS on every row. The whole HR platform in plain English with diagrams, code, and the trade-offs that shaped it.
One app, one database,
RLS on every row.
StaffPortal is a Next.js 14 application backed by a single Supabase project. Eight modules, attendance, leave, expenses, kiosk, visitors, announcements, analytics, notifications, share auth, RLS, and notification primitives.
Every table has organisation_id and user_id. Every PostgreSQL policy scopes by both. Authorisation is defence in depth: the route handler checks, the database refuses to leak even if the route forgets.
Receipt OCR is the embedded Receipt Scanner pipeline. Kiosk is a PWA with IndexedDB offline queue. Notifications fan out to email, Slack, and in-app channels. Payroll exports are CSV in Xero/QuickBooks/Sage formats.
Two paths in. One source of truth.
Web for office staff, kiosk PWA for on-site staff. Both end up in the same Postgres rows.
Each module, deep-dived
Authentication & multi-tenancy
Every request must answer two questions: who is the user, and which organisation are they acting in? Both questions are answered before any business logic runs.
Supabase Auth issues a JWT with user_id (auth.uid()) and a custom organisation_id claim populated on sign-in. The JWT lives in an HttpOnly cookie. Server components and route handlers read it via the @supabase/ssr helper. Every PostgreSQL policy reads both claims to scope rows.
Attendance & timesheets
Hours worked are the bedrock of payroll. The system must capture them accurately on the web, on a tablet kiosk, and via manager bulk edits, and produce a single approved timesheet at the end of the period.
clock_events records every clock-in and clock-out as a discrete row with timestamp, source (web/kiosk/manual), photo URL when available, and geofence flag. A nightly job aggregates events into timesheet_lines with break deduction and overtime calculation. Managers approve at the timesheet level; the underlying events remain immutable.
Leave management
Per-employee allowances must accrue continuously, decrement on approval, and be auditable retroactively. Conflict detection prevents two key team members booking the same week off.
leave_balances is a SQL view computed from leave_allowances and approved leave_requests. Submission triggers a notification to the line manager. Approval mutates the request status and fires a calendar event to the team channel. The team_calendar view aggregates all approved leave for visualisation.
Expense capture (Receipt Scanner embedded)
Receipts are the single biggest source of HR data-entry friction. The fastest path is photograph, OCR, validate, submit, under ten seconds.
POST /api/expenses/scan accepts a multipart image. The image passes through sharp.rotate().resize(1568).jpeg(85), then to a frontier vision LLM with the Receipt Scanner system prompt. Output is Zod-validated and inserted as a pending expense with full raw JSON in the raw column. A manager approval notification fires.
Kiosk Progressive Web App
On-site staff need to clock in from a wall-mounted tablet, often on flaky Wi-Fi. The kiosk has to work offline and sync on reconnect.
A separate /kiosk route renders a touch-first React UI. Service worker caches the shell. IndexedDB stores queued sign-in events as { user_id, timestamp, photo_blob, geo? }. On navigator.online, a flush handler POSTs each queued event to /api/kiosk/sign-in and removes it from the queue on 200.
Visitor management
Compliance, security, and host-notification needs converge in visitor sign-in. The system tracks pre-registration, photo capture, NDA acknowledgement, and host alerts in one flow.
Pre-register a visit via /visitors/new. On arrival, the visitor signs in via the kiosk by name lookup, captures a photo, acknowledges the host's NDA if applicable, and triggers an email + Slack alert to the host. visitor_visits records the full lifecycle. Watchlist matches block sign-in and notify security.
Notifications
Approval workflows, leave decisions, expense rejections, announcements, and visitor alerts all require timely delivery. Three channels, email, Slack, in-app, with per-user preferences.
lib/notify.ts exposes notify(userId, eventType, payload). Reads notify_preferences for the user, fans out to enabled channels. Email via Resend with templated HTML. Slack via incoming webhook with formatted blocks. In-app via realtime broadcast to the user's subscribed channel. Every send is logged to notify_log for audit.
Analytics & exports
Managers need real-time visibility on attendance trends, leave balances, and expense patterns. Payroll teams need a clean monthly export.
Materialised views over the live tables compute attendance heatmaps, leave-by-team breakdowns, and expense totals by category. /analytics renders these via server components with chart components from recharts. Monthly close exports CSV in Xero/QuickBooks/Sage-compatible formats with the correct GL codes.
v2 turned this into a live workforce platform
Fifteen capabilities shipped on the same Supabase project. Twelve crons keep the live state honest. A reception status board and roll-call view derive their answers from clock_events, WFH flags, leave_requests and pre-logged running-late entries.
Live time-in-office counter
Ticks every second, deducts logged breaks. Derived state from clock_events, no separate timer table.
Running late pre-logs
Staff log a running-late entry for today or tomorrow. Amber banner on the dashboard, surfaced to managers.
WFH toggle + email fan-out
One click marks WFH for the day. Resend fans out a notification to the configured recipients.
Multi-step leave approval
Up to three approvers in priority order. Email per step, audit row per decision, certificate on final approve.
Year-end carry-forward
Cron on 1 January carries unused entitlement forward up to the cap. Idempotent, audited.
Birthday cron
Colleagues notified two days ahead, birthday person on the day. One flag turns it off entirely.
Forgotten clock-out 19:00
Finds open clock-ins with no clock-out, emails the employee, surfaces to admin.
Missing attendance 19:00
Compiles a report of staff with no attendance today and emails the admin team.
PIN-based kiosk at /kiosk
No login route. PIN for staff, QR for visitors. Scoped server actions only.
Visitor management with QR
Pre-registered visitors get a QR-coded reference. Host emailed on arrival, PDF pass generated, audit trail end to end.
Reception live-status board
In office, WFH, On leave, Running late, Clocked out. Realtime, no polling.
Roll-call view
Who is in the building right now, for fire drills and head-counts. Live, not snapshot.
PDF leave certificate
PDFKit renders a certificate on leave approval and emails it to the employee.
Diary notes + reminders
Personal diary inside the portal with timestamped email reminders.
Excel timesheets + bank reconciliation
ExcelJS workbook export and a bank statement reconciliation tool that matches deposits to approved claims.
Why this, not that
Next.js 14 App Router
Server components keep authorisation and DB queries server-side. Server actions cut form-submission boilerplate. File-based routes map to module structure.
Pages Router, older paradigm, more client-side state, less natural fit for streaming and partial pre-rendering.
Supabase
Postgres + Auth + RLS + Storage + Realtime in one service. Reproducing this in-house is 3,000+ lines and a permanent operational burden.
Custom Postgres + Lucia/NextAuth + S3 + custom WebSocket layer, works, but you maintain four systems instead of one.
Row-Level Security on every table
Multi-tenancy at the application layer is one bug away from data leakage. RLS makes Postgres refuse cross-tenant reads regardless of route logic.
Application-layer scoping only, works until it does not. Authorisation should be defence in depth.
PWA for kiosk
One codebase. No app store. Updates ship instantly. IndexedDB offline queue handles real site Wi-Fi conditions reliably.
Native iOS/Android, multiplies maintenance, app store gatekeeping for what is fundamentally a PIN-and-photo screen.
Embedded Receipt Scanner
Reuses a published, hardened OCR pipeline. Same prompt, same Zod schema, same accuracy.
Reimplement OCR per product, duplicates work, drift between expense scanning here and the standalone product, no shared improvements.
Resend for transactional email
Cleanest API, simplest verification, generous free tier, SDK-level templated HTML.
SendGrid, heavier API, opaque pricing. SES, IAM and sandbox approval drag for what should be one env var.
Generic approvals table
leave, expense, timesheet, visitor approvals share 90% of their logic. One table with target_type halves the route code.
Per-entity approval tables, duplicates schema, route code, and notification logic five times.
What you can measure
Failure modes you should expect
What’s next
Direct Xero / QuickBooks integrations
OAuth, scope mapping, push approved expenses and timesheets without CSV round-trip.
Performance reviews module
360-feedback flows, goal tracking, calibration sessions. Distinct domain, similar primitives.
Native mobile apps
iOS / Android wrappers around the PWA with push notifications and biometric auth.
Internationalisation
i18n routing, multilingual UI, locale-aware date and currency formatting.
Audit log viewer
Every state change is already in audit_log. Surface it in admin UI with filters and export.
Multi-org admin console
For agencies running StaffPortal on behalf of multiple clients, cross-tenant admin views.
Ready to deploy it?
Clone, set the env vars, run pnpm db:migrate, and invite the team. Twenty minutes from zero to a working HR platform.