Skip to content
← All projects

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.

Loading permission playground…

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

A shared layer on Tailwind and shadcn/ui. The primitives live in the repo as source, and app-level components such as a data table, a form field and a status badge are built on top of them. Modules are assembled from that layer.

Rejected

Letting each module style its own screens. It is faster for the first module and slower for every one after it, and the drift shows up in review, not in the type checker.

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

RTK Query for all server state. Queries declare the tags they provide, and mutations declare the tags they invalidate. Approving leave invalidates Leave, Attendance and Calendar, and every mounted view that depends on them refetches.

Rejected

Calling 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.

leaveApi.ts(renamed and simplified)illustrative
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",
  ],
}),
Illustrative only: renamed and simplified, not production code. The mutation names everything it affects, so no screen has to know who changed its data.

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

One Zod schema per contract. TypeScript types come from z.infer, forms validate against the same schema, and API responses are parsed at the boundary before any component sees them.

Rejected

Hand-written interfaces with separate validation rules in each form. They compile cleanly and drift without anyone noticing, because TypeScript trusts whatever the API sends back.

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

One permission map from capability to roles. The route guard, the navigation builder and every action button read it through a single can() helper, so all three answer the same question the same way.

Rejected

Role checks written inline wherever they are needed. They are easy to add, hard to audit and impossible to change in one place.

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.

permissions.ts(renamed and simplified)illustrative
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 />}       // actions
Illustrative only: renamed and simplified. Adding a capability means editing one map, not hunting for every place a role is checked.

Decision 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

Write the spec first. Spec Kit turns it into an implementation plan and a task list, which I check against the backend API contract before writing code. I then implement in small tasks with AI-assisted development and review each change.

Rejected

Going from ticket to code, with the edge cases worked out during the build. It starts faster and pays for it later.

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

HRMS frontend request lifecycleEight steps in order: Route, RBAC guard, Module, RTK Query slice, cache tags, Zod contract, REST API, database. The first four run in the browser; the Zod contract is shared; the REST API and database are on the server side of an HTTP boundary. The RBAC guard and the cache tags are highlighted because the playground on this page shows them live.BROWSER · REACT + TYPESCRIPTSHOWN LIVE IN THE PLAYGROUNDRouteURL → pageRBAC guardcan(permission)?ModuleLeave, Attendance, …RTK Query sliceendpoints + hooksCache tagsprovides / invalidatesZod contractparse in, parse outREST APIbusiness rulesDatabaseco-designed schemaBROWSER · SHARED SCHEMASSERVER
Read the top row left to right, then the bottom row. Highlighted steps are the ones the playground shows live. On narrow screens the diagram scrolls sideways.

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.