Enterprise SaaS · IITIL · 2026 – Present
HRMS
A permission-aware HR platform, built as one design system.
- Role
- Senior Software Engineer · Frontend architecture
- Timeline
- May 2026 – present (ongoing)
- Stack focus
- React, TypeScript, RTK Query, shadcn/ui, Zod
- Outcome
- 9 modules (employee lifecycle, org, leave, attendance, documents, rosters) on a shared, type-safe component layer.
React, TypeScript, RTK Query, shadcn/ui, Zod, FullCalendar, Spec Kit
01Overview
Many modules, many roles, one codebase
The HRMS I work on at IITIL is a single web app that HR teams, managers and employees all use. It has 9 modules covering employee lifecycle, organization management, leave, attendance, holidays, shifts and rosters, overtime, employee requests and documents. Every module serves more than one role, and most of them read or change data that another module displays.
My job is the frontend architecture. That means three things: building each module so the next one costs less than the last, keeping server data correct on every screen that shows it, and making one codebase show each role exactly what it is allowed to do, and nothing more.
The product and its screens are proprietary, so this page is about decisions rather than screenshots. The playground further down is a rebuild from scratch with a fictional brand, fictional people and mock data.
02Constraints
What the design had to survive
One screen, several audiences. The same leave screen serves the person asking for time off, the manager approving it and the HR admin who sets the policy. Each sees different data and different buttons.
Modules share data. Approving a leave request changes a balance, an attendance record and a calendar, and those belong to different modules.
Contracts move. Backend APIs and database schemas are designed alongside the frontend, so the shape of the data can change under a screen that already exists.
Rules are dense. HR policy is full of edge cases, like a leave range that crosses a holiday or two shifts that overlap. These are easy to get half right.
It never stops shipping. I joined in May 2026. Modules ship one after another rather than in a single launch, so every pattern has to hold up while the codebase grows around it.
03Try it
Try it: the permission playground
Switch between Employee, Manager and HR Admin and watch buttons lock and calendar layers appear or disappear. Toggle a cell in the permission matrix to change what a role can do. Then approve a pending leave request and check the side panel: it lists the cache tags that one mutation invalidated. That is decisions 02 and 04 below, running in front of you.
04Decisions
Five decisions that shaped the code
Most of the codebase follows from five choices. For each one I list what I rejected and what the choice costs, because every one of them has a cost.
Decision 01
One shared component layer, not per-module styling
Every module needs the same parts: data tables with filters, forms, dialogs, status badges, page headers and empty states. With 9 modules, each small inconsistency gets repeated 9 times, and users notice when "Approve" looks different in two places.
Chose
Rejected
Trade-off · The layer had to exist before the first module could ship, and a change to a shared component now affects every module, so those changes get more review. In return, a new module starts from finished parts instead of a blank file.
Decision 02
Tag-based cache invalidation with RTK Query
Approving a leave request is one click for the manager. But it changes the request's status, the employee's leave balance, their attendance for those dates and the team calendar. Any of those screens might already be cached.
Chose
Rejected
refetch() by hand after each mutation, or copying server data into a global Redux slice and keeping it in sync myself. Both depend on whoever writes the mutation remembering every screen it affects.Trade-off · The tag vocabulary is a small schema that needs maintaining, and broad tags cause some extra requests. I accept a few unnecessary refetches in exchange for screens that are never stale after an approval.
approveLeave: build.mutation<LeaveRequest, ApproveInput>({
query: ({ id }) => ({
url: `/leave-requests/${id}/approve`,
method: "POST",
}),
invalidatesTags: (_res, _err, { id, employeeId }) => [
{ type: "LeaveRequest", id },
{ type: "LeaveBalance", id: employeeId },
{ type: "Attendance", id: employeeId },
"Calendar",
],
}),Decision 03
Zod schemas shared by forms and API contracts
Each entity, such as a leave request, a shift or an employee record, appears in a form, in a request body and in a response. While the backend contracts were still being designed, those three descriptions could easily drift apart.
Chose
z.infer, forms validate against the same schema, and API responses are parsed at the boundary before any component sees them.Rejected
Trade-off · Runtime parsing adds bundle weight and a little work per response. A contract change now fails loudly at the boundary, where a loose interface would have rendered something subtly wrong. I want that failure to be loud.
Decision 04
A declarative permission map, not scattered role checks
Many roles see the same screens with different abilities. Inline checks like role === "admin" spread through components quickly, and the route, the menu and the button soon stop agreeing about who can do what.
Chose
can() helper, so all three answer the same question the same way.Rejected
Trade-off · The map is a single file that every feature touches, so it needs careful review. It also only controls what the UI shows: the API has to enforce the same rules, because a hidden button is not a security boundary.
export const permissions = {
"leave.request": ["employee", "manager", "hrAdmin"],
"leave.approve": ["manager", "hrAdmin"],
"holiday.manage": ["hrAdmin"],
} as const satisfies Record<string, readonly Role[]>;
// One map, three consumers:
<RequirePermission permission="leave.approve" /> // route guard
navItems.filter((item) => can(item.permission)) // navigation
{can("leave.approve") && <ApproveButton />} // actionsDecision 05
Spec-driven delivery with GitHub Spec Kit
Requirements arrive as HR policy, and the backend contract for a feature is often being written at the same time as the UI. Going straight from a ticket to code tends to push edge cases and contract mismatches late, into QA or review.
Chose
Rejected
Trade-off · The first commit on a feature comes later, and a spec that is not kept up to date becomes misleading. The gain is that disagreements with the backend contract show up in a document review, which is the cheapest place to fix them.
05Architecture
One request, end to end
A route mounts and the RBAC guard checks the permission map before the module renders anything. Module components never fetch on their own. They call hooks from an RTK Query slice, and each endpoint declares the cache tags it provides or invalidates. Requests and responses pass through the Zod contract, so a component only ever receives data that matches its type.
On the server side of the HTTP boundary, the REST API applies business rules and the database schema backs them. I help design both with the backend team. The permission map on the frontend decides what the interface offers. Authorization itself is enforced on the server.
06Scheduling
Calendars and validated workflows
Leave, holidays and attendance each have an interactive calendar built on FullCalendar. I treat each calendar as one more view over RTK Query data, not as a separate store. Events are mapped from the same endpoint results the list screens use, so when an approval invalidates the Calendar tag, the calendar refetches along with everything else and never needs its own sync code.
Shifts, rosters, overtime and employee requests are form-heavy workflows. Their Zod schemas encode the rule as well as the type, for example an end time that must come after the start time or a date range that must be valid as a whole, so the form, the request and the response all enforce the same constraint. Cross-field checks live in the schema, not in the submit handler, where they would be easy to skip.
The playground shows a small version of this: calendar layers are gated by the permission map, like any other action.
07My role
What I own and what I share
I own
- Frontend architecture across the modules
- The shared Tailwind + shadcn/ui component layer
- The RTK Query data layer and its cache-tag design
- Permission-aware UI: guards, navigation, actions
- Schema-validated forms and scheduling workflows
With the team
- Database schema design
- Backend business logic
- API contracts, agreed through specs
To be clear about the timeline: I joined IITIL in May 2026, so this is current work, not a finished project. The decisions above are in use today, and the modules keep shipping on top of them. I don't have production metrics I can publish yet, so this page describes the architecture rather than quoting numbers.
08Next
What I'd do differently, and what's next
Give the contract one owner. A Zod schema that mirrors the API by hand is a second copy of the contract. I want the schemas generated from, or shared with, the backend definition, so a contract change is one edit instead of two.
Test the shared layer as a product. A change to the data table touches every module. I'd add visual regression tests on the shared components so a regression shows up before a reviewer has to spot it.
Check the permission map against the API. The frontend map and the server rules describe the same policy. A test that compares the two would catch the case where the UI offers an action the API will refuse.
Measure. I'd instrument time-to-interactive per module and refetch counts per mutation, so the next round of decisions rests on numbers rather than judgment.