Whitepaper · StaffPortal

StaffPortal

A complete open-source HR platform — attendance, leave, expenses, kiosk, visitors — built on Next.js and Supabase.

MIT LicensedOpen SourceSelf-Hostable8 modulesRLS throughout£0 / employee
8core modules
40+database tables
100%RLS coverage
~£20/moinfra for 50 staff

v1.0 · April 2026 · Sai Sarma · Sarma Linux

Abstract

StaffPortal is an open-source, MIT-licensed staff management platform that consolidates attendance, timesheets, leave, expenses with AI receipt scanning, kiosk sign-in, visitor management, announcements, and analytics into a single Next.js 16 application backed by Supabase. This whitepaper documents the multi-tenant security model (Row-Level Security on every table), the domain models for attendance and leave, the kiosk-mode offline architecture, the embedded receipt OCR pipeline, payroll-ready export contracts, and the deployment topology that runs the platform for £20 per month across a 50-person organisation.

01Executive Summary

StaffPortal replaces per-seat HR SaaS for small-to-medium organisations. Eight cohesive modules — attendance, timesheets, leave, expenses, kiosk, visitors, announcements, analytics — share a single Next.js application, a single Supabase project, and a single set of authentication, authorisation, and notification primitives. The data layer is a 40-table PostgreSQL schema with Row-Level Security on every row, scoped by both user_id and organisation_id.

Attendance is captured through three channels: the web application for office staff, a tablet-mounted kiosk for on-site staff, and a manager-side bulk-edit view for retroactive corrections. The kiosk mode runs as a Progressive Web Application, captures sign-in photos, works offline, and synchronises on reconnect. Leave management implements per-employee allowances with rolling-year accrual, manager approval flows, and a team calendar with conflict detection.

Expense capture embeds the Receipt Scanner pipeline directly: an employee photographs a receipt, the image passes through Anthropic Claude vision OCR, returns structured JSON, and enters a manager approval workflow with policy checks. Payroll-ready exports produce CSV files compatible with Xero, QuickBooks, and Sage at the close of each pay period.

02Background & Motivation

The HR SaaS market — BambooHR, Personio, Hibob, Rippling, Sage HR, Breathe — charges between £8 and £15 per employee per month. For a 30-person business that is between £2,800 and £5,400 a year. For a 100-person business, between £9,600 and £18,000 a year. Every year. For software that mostly tracks who is in, who is on holiday, and who claimed lunch on expenses.

The functionality these products provide is not technically complex. It is a relational schema, some forms, an approval workflow, a tablet kiosk, and an OCR pipeline. The reason businesses pay rather than build is that no credible open-source alternative exists. The available open-source HR projects are either single-feature (clock-in only, leave-only) or built on legacy stacks (LAMP, custom PHP) that do not appeal to teams used to modern web frameworks.

StaffPortal exists to fill that gap with a project built on the stack most contemporary developers already use — Next.js, TypeScript, Supabase, Tailwind — and licensed permissively enough to be deployed inside any organisation without legal review.

03The Problem

The specific problems StaffPortal solves:

  • Per-seat pricing. Every employee added to BambooHR is another £8 to £15 per month forever. StaffPortal’s cost is fixed at the infrastructure level — Supabase free tier covers up to 500MB of HR data, Vercel hosts the app, the only variable cost is Anthropic vision OCR for receipts.
  • Data exit. Closed HR platforms make data export awkward (or paid). StaffPortal stores everything in your own Supabase project; you have direct SQL access at all times.
  • Customisation requires "Enterprise" tier. Adding a custom expense category in BambooHR may require an upgrade. StaffPortal lets you add a column and a form field with a single migration.
  • Integration latency. Closed platforms expose webhooks and APIs at a paid tier. StaffPortal is the database — integrations are SQL queries or Supabase Edge Functions.

04Goals & Non-goals

Goals

  • Replace BambooHR / Personio for organisations of 5 to 200 employees.
  • Self-host on Vercel + Supabase free tier for organisations under 50 staff.
  • Multi-tenant by default — one deployment can serve multiple organisations.
  • Row-Level Security enforced at PostgreSQL on every table.
  • Payroll-ready CSV exports for Xero, QuickBooks, Sage.
  • Kiosk mode that works offline and synchronises on reconnect.

Non-goals (v1)

  • Performance reviews and OKRs. Different shape of problem; out of scope for the first major release.
  • Recruitment / ATS. Distinct domain. Belongs in a sibling project.
  • Direct payroll execution. StaffPortal exports the data; running payroll is the job of HMRC-recognised payroll software.
  • Mobile-native apps. The PWA covers the kiosk and on-the-go use cases. Native iOS/Android is roadmap, not v1.

05Architecture

System overview

┌──────────── BROWSER ─────────────┐  ┌───── KIOSK TABLET (PWA) ─────┐
│   Employee · Manager · Admin     │  │   PIN auth · photo capture   │
│   web routes (server-rendered)   │  │   offline-capable IndexedDB  │
└──────────────────────────────────┘  └──────────────────────────────┘
                │                                    │
                └──────────── Supabase auth ─────────┘
                                  │
                                  ▼
┌──────────── NEXT.JS 16 APP (Vercel) ───────────────────────────────┐
│  /api/attendance · /api/leave · /api/expenses · /api/visitors      │
│  /api/kiosk      · /api/announcements · /api/analytics             │
│  shared lib: auth · permissions · notify · audit · scanner · cron  │
└────────────────────────────────────────────────────────────────────┘
                                  │
        ┌─────────────────────────┼──────────────────────────┐
        ▼                         ▼                          ▼
┌────────────────┐        ┌────────────────┐        ┌──────────────┐
│   SUPABASE     │        │    RESEND      │        │  ANTHROPIC   │
│   Postgres +   │        │  email (notify │        │  Claude 3.5  │
│   Auth + RLS + │        │   approvals,   │        │  vision (OCR)│
│   Storage      │        │   announce)    │        │              │
└────────────────┘        └────────────────┘        └──────────────┘

Module map

ModuleRoutesTables
Attendance/attendance, /api/attendanceshifts, clock_events, timesheets, timesheet_lines, schedules
Leave/leave, /api/leaveleave_types, leave_allowances, leave_requests, leave_approvals
Expenses/expenses, /api/expenses, /api/scanexpense_categories, expenses, expense_items, expense_approvals, receipts
Kiosk/kiosk, /api/kioskkiosks, kiosk_sessions, sign_in_events
Visitors/visitors, /api/visitorsvisitors, visitor_visits, visitor_watchlist, ndas
Announcements/announcementsannouncements, announcement_reads, announcement_targets
Analytics/analyticsmaterialised views over the live tables
Notificationsshared libnotify_preferences, notify_log, slack_webhooks

06Key Technical Decisions

Why Supabase rather than a custom Postgres + auth stack

Supabase bundles PostgreSQL, an auth service that issues JWTs, Row-Level Security policy enforcement, object storage with signed URLs, and a JavaScript SDK that handles cookie management out of the box. Reproducing that in-house is at least 3,000 lines of code and a permanent operational burden. Supabase is the largest single accelerant in the project.

Why Row-Level Security on every table

Multi-tenancy at the application layer is one programmer error away from data leakage. RLS makes the database refuse to serve organisation A’s data to a session authenticated as organisation B, regardless of what the application code says. (auth.uid(), auth.jwt() -> organisation_id) is in every USING clause.

Why Next.js 16 App Router

Server components keep authorisation logic and database queries server-side, where they cannot be tampered with. The App Router’s file-based routing maps cleanly to module structure. Server Actions reduce form-submission boilerplate.

Why a kiosk Progressive Web Application

Tablet kiosks are deployed across factory floors, warehouses, and on-site teams where Wi-Fi is intermittent. A PWA with IndexedDB-backed offline queue captures sign-ins reliably and synchronises when connectivity returns. The alternative — a native iOS/Android app — multiplies maintenance and submission overhead with no functional benefit for this use case.

Why embed Receipt Scanner directly

Expense capture is the most-used premium feature in HR SaaS, and dedicated mobile receipt scanners (Dext, Pleo, Expensify) charge per user. StaffPortal embeds the same Receipt Scanner pipeline that ships as a standalone product — Claude vision OCR, sharp image preprocessing, Zod-validated JSON, persistence into the expenses tables. Cost: roughly 1 penny per scan.

Why Resend for notifications

Approvals, leave decisions, expense rejections, announcement digests, and visitor host alerts all need transactional email. Resend has the cleanest API, the simplest domain verification, a generous free tier, and SDK-level support for templated HTML emails.

Why payroll-ready CSV rather than direct integrations

Direct payroll integrations (Xero API, QuickBooks API) require OAuth, scope management, and per-vendor field mapping. CSV export is the universal interface every payroll product accepts. Direct integrations are roadmap, not v1.

07Implementation

Multi-tenant RLS pattern

-- Every table has organisation_id and user_id columns.
-- RLS policies scope by both.

create policy "members_can_read_org_data" on expenses
  for select
  using (
    organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
  );

create policy "members_can_insert_own_expense" on expenses
  for insert
  with check (
    user_id = auth.uid()
    and organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
  );

create policy "managers_can_update_team_expense" on expenses
  for update
  using (
    organisation_id = (auth.jwt() ->> 'organisation_id')::uuid
    and exists (
      select 1 from team_memberships m
      where m.user_id = auth.uid()
        and m.role in ('manager', 'admin')
        and m.team_id = expenses.team_id
    )
  );

Receipt OCR pipeline (embedded)

// app/api/expenses/scan/route.ts
import { extract } from '@/lib/scanner'  // shares the Receipt Scanner code

export async function POST(req: Request) {
  const user = await requireAuth()
  const orgId = await getOrganisationId(user.id)

  const form = await req.formData()
  const file = form.get('image') as File
  const buf  = Buffer.from(await file.arrayBuffer())

  const receipt = await extract(buf)              // Claude vision OCR

  const url = await uploadToStorage(buf, user.id) // signed Supabase URL

  const { data } = await supabase.from('expenses').insert({
    user_id: user.id,
    organisation_id: orgId,
    vendor: receipt.vendor,
    amount: receipt.total,
    currency: receipt.currency,
    date: receipt.date,
    raw: receipt,
    image_url: url,
    status: 'pending',
  }).select('*').single()

  await notifyManager(user.id, 'expense.submitted', data)

  return Response.json({ ok: true, expense: data })
}

Kiosk offline queue

// In the kiosk PWA service worker:
// Sign-in events captured offline are queued in IndexedDB
// and replayed when navigator.onLine returns true.

self.addEventListener('online', async () => {
  const queued = await idb.getAll('queued_signins')
  for (const evt of queued) {
    const res = await fetch('/api/kiosk/sign-in', {
      method: 'POST',
      body: JSON.stringify(evt),
      headers: { 'Content-Type': 'application/json' },
    })
    if (res.ok) await idb.delete('queued_signins', evt.id)
  }
})

Leave allowance accrual

A SQL view computes remaining allowance per employee per leave type, accruing month-by-month from the start of their employment year.

create view leave_balances as
select
  m.user_id,
  m.organisation_id,
  t.id as leave_type_id,
  a.annual_days * least(months_since_start(m.start_date) / 12.0, 1) as accrued,
  coalesce(used.taken, 0) as used,
  a.annual_days - coalesce(used.taken, 0) as remaining
from team_memberships m
join leave_types t          on t.organisation_id = m.organisation_id
join leave_allowances a     on a.user_id = m.user_id and a.leave_type_id = t.id
left join (
  select user_id, leave_type_id, sum(days) as taken
  from leave_requests
  where status = 'approved'
    and date_part('year', start_date) = date_part('year', current_date)
  group by 1, 2
) used on used.user_id = m.user_id and used.leave_type_id = t.id;

08Results & Operations

Reference deployment cost (50-person organisation)

ServiceTierCost / month
VercelHobby£0
SupabasePro (recommended past 500MB)£20
Resend3,000 emails free£0
Anthropic vision (~500 receipts)Pay-as-you-go~£6
Total~£26 / month

Compare with BambooHR Pro at £15/employee × 50 employees = £750/month. The break-even point versus self-hosted StaffPortal is somewhere between 2 and 3 employees.

Performance characteristics

OperationP50P95
Web page render (server component)~250 ms~450 ms
Kiosk sign-in (online)~180 ms~350 ms
Receipt scan (Claude vision)~1.6 s~2.4 s
Leave request submission~140 ms~290 ms
Analytics dashboard initial load~400 ms~700 ms

09Lessons & Trade-offs

What worked

  • RLS first, route logic second. Designing every table’s policies before writing the route handlers eliminated whole classes of authorisation bugs. The database refuses to leak data even if a route forgets to check.
  • One Supabase project per deployment. Resisting the urge to split modules across multiple databases kept transactions, foreign keys, and joins simple. Multi-tenancy is a column, not a schema.
  • PWA over native for the kiosk. One codebase. No app store. Updates ship instantly. The IndexedDB offline queue handles real-world site conditions reliably.
  • Embedding Receipt Scanner. Reusing a published, hardened OCR pipeline rather than reimplementing kept expense capture lean and consistent with the standalone product.

What we got wrong on first pass

  • First leave-balance design used a stored snapshot. It drifted from the underlying transactions whenever a request was approved retroactively. Switching to a SQL view computed live from leave_allowances and leave_requests eliminated the drift.
  • Initial kiosk used localStorage, not IndexedDB. localStorage is synchronous and capped at 5 MB, which broke once sign-ins accumulated photos. IndexedDB is the right primitive for queued-with-blob workloads.
  • Approvals originally lived as a separate table per workflow. Five approval tables (leave, expense, timesheet, visitor, announcement) duplicated logic. Consolidating into a generic approvals table with a polymorphic target_type halved the route code.

Trade-offs we accept

  • Supabase is mostly Postgres but not entirely. Migrating to plain Postgres is possible but loses Auth and RLS-via-JWT. Acceptable lock-in for the value delivered.
  • Vercel hobby works under 50 staff. Above that, function timeouts on analytics queries can become an issue. Vercel Pro at £20/month or self-hosting on a VPS resolves it.
  • CSV exports rather than direct payroll integrations in v1. Universal but requires manual import. Direct Xero / QuickBooks integrations are on the roadmap.

10Conclusion

StaffPortal demonstrates that a complete HR platform — covering attendance, leave, expenses, kiosk sign-in, and visitor management — fits in a single Next.js 16 application backed by a single Supabase project, costs roughly £26 per month to operate for a 50-person organisation, and can be deployed without writing a custom line of code. The premium that HR SaaS vendors charge is not paying for technically hard work; it is paying for distribution. Open source closes that gap.

The schema is yours. The UI is yours. The notification logic is yours. Customisation is a migration and a form field, not a sales call. That is the proposition.

ASchema highlights

-- Membership ties a user to an organisation with a role
create table team_memberships (
  id              uuid primary key default gen_random_uuid(),
  user_id         uuid not null references auth.users(id) on delete cascade,
  organisation_id uuid not null references organisations(id) on delete cascade,
  team_id         uuid references teams(id),
  role            text not null check (role in ('admin', 'manager', 'member')),
  start_date      date not null,
  end_date        date,
  created_at      timestamptz default now(),
  unique (user_id, organisation_id)
);

-- Generic approvals table (polymorphic target)
create table approvals (
  id              uuid primary key default gen_random_uuid(),
  organisation_id uuid not null,
  target_type     text not null,    -- 'leave_request' | 'expense' | 'timesheet'
  target_id       uuid not null,
  approver_id     uuid not null references auth.users(id),
  status          text not null check (status in ('pending', 'approved', 'rejected')),
  reason          text,
  created_at      timestamptz default now(),
  decided_at      timestamptz
);

-- Expenses with embedded raw OCR JSON
create table expenses (
  id              uuid primary key default gen_random_uuid(),
  user_id         uuid not null references auth.users(id) on delete cascade,
  organisation_id uuid not null references organisations(id) on delete cascade,
  team_id         uuid references teams(id),
  category_id     uuid references expense_categories(id),
  vendor          text,
  amount          numeric(12,2),
  currency        text,
  date            date,
  raw             jsonb,             -- full Receipt Scanner output
  image_url       text,
  status          text not null default 'pending',
  created_at      timestamptz default now()
);

alter table expenses enable row level security;

BConfiguration

VariableRequiredPurpose
NEXT_PUBLIC_SUPABASE_URLYesSupabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEYYesSupabase anon key (client-safe)
SUPABASE_SERVICE_ROLE_KEYYesService role key (server-only)
RESEND_API_KEYYesTransactional email delivery
FROM_EMAILYesVerified sender domain
ANTHROPIC_API_KEYYesReceipt OCR vision API
SLACK_WEBHOOK_URLNoOptional Slack fan-out for visitor and approval alerts
KIOSK_PIN_LENGTHNoDefault 4 digits
SIGN_IN_PHOTO_REQUIREDNoDefault false