One HR platform for a fast-growing team
An internal HR platform where onboarding, time-off, and people data live in one place, built for daily use by growing teams.
How the work was scoped
- Industry
- Professional Services
- Duration
- 15 weeks
- Cooperation model
- Time & materials
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
What went wrong, and when
HR data was spread across disconnected sheets, so requests slipped, people data went stale, and managers had no clear view of their team.
The same person existed in a payroll sheet, a directory sheet and an inbox thread, and none of the three agreed about their manager. Requests slipped because approval was a message someone had to notice, and the directory went stale because updating it was nobody's specific job. So managers had stopped using it and were asking in Slack instead.
We modeled the core HR workflows, designed role-based views for staff and managers, and built approval flows and a directory that stay accurate as the team grows.
Phase by phase
Phase 1: Audit
What the spreadsheets were actually holding
Cataloged every sheet, form, and shared doc in use, and which of them was authoritative when two disagreed.
- Source inventory
- Field mapping
- Authority list
Phase 2: Model
One employee record
Modeled the employee, the team, and the leave ledger as related records so a change in one place propagated instead of being retyped.
- Data model
- Migration plan
Phase 3: Build
Self-service flows
Built onboarding, time-off, and review flows against the shared model, with role-scoped access from the first commit, never bolted on.
- Onboarding flow
- Time-off module
- Permission model
Phase 4: Cut over
Migration and handover
Migrated live records, ran both systems in parallel for one cycle, then retired the sheets.
- Migrated dataset
- Parallel-run report
- Admin runbook
Onboarding as a tracked checklist per new hire, with an owner and a due date on every task and the overdue one visible, beside onboarding time halved from offer acceptance to documents complete.
The numbers, before and after
−50%
Onboarding time
−65%
Approval turnaround
94%
Manager adoption
Onboarding time is offer acceptance to all documents complete, median across hires in the two quarters after launch against the two before. Approval turnaround is request to decision. Manager adoption is the share of managers who approved at least one request in the platform in a given month, not the share with accounts.
Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.
The engagement
HR ran from scattered sheets and inboxes. We consolidated onboarding, leave, and people data into one platform managers use daily.
Forty people at the start of the engagement and a stated plan to reach a hundred and twenty inside a year. HR was one operations lead, three spreadsheets and a shared inbox: an arrangement that had worked at fifteen people and was audibly failing at forty. The trigger was two leave requests approved twice and one not at all, in the same two weeks.
Design & Build
How it was handled
Mapped the core HR workflows and role permissions
Workflows were mapped against who actually decides, whatever the org chart said, which is how four people reporting to two came to light before the permission model was built.
Built approval flows for onboarding and leave
Building this surfaced an org chart nobody had checked: four people reported to two, and the approval routes couldn't be drawn until that was settled.
Shipped a directory that stays current automatically
The directory reads the same employee record everything else does, so it can't go stale: there's no separate copy for anyone to forget to update.
Kept it running with a managed-support retainer
One full leave cycle ran in parallel with the spreadsheets before they were retired, instead of cutting over on a passing test suite.
One record per employee
A single profile that onboarding, time off, and reviews all read from, so nothing is entered twice.
The employee is one row in Postgres with everything else pointing at it through Prisma relations, so a change of surname or manager propagates on its own, with nothing to repeat in three tools. Onboarding, time off and reviews are separate flows reading one profile, which removed the class of bug where the directory and the org chart disagreed about who reported to whom.
- One employee row; every flow reads it through relations
- A name or manager change propagates everywhere at once
- Directory and org chart can't disagree, by construction
One record per employee: the single profile that onboarding, time off and reviews all read from, a manager change made once on 1 Jul, and the row's fields with every flow that reads each one.
Self-service time off
Time off as a team calendar instead of an inbox thread: pending requests hatched, approved leave solid, and the selected request showing its derived approver, its balance computed from policy and who is already off those days.
Requests, approvals, and balances handled by the people involved, with no admin in the middle.
Requests route to the approver derived from the org chart instead of a hard-coded admin, so the flow keeps working as the team doubles. Balances are computed from the accrual policy on demand, never stored and left to drift, and the calendar shows a manager who else on the team is already off before they approve. That context used to be a Slack message.
- Approver derived from the org chart, not hard-coded
- Balances computed from policy, never stored and drifted
- Team coverage shown at the point of approval
Role-scoped visibility
Managers see their own reports; everything else stays closed by default, not by convention.
Visibility is closed by default and opened by relationship: a manager sees their own reports because the query walks the reporting line, not because a role flag was ticked. Every scoped read resolves server-side in the same place, so there's one thing to audit instead of a permission check in each page. Sensitive fields are excluded from the query itself, never just hidden in the response.
- Closed by default; access derived from the reporting line
- One server-side place resolving every scoped read
- Sensitive fields excluded from the query, not the render
The directory as a manager sees it: 62 people across 9 teams with new hires in the same list, her eight reports open, everyone else closed, and the fields the query never selects for people outside her line.
Working inside their operation
- 01
A cross-functional team of 5 worked on a time & materials basis over 15 weeks, covering Internal platform, Approval workflows, Ongoing support. We ran two-week increments, each one shippable, reviewed live before it merged. Decisions were recorded as they were made, so the reasoning survived the people who made it.
One full leave cycle ran with the new platform and the spreadsheets side by side before the sheets were retired, which is where three accrual edge cases surfaced that no test had covered. Time and materials suited it because the permission model genuinely changed once managers started using it: the org chart turned out to have four people reporting to two.
What changed in the runbook
The bottleneck was never the forms. It was that no one could say which copy of a record was true.
Every version of the request form worked. What didn't work was three records disagreeing about who should approve it, which no form design can resolve.
Scoping permissions on day one cost less than retrofitting them after the first accidental disclosure.
The permission model was cheap to build against an empty database and would have been expensive against a populated one with a disclosure already in it.
Running one full leave cycle in parallel caught three edge cases no test had.
Tests assert what someone thought of; a real cycle asserts what actually happens, and the three cases it caught were all accrual arithmetic at year boundaries.
One request, five steps
Nobody forwards it. The record already knows who decides.
Three days of leave, followed from the moment they're asked for to the moment the team calendar changes: who holds the request at each step, and exactly what the approving manager sees. Switch steps, or use the arrow keys once a tab is focused.
Ines picks three days. The form shows her balance computed from her leave policy at that moment, never a number someone last typed into a sheet. Nothing has gone to an admin.
- Ines DuarteAsks, from her own account
- Her employee recordmanager_id → Priya Raman
- Priya RamanApprover, derived, not assigned
- Team calendar & balanceRead from the same ledger
Wed 16 Sep · 09:14 · illustrative time
Why it keeps working as the team doubles: no step names an approver. Each one reads the employee record, so a new hire, a new manager or a reorganized team changes the route by changing one row.
From one change to every view that depends on it
Onboarding, time off and reviews are separate flows reading one profile, with role-scoped access from the first commit.
- 01 · TriggerA request or a record changeStaff and managers act for themselves, signed in through Auth.js. Nothing is routed through an admin to be retyped.
- 02 · ScopeOne server-side resolverEvery scoped read resolves in the same place by walking the reporting line, so there is one thing to audit, not a check per page.
- 03 · EngineApproval routes and accrual policyThe approver is derived from the org chart, never hard-coded, and balances are computed from the accrual policy on demand.
- 04 · StateOne employee row in PostgreSQLTeam and leave ledger relate to it through Prisma, so a surname or manager change propagates instead of being repeated.
- 05 · DeliveryRole-based views in Next.jsA manager's home, the directory and the team calendar read the same record, so they can't disagree about who reports to whom.
People data, scoped before it's fetched
Closed by default, opened by relationship
A manager sees their own reports because the query walks the reporting line, not because a role flag was ticked. Everyone else stays closed by default.
One place resolves every scoped read
Scoping runs server-side in a single place instead of as a check in each page, so there's one thing to audit. It was modeled on day one, against an empty database.
Sensitive fields are never selected
Fields a viewer may not see are excluded from the query itself, so they're never in the payload for a browser to reveal.
Still running HR from spreadsheets and a shared inbox? Scope your build in 3 minutes.
Scope your buildNearby engagements
AI & AutomationA private legal assistant grounded in verified precedents
A private knowledge assistant that searches internal case files and precedents, providing cited answers legal teams can verify in seconds.
Legal & Law Firms · 14 weeks
Product DesignAn onboarding flow that guides trial users to value
A redesigned SaaS trial onboarding experience with progressive checklists, sample data, and inline guidance that turns signups into active subscribers.
Professional Services · 10 weeks
Product DesignA design system that brought speed and consistency to 4 product teams
A token-based design system in Figma and React that eliminated component duplication across 4 product squads and cut the time from design handoff to merged frontend.
Professional Services · 14 weeks
Let's talk
Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.














