Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 05: Time-Off System

Complete time-off management with policies, balances, requests, and approvals

Phase 05: Time-Off System

Goal: Build a complete time-off management system with leave policies, balance tracking, request submission, manager approval workflow, and team calendar view.

AttributeValue
Steps73-92
Estimated Time10-14 hours
DependenciesPhase 04 complete (TanStack Query, API helper available)
Completion GateEmployees can request time-off, managers can approve/reject, balances update correctly

Step Timing Estimates

StepTaskEst. Time
73Add LeaveType enum10 min
74Add AccrualType enum10 min
75Add RequestStatus enum10 min
76Add PolicyStatus, HalfDayPart enums10 min
77Add TimeOffPolicy model20 min
78Add TimeOffBalance model15 min
79Add TimeOffRequest model20 min
80Run migration15 min
81Create TimeOffPolicyRepository25 min
82Create TimeOffPolicyService20 min
83Create TimeOffPolicyController25 min
84Create TimeOffBalanceService30 min
85Create TimeOffRequestRepository25 min
86Create TimeOffRequestService45 min
87Create TimeOffRequestController30 min
88Create time-off request form35 min
89Create my requests page30 min
90Create pending approvals page30 min
91Add approve/reject actions25 min
92Create leave calendar view40 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • Time-off policy management (vacation, sick, personal, etc.)
  • Employee balance tracking per policy per year
  • Request submission with date range and reason
  • Manager approval workflow with comments
  • Balance calculation: Available = Entitled + CarryOver - Used - Pending + Adjustment
  • Team calendar showing approved time-off
  • Half-day support (morning/afternoon)

What This Phase Does NOT Include

  • Accrual automation (monthly/daily) - manual balance management only
  • Public holiday integration - future enhancement
  • Carry-over automation at year end - manual process
  • Email notifications - future enhancement
  • Mobile-specific views - responsive web only

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Complex workflow engines (Temporal, Step Functions) - simple status transitions
  • Separate calendar service - built into main API
  • Event sourcing for balance changes - direct updates with audit via updatedAt
  • External calendar sync (Google, Outlook) - internal calendar only

If the AI suggests adding any of these, REJECT and continue with the spec.


Step 73: Add LeaveType Enum

Input

  • Phase 04 complete
  • Prisma schema at packages/database/prisma/schema.prisma

Constraints

  • DO NOT modify existing models
  • ONLY add the enum definition
  • Use exact values from database-schema.mdx

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// TIME-OFF ENUMS
// ==========================================

enum LeaveType {
  VACATION
  SICK
  PERSONAL
  PARENTAL
  MATERNITY
  PATERNITY
  BEREAVEMENT
  UNPAID
  COMPENSATORY
  OTHER
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 15 "enum LeaveType"
# Should show all enum values

Common Errors

ErrorCauseFix
Enum already existsDuplicate definitionRemove duplicate
Invalid enum valueTypo in valueCheck spelling matches spec

Rollback

# Remove the enum block from schema.prisma

Lock

packages/database/prisma/schema.prisma (LeaveType enum)

Checkpoint

  • LeaveType enum added
  • prisma format succeeds

Step 74: Add AccrualType Enum

Input

  • Step 73 complete
  • LeaveType enum exists

Constraints

  • DO NOT modify LeaveType enum
  • ONLY add AccrualType enum

Task

Add to packages/database/prisma/schema.prisma (after LeaveType):

enum AccrualType {
  ANNUAL
  MONTHLY
  DAILY
  NONE
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 6 "enum AccrualType"

Rollback

# Remove AccrualType enum block

Lock

packages/database/prisma/schema.prisma (AccrualType enum)

Checkpoint

  • AccrualType enum added
  • prisma format succeeds

Step 75: Add RequestStatus Enum

Input

  • Step 74 complete

Constraints

  • ONLY add RequestStatus enum

Task

Add to packages/database/prisma/schema.prisma:

enum RequestStatus {
  PENDING
  APPROVED
  REJECTED
  CANCELLED
  EXPIRED
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -A 7 "enum RequestStatus"

Rollback

# Remove RequestStatus enum block

Lock

packages/database/prisma/schema.prisma (RequestStatus enum)

Checkpoint

  • RequestStatus enum added

Step 76: Add PolicyStatus and HalfDayPart Enums

Input

  • Step 75 complete

Constraints

  • Add both enums in this step

Task

Add to packages/database/prisma/schema.prisma:

enum PolicyStatus {
  ACTIVE
  INACTIVE
  ARCHIVED
}

enum HalfDayPart {
  MORNING
  AFTERNOON
}

Gate

cd packages/database
npx prisma format
cat prisma/schema.prisma | grep -E "enum (PolicyStatus|HalfDayPart)" -A 5

Rollback

# Remove both enum blocks

Lock

packages/database/prisma/schema.prisma (PolicyStatus, HalfDayPart enums)

Checkpoint

  • PolicyStatus enum added
  • HalfDayPart enum added

Step 77: Add TimeOffPolicy Model

Input

  • Step 76 complete
  • All time-off enums exist

Constraints

  • DO NOT add relations yet (added in Step 79 after all models exist)
  • Use exact field names from database-schema.mdx

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// TIME-OFF MODELS
// ==========================================

model TimeOffPolicy {
  id              String       @id @default(cuid())
  tenantId        String
  name            String       // e.g., "Annual Vacation"
  code            String?      // e.g., "VACATION", "SICK"
  description     String?
  leaveType       LeaveType
  accrualType     AccrualType  @default(ANNUAL)
  annualAllowance Float        @default(0)    // days per year
  carryOverLimit  Float        @default(0)    // max days to carry over
  carryOverExpiry Int?         // months before carry-over expires
  requiresApproval Boolean     @default(true)
  autoApproveRules Json?       // conditions for automatic approval
  appliesTo       Json         @default("{}")  // employee filters
  status          PolicyStatus @default(ACTIVE)
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt

  // Relations added in Step 79 after all models are defined

  @@unique([tenantId, code])
  @@map("time_off_policies")
}

Important: Do NOT run prisma format yet - relations will be added in Step 79 after all models exist.

Gate

# Just verify the model was added - NO prisma format yet
cat packages/database/prisma/schema.prisma | grep -A 5 "model TimeOffPolicy"

Common Errors

ErrorCauseFix
Unknown type TimeOffRequestRelations added too earlyWait for Step 79 to add relations

Rollback

# Remove TimeOffPolicy model and Tenant relation

Lock

packages/database/prisma/schema.prisma (TimeOffPolicy model)

Checkpoint

  • TimeOffPolicy model added (without relations)
  • tenantId field included

Step 78: Add TimeOffBalance Model

Input

  • Step 77 complete
  • TimeOffPolicy model exists

Constraints

  • DO NOT add relations yet (added in Step 79 after all models exist)
  • Include unique constraint on [employeeId, policyId, year]

Task

Add to packages/database/prisma/schema.prisma:

model TimeOffBalance {
  id         String   @id @default(cuid())
  tenantId   String
  employeeId String
  policyId   String
  year       Int      // fiscal year
  entitled   Float    @default(0)    // total allocated days
  used       Float    @default(0)    // approved days taken
  pending    Float    @default(0)    // pending request days
  carryOver  Float    @default(0)    // days from previous year
  adjustment Float    @default(0)    // admin adjustments
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  // Relations added in Step 79 after all models are defined

  @@unique([employeeId, policyId, year])
  @@map("time_off_balances")
}

Important: Do NOT run prisma format yet - relations will be added in Step 79.

Gate

# Just verify the model was added - NO prisma format yet
cat packages/database/prisma/schema.prisma | grep -A 5 "model TimeOffBalance"

Rollback

# Remove TimeOffBalance model

Lock

packages/database/prisma/schema.prisma (TimeOffBalance model)

Checkpoint

  • TimeOffBalance model added (without relations)
  • Unique constraint on [employeeId, policyId, year] included
  • tenantId, employeeId, policyId fields included

Step 79: Add TimeOffRequest Model and All Relations

Input

  • Step 78 complete
  • TimeOffPolicy and TimeOffBalance models exist (without relations)

Constraints

  • Add TimeOffRequest model
  • Add ALL relations to ALL three time-off models
  • Include index on [tenantId, status]

Task

Part 1: Add TimeOffRequest model to packages/database/prisma/schema.prisma:

model TimeOffRequest {
  id              String        @id @default(cuid())
  tenantId        String
  employeeId      String
  policyId        String
  startDate       DateTime
  endDate         DateTime
  days            Float         // business days calculated
  halfDay         Boolean       @default(false)
  halfDayPart     HalfDayPart?  // MORNING or AFTERNOON
  status          RequestStatus @default(PENDING)
  reason          String?       // optional notes
  approverId      String?       // manager who approved/rejected
  approvedAt      DateTime?
  approverComment String?
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

  tenant   Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  employee Employee      @relation(fields: [employeeId], references: [id], onDelete: Cascade)
  policy   TimeOffPolicy @relation(fields: [policyId], references: [id])

  @@index([tenantId, status])
  @@index([tenantId, employeeId, status])  // For "my requests" queries
  @@map("time_off_requests")
}

Part 2: Update TimeOffPolicy (add relations to model created in Step 77):

// Add these lines inside the TimeOffPolicy model:
  tenant   Tenant             @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  requests TimeOffRequest[]
  balances TimeOffBalance[]

Part 3: Update TimeOffBalance (add relations to model created in Step 78):

// Add these lines inside the TimeOffBalance model:
  tenant   Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  employee Employee      @relation(fields: [employeeId], references: [id], onDelete: Cascade)
  policy   TimeOffPolicy @relation(fields: [policyId], references: [id])

Part 4: Update the Tenant model:

// In the Tenant model, add:
timeOffPolicies  TimeOffPolicy[]
timeOffBalances  TimeOffBalance[]
timeOffRequests  TimeOffRequest[]

Part 5: Update the Employee model:

// In the Employee model, add:
timeOffBalances TimeOffBalance[]
timeOffRequests TimeOffRequest[]

Gate

cd packages/database
npx prisma format
# Should complete without errors - all relations now exist

Rollback

# Remove all time-off models and their relations from Tenant/Employee

Lock

packages/database/prisma/schema.prisma (all time-off models)

Checkpoint

  • TimeOffRequest model added
  • TimeOffPolicy relations added (tenant, requests, balances)
  • TimeOffBalance relations added (tenant, employee, policy)
  • TimeOffRequest relations added (tenant, employee, policy)
  • Tenant model updated with 3 new arrays
  • Employee model updated with 2 new arrays
  • prisma format succeeds

Step 80: Run Migration

Input

  • Step 79 complete
  • All time-off models defined

Constraints

  • Use db push for development
  • Verify all tables created

Task

cd packages/database

# Push schema changes to database
npx prisma db push

# Verify tables exist
npx prisma studio
# Check for: time_off_policies, time_off_balances, time_off_requests tables

Gate

npx prisma db push
# Should complete without errors

# Verify in psql or Prisma Studio:
# - time_off_policies table exists
# - time_off_balances table exists
# - time_off_requests table exists
# - All enums created (LeaveType, AccrualType, RequestStatus, PolicyStatus, HalfDayPart)

Common Errors

ErrorCauseFix
Database connection failedDocker not runningStart PostgreSQL container
Relation does not existTenant/Employee model missingEnsure Phase 01-02 migrations ran
Enum already existsRe-running migrationUse prisma db push --force-reset (dev only)

Rollback

# In development: prisma db push --force-reset
# In production: Create down migration

Lock

packages/database/prisma/schema.prisma (all time-off models - LOCKED)

Checkpoint

  • db push succeeds
  • time_off_policies table exists
  • time_off_balances table exists
  • time_off_requests table exists
  • All 5 enums created

Step 81: Create TimeOffPolicyRepository

Input

  • Step 80 complete
  • Database tables exist

Constraints

  • Tenant-filtered queries
  • Support CRUD operations
  • Filter by status

Task

First, create the directory structure:

mkdir -p apps/api/src/time-off/repositories
mkdir -p apps/api/src/time-off/services
mkdir -p apps/api/src/time-off/controllers
mkdir -p apps/api/src/time-off/dto

Create apps/api/src/time-off/repositories/time-off-policy.repository.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, PolicyStatus } from '@prisma/client';

@Injectable()
export class TimeOffPolicyRepository {
  constructor(private prisma: PrismaService) {}

  async findAll(tenantId: string, options?: { status?: PolicyStatus }) {
    const where: Prisma.TimeOffPolicyWhereInput = {
      tenantId,
    };

    if (options?.status) {
      where.status = options.status;
    }

    return this.prisma.timeOffPolicy.findMany({
      where,
      orderBy: { name: 'asc' },
    });
  }

  async findById(tenantId: string, id: string) {
    return this.prisma.timeOffPolicy.findFirst({
      where: { id, tenantId },
    });
  }

  async findByCode(tenantId: string, code: string) {
    return this.prisma.timeOffPolicy.findFirst({
      where: { tenantId, code },
    });
  }

  async create(data: Prisma.TimeOffPolicyCreateInput) {
    return this.prisma.timeOffPolicy.create({ data });
  }

  async update(tenantId: string, id: string, data: Prisma.TimeOffPolicyUpdateInput) {
    return this.prisma.timeOffPolicy.updateMany({
      where: { id, tenantId },
      data,
    });
  }

  async delete(tenantId: string, id: string) {
    // Soft delete by setting status to ARCHIVED
    return this.prisma.timeOffPolicy.updateMany({
      where: { id, tenantId },
      data: { status: 'ARCHIVED' },
    });
  }

  async getActivePolicies(tenantId: string) {
    return this.prisma.timeOffPolicy.findMany({
      where: {
        tenantId,
        status: 'ACTIVE',
      },
      orderBy: { name: 'asc' },
    });
  }
}

Gate

cd apps/api
npm run build
# Should compile without errors

Rollback

rm apps/api/src/time-off/repositories/time-off-policy.repository.ts

Lock

apps/api/src/time-off/repositories/time-off-policy.repository.ts

Checkpoint

  • Repository file created
  • All CRUD methods implemented
  • Tenant filtering in all queries
  • npm run build succeeds

Step 82: Create TimeOffPolicyService

Input

  • Step 81 complete
  • TimeOffPolicyRepository exists

Constraints

  • Use repository for data access
  • Validate policy code uniqueness
  • Handle NotFoundException

Task

Create apps/api/src/time-off/services/time-off-policy.service.ts:

import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { TimeOffPolicyRepository } from '../repositories/time-off-policy.repository';
import { CreateTimeOffPolicyDto } from '../dto/create-time-off-policy.dto';
import { UpdateTimeOffPolicyDto } from '../dto/update-time-off-policy.dto';
import { PolicyStatus } from '@prisma/client';

@Injectable()
export class TimeOffPolicyService {
  constructor(private policyRepository: TimeOffPolicyRepository) {}

  async findAll(tenantId: string, status?: PolicyStatus) {
    return this.policyRepository.findAll(tenantId, { status });
  }

  async findById(tenantId: string, id: string) {
    const policy = await this.policyRepository.findById(tenantId, id);
    if (!policy) {
      throw new NotFoundException(`Policy with ID ${id} not found`);
    }
    return policy;
  }

  async getActivePolicies(tenantId: string) {
    return this.policyRepository.getActivePolicies(tenantId);
  }

  async create(tenantId: string, dto: CreateTimeOffPolicyDto) {
    // Check for duplicate code
    if (dto.code) {
      const existing = await this.policyRepository.findByCode(tenantId, dto.code);
      if (existing) {
        throw new ConflictException(`Policy with code ${dto.code} already exists`);
      }
    }

    return this.policyRepository.create({
      tenant: { connect: { id: tenantId } },
      name: dto.name,
      code: dto.code,
      description: dto.description,
      leaveType: dto.leaveType,
      accrualType: dto.accrualType,
      annualAllowance: dto.annualAllowance,
      carryOverLimit: dto.carryOverLimit,
      carryOverExpiry: dto.carryOverExpiry,
      requiresApproval: dto.requiresApproval,
      appliesTo: dto.appliesTo || {},
    });
  }

  async update(tenantId: string, id: string, dto: UpdateTimeOffPolicyDto) {
    await this.findById(tenantId, id); // Verify exists

    // Check code uniqueness if changing
    if (dto.code) {
      const existing = await this.policyRepository.findByCode(tenantId, dto.code);
      if (existing && existing.id !== id) {
        throw new ConflictException(`Policy with code ${dto.code} already exists`);
      }
    }

    await this.policyRepository.update(tenantId, id, dto);
    return this.findById(tenantId, id);
  }

  async archive(tenantId: string, id: string) {
    await this.findById(tenantId, id);
    await this.policyRepository.delete(tenantId, id);
    return { success: true };
  }
}

Create apps/api/src/time-off/dto/create-time-off-policy.dto.ts:

import { IsString, IsOptional, IsEnum, IsNumber, IsBoolean, Min } from 'class-validator';
import { LeaveType, AccrualType } from '@prisma/client';

export class CreateTimeOffPolicyDto {
  @IsString()
  name: string;

  @IsOptional()
  @IsString()
  code?: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsEnum(LeaveType)
  leaveType: LeaveType;

  @IsOptional()
  @IsEnum(AccrualType)
  accrualType?: AccrualType;

  @IsOptional()
  @IsNumber()
  @Min(0)
  annualAllowance?: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  carryOverLimit?: number;

  @IsOptional()
  @IsNumber()
  carryOverExpiry?: number;

  @IsOptional()
  @IsBoolean()
  requiresApproval?: boolean;

  @IsOptional()
  appliesTo?: Record<string, unknown>;
}

Create apps/api/src/time-off/dto/update-time-off-policy.dto.ts:

import { PartialType } from '@nestjs/mapped-types';
import { CreateTimeOffPolicyDto } from './create-time-off-policy.dto';
import { IsEnum, IsOptional } from 'class-validator';
import { PolicyStatus } from '@prisma/client';

export class UpdateTimeOffPolicyDto extends PartialType(CreateTimeOffPolicyDto) {
  @IsOptional()
  @IsEnum(PolicyStatus)
  status?: PolicyStatus;
}

Gate

cd apps/api
npm run build

Rollback

rm apps/api/src/time-off/services/time-off-policy.service.ts
rm apps/api/src/time-off/dto/create-time-off-policy.dto.ts
rm apps/api/src/time-off/dto/update-time-off-policy.dto.ts

Lock

apps/api/src/time-off/services/time-off-policy.service.ts
apps/api/src/time-off/dto/create-time-off-policy.dto.ts
apps/api/src/time-off/dto/update-time-off-policy.dto.ts

Checkpoint

  • Service file created
  • DTOs created with validation
  • Code uniqueness validation
  • npm run build succeeds

Step 83: Create TimeOffPolicyController

Input

  • Step 82 complete
  • TimeOffPolicyService exists

Constraints

  • Require HR_ADMIN or SYSTEM_ADMIN for write operations
  • All users can read active policies

Task

Create apps/api/src/time-off/controllers/time-off-policy.controller.ts:

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
} from '@nestjs/common';
import { TimeOffPolicyService } from '../services/time-off-policy.service';
import { CreateTimeOffPolicyDto } from '../dto/create-time-off-policy.dto';
import { UpdateTimeOffPolicyDto } from '../dto/update-time-off-policy.dto';
import { TenantGuard } from '../../tenant/tenant.guard';
import { SystemRoleGuard } from '../../auth/system-role.guard';
import { RequireRoles } from '../../auth/require-roles.decorator';
import { TenantId } from '../../tenant/tenant.decorator';
import { PolicyStatus } from '@prisma/client';

@Controller('api/v1/timeoff/policies')
@UseGuards(TenantGuard)
export class TimeOffPolicyController {
  constructor(private policyService: TimeOffPolicyService) {}

  @Get()
  async findAll(
    @TenantId() tenantId: string,
    @Query('status') status?: PolicyStatus,
  ) {
    const policies = await this.policyService.findAll(tenantId, status);
    return { data: policies, error: null };
  }

  @Get('active')
  async getActive(@TenantId() tenantId: string) {
    const policies = await this.policyService.getActivePolicies(tenantId);
    return { data: policies, error: null };
  }

  @Get(':id')
  async findOne(@TenantId() tenantId: string, @Param('id') id: string) {
    const policy = await this.policyService.findById(tenantId, id);
    return { data: policy, error: null };
  }

  @Post()
  @UseGuards(SystemRoleGuard)
  @RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
  async create(
    @TenantId() tenantId: string,
    @Body() dto: CreateTimeOffPolicyDto,
  ) {
    const policy = await this.policyService.create(tenantId, dto);
    return { data: policy, error: null };
  }

  @Put(':id')
  @UseGuards(SystemRoleGuard)
  @RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
  async update(
    @TenantId() tenantId: string,
    @Param('id') id: string,
    @Body() dto: UpdateTimeOffPolicyDto,
  ) {
    const policy = await this.policyService.update(tenantId, id, dto);
    return { data: policy, error: null };
  }

  @Delete(':id')
  @UseGuards(SystemRoleGuard)
  @RequireRoles('SYSTEM_ADMIN', 'HR_ADMIN')
  async archive(@TenantId() tenantId: string, @Param('id') id: string) {
    await this.policyService.archive(tenantId, id);
    return { data: { success: true }, error: null };
  }
}

Gate

cd apps/api
npm run build

Rollback

rm apps/api/src/time-off/controllers/time-off-policy.controller.ts

Lock

apps/api/src/time-off/controllers/time-off-policy.controller.ts

Checkpoint

  • Controller created
  • CRUD endpoints implemented
  • Role guards applied
  • npm run build succeeds

Step 84: Create TimeOffBalanceService

Input

  • Step 83 complete
  • TimeOffPolicy CRUD works

Constraints

  • Calculate available balance correctly
  • Support admin adjustments
  • Handle year-based balances

Task

Create apps/api/src/time-off/repositories/time-off-balance.repository.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma } from '@prisma/client';

@Injectable()
export class TimeOffBalanceRepository {
  constructor(private prisma: PrismaService) {}

  async findByEmployeeAndYear(tenantId: string, employeeId: string, year: number) {
    return this.prisma.timeOffBalance.findMany({
      where: { tenantId, employeeId, year },
      include: { policy: true },
    });
  }

  async findByEmployeePolicyYear(tenantId: string, employeeId: string, policyId: string, year: number) {
    return this.prisma.timeOffBalance.findFirst({
      where: {
        tenantId,
        employeeId,
        policyId,
        year,
      },
    });
  }

  async upsert(data: {
    tenantId: string;
    employeeId: string;
    policyId: string;
    year: number;
    entitled?: number;
    used?: number;
    pending?: number;
    carryOver?: number;
    adjustment?: number;
  }) {
    const { tenantId, employeeId, policyId, year, ...updateData } = data;

    return this.prisma.timeOffBalance.upsert({
      where: {
        employeeId_policyId_year: { employeeId, policyId, year },
      },
      create: {
        tenantId,
        employeeId,
        policyId,
        year,
        entitled: updateData.entitled ?? 0,
        used: updateData.used ?? 0,
        pending: updateData.pending ?? 0,
        carryOver: updateData.carryOver ?? 0,
        adjustment: updateData.adjustment ?? 0,
      },
      update: updateData,
    });
  }

  async incrementUsed(employeeId: string, policyId: string, year: number, days: number) {
    return this.prisma.timeOffBalance.update({
      where: {
        employeeId_policyId_year: { employeeId, policyId, year },
      },
      data: {
        used: { increment: days },
        pending: { decrement: days },
      },
    });
  }

  async incrementPending(employeeId: string, policyId: string, year: number, days: number) {
    return this.prisma.timeOffBalance.update({
      where: {
        employeeId_policyId_year: { employeeId, policyId, year },
      },
      data: {
        pending: { increment: days },
      },
    });
  }

  async decrementPending(employeeId: string, policyId: string, year: number, days: number) {
    return this.prisma.timeOffBalance.update({
      where: {
        employeeId_policyId_year: { employeeId, policyId, year },
      },
      data: {
        pending: { decrement: days },
      },
    });
  }
}

Create apps/api/src/time-off/services/time-off-balance.service.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { TimeOffBalanceRepository } from '../repositories/time-off-balance.repository';
import { TimeOffPolicyRepository } from '../repositories/time-off-policy.repository';

export interface BalanceWithAvailable {
  id: string;
  policyId: string;
  policyName: string;
  policyCode: string | null;
  year: number;
  entitled: number;
  used: number;
  pending: number;
  carryOver: number;
  adjustment: number;
  available: number; // calculated
}

@Injectable()
export class TimeOffBalanceService {
  constructor(
    private balanceRepository: TimeOffBalanceRepository,
    private policyRepository: TimeOffPolicyRepository,
  ) {}

  async getEmployeeBalances(
    tenantId: string,
    employeeId: string,
    year?: number,
  ): Promise<BalanceWithAvailable[]> {
    const targetYear = year || new Date().getFullYear();
    const balances = await this.balanceRepository.findByEmployeeAndYear(
      tenantId,
      employeeId,
      targetYear,
    );

    return balances.map((balance) => ({
      id: balance.id,
      policyId: balance.policyId,
      policyName: balance.policy.name,
      policyCode: balance.policy.code,
      year: balance.year,
      entitled: balance.entitled,
      used: balance.used,
      pending: balance.pending,
      carryOver: balance.carryOver,
      adjustment: balance.adjustment,
      available: this.calculateAvailable(balance),
    }));
  }

  calculateAvailable(balance: {
    entitled: number;
    used: number;
    pending: number;
    carryOver: number;
    adjustment: number;
  }): number {
    // Available = Entitled + CarryOver - Used - Pending + Adjustment
    return (
      balance.entitled +
      balance.carryOver -
      balance.used -
      balance.pending +
      balance.adjustment
    );
  }

  async ensureBalanceExists(
    tenantId: string,
    employeeId: string,
    policyId: string,
    year: number,
  ) {
    const existing = await this.balanceRepository.findByEmployeePolicyYear(
      tenantId,
      employeeId,
      policyId,
      year,
    );

    if (!existing) {
      // Get policy to set initial entitlement
      const policy = await this.policyRepository.findById(tenantId, policyId);
      if (!policy) {
        throw new NotFoundException(`Policy ${policyId} not found`);
      }

      await this.balanceRepository.upsert({
        tenantId,
        employeeId,
        policyId,
        year,
        entitled: policy.annualAllowance,
      });
    }
  }

  async adjustBalance(
    tenantId: string,
    employeeId: string,
    policyId: string,
    year: number,
    adjustment: number,
    reason?: string,
  ) {
    await this.ensureBalanceExists(tenantId, employeeId, policyId, year);

    return this.balanceRepository.upsert({
      tenantId,
      employeeId,
      policyId,
      year,
      adjustment,
    });
  }
}

Gate

cd apps/api
npm run build

Rollback

rm apps/api/src/time-off/repositories/time-off-balance.repository.ts
rm apps/api/src/time-off/services/time-off-balance.service.ts

Lock

apps/api/src/time-off/repositories/time-off-balance.repository.ts
apps/api/src/time-off/services/time-off-balance.service.ts

Checkpoint

  • Balance repository created
  • Balance service created
  • Available calculation: Entitled + CarryOver - Used - Pending + Adjustment
  • npm run build succeeds

Step 85: Create TimeOffRequestRepository

Input

  • Step 84 complete
  • Balance service works

Constraints

  • Support status filtering
  • Include employee and policy relations
  • Date range queries for calendar

Task

Create apps/api/src/time-off/repositories/time-off-request.repository.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, RequestStatus } from '@prisma/client';

@Injectable()
export class TimeOffRequestRepository {
  constructor(private prisma: PrismaService) {}

  // findAll: Uses STRICT CONTAINMENT semantics
  // - Request must START on/after startDate AND END on/before endDate
  // - Use for: "My requests this month" list view
  // - Compare with getCalendarData which uses OVERLAP semantics
  async findAll(
    tenantId: string,
    options?: {
      employeeId?: string;
      status?: RequestStatus;
      startDate?: Date;
      endDate?: Date;
    },
  ) {
    const where: Prisma.TimeOffRequestWhereInput = { tenantId };

    if (options?.employeeId) {
      where.employeeId = options.employeeId;
    }
    if (options?.status) {
      where.status = options.status;
    }
    // Strict containment: request must be entirely within the date range
    if (options?.startDate || options?.endDate) {
      where.startDate = {};
      if (options.startDate) {
        where.startDate.gte = options.startDate;
      }
      if (options.endDate) {
        where.endDate = { lte: options.endDate };
      }
    }

    return this.prisma.timeOffRequest.findMany({
      where,
      include: {
        employee: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            email: true,
            pictureUrl: true,
          },
        },
        policy: {
          select: {
            id: true,
            name: true,
            code: true,
            leaveType: true,
          },
        },
      },
      orderBy: { createdAt: 'desc' },
    });
  }

  async findById(tenantId: string, id: string) {
    return this.prisma.timeOffRequest.findFirst({
      where: { id, tenantId },
      include: {
        employee: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            email: true,
            pictureUrl: true,
            orgRelations: {
              select: { primaryManagerId: true },
            },
          },
        },
        policy: true,
      },
    });
  }

  async findPendingForManager(tenantId: string, managerId: string) {
    return this.prisma.timeOffRequest.findMany({
      where: {
        tenantId,
        status: 'PENDING',
        employee: {
          orgRelations: {
            primaryManagerId: managerId,
          },
        },
      },
      include: {
        employee: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            pictureUrl: true,
          },
        },
        policy: {
          select: {
            id: true,
            name: true,
            code: true,
          },
        },
      },
      orderBy: { createdAt: 'asc' },
    });
  }

  async create(data: Prisma.TimeOffRequestUncheckedCreateInput) {
    return this.prisma.timeOffRequest.create({
      data,
      include: {
        employee: true,
        policy: true,
      },
    });
  }

  async updateStatus(
    tenantId: string,
    id: string,
    status: RequestStatus,
    approverId?: string,
    approverComment?: string,
  ) {
    return this.prisma.timeOffRequest.updateMany({
      where: { id, tenantId },
      data: {
        status,
        approverId,
        approverComment,
        approvedAt: status === 'APPROVED' || status === 'REJECTED' ? new Date() : undefined,
      },
    });
  }

  async checkOverlap(
    employeeId: string,
    startDate: Date,
    endDate: Date,
    excludeRequestId?: string,
  ) {
    const where: Prisma.TimeOffRequestWhereInput = {
      employeeId,
      status: { in: ['PENDING', 'APPROVED'] },
      OR: [
        {
          startDate: { lte: endDate },
          endDate: { gte: startDate },
        },
      ],
    };

    if (excludeRequestId) {
      where.id = { not: excludeRequestId };
    }

    return this.prisma.timeOffRequest.findFirst({ where });
  }

  // getCalendarData: Uses OVERLAP semantics
  // - Returns any request that OVERLAPS with the date range
  // - startDate <= range.endDate AND endDate >= range.startDate
  // - Use for: Team calendar view showing who's off during a period
  // - Compare with findAll which uses STRICT CONTAINMENT semantics
  async getCalendarData(
    tenantId: string,
    startDate: Date,
    endDate: Date,
    options?: {
      departmentId?: string;
      teamId?: string;
    },
  ) {
    // Overlap condition: request touches any part of the date range
    const where: Prisma.TimeOffRequestWhereInput = {
      tenantId,
      status: 'APPROVED',
      startDate: { lte: endDate },   // Request starts before range ends
      endDate: { gte: startDate },   // Request ends after range starts
    };

    if (options?.departmentId) {
      where.employee = {
        orgRelations: {
          departments: {
            some: {
              departmentId: options.departmentId,
              isPrimary: true,
            },
          },
        },
      };
    }

    return this.prisma.timeOffRequest.findMany({
      where,
      include: {
        employee: {
          select: {
            id: true,
            firstName: true,
            lastName: true,
            pictureUrl: true,
          },
        },
        policy: {
          select: {
            name: true,
            code: true,
            leaveType: true,
          },
        },
      },
      orderBy: { startDate: 'asc' },
    });
  }
}

Gate

cd apps/api
npm run build

Rollback

rm apps/api/src/time-off/repositories/time-off-request.repository.ts

Lock

apps/api/src/time-off/repositories/time-off-request.repository.ts

Checkpoint

  • Repository created
  • Status filtering works
  • Overlap detection implemented
  • Calendar data query implemented
  • npm run build succeeds

Step 86: Create TimeOffRequestService

Input

  • Step 85 complete
  • Request repository exists

Constraints

  • Validate available balance before submitting
  • Manager can only approve direct reports
  • Employees cannot approve own requests
  • Rejection requires comment
  • Update balance on approval

Task

Create apps/api/src/time-off/dto/create-time-off-request.dto.ts:

import { IsString, IsDateString, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { HalfDayPart } from '@prisma/client';

export class CreateTimeOffRequestDto {
  @IsString()
  policyId: string;

  @IsDateString()
  startDate: string;

  @IsDateString()
  endDate: string;

  @IsOptional()
  @IsBoolean()
  halfDay?: boolean;

  @IsOptional()
  @IsEnum(HalfDayPart)
  halfDayPart?: HalfDayPart;

  @IsOptional()
  @IsString()
  reason?: string;
}

Create apps/api/src/time-off/dto/approve-request.dto.ts:

import { IsString, IsOptional, IsEnum } from 'class-validator';
import { RequestStatus } from '@prisma/client';

export class ApproveRequestDto {
  @IsEnum(RequestStatus)
  status: 'APPROVED' | 'REJECTED';

  @IsOptional()
  @IsString()
  comment?: string;
}

Create apps/api/src/time-off/services/time-off-request.service.ts:

import {
  Injectable,
  NotFoundException,
  BadRequestException,
  ForbiddenException,
} from '@nestjs/common';
import { TimeOffRequestRepository } from '../repositories/time-off-request.repository';
import { TimeOffBalanceRepository } from '../repositories/time-off-balance.repository';
import { TimeOffBalanceService } from './time-off-balance.service';
import { TimeOffPolicyService } from './time-off-policy.service';
import { CreateTimeOffRequestDto } from '../dto/create-time-off-request.dto';
import { ApproveRequestDto } from '../dto/approve-request.dto';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class TimeOffRequestService {
  constructor(
    private prisma: PrismaService,
    private requestRepository: TimeOffRequestRepository,
    private balanceRepository: TimeOffBalanceRepository,
    private balanceService: TimeOffBalanceService,
  ) {}

  async findAll(
    tenantId: string,
    options?: {
      employeeId?: string;
      status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED';
    },
  ) {
    return this.requestRepository.findAll(tenantId, options);
  }

  async findById(tenantId: string, id: string) {
    const request = await this.requestRepository.findById(tenantId, id);
    if (!request) {
      throw new NotFoundException(`Request ${id} not found`);
    }
    return request;
  }

  async getPendingForManager(tenantId: string, managerId: string) {
    return this.requestRepository.findPendingForManager(tenantId, managerId);
  }

  async submit(tenantId: string, employeeId: string, dto: CreateTimeOffRequestDto) {
    const startDate = new Date(dto.startDate);
    const endDate = new Date(dto.endDate);
    const year = startDate.getFullYear();

    // Validate dates
    if (endDate < startDate) {
      throw new BadRequestException('End date must be after start date');
    }

    // Calculate business days
    const days = dto.halfDay ? 0.5 : this.calculateBusinessDays(startDate, endDate);

    // Check for overlapping requests
    const overlap = await this.requestRepository.checkOverlap(employeeId, startDate, endDate);
    if (overlap) {
      throw new BadRequestException('You already have a request for these dates');
    }

    // Ensure balance exists and check available
    await this.balanceService.ensureBalanceExists(tenantId, employeeId, dto.policyId, year);
    const balances = await this.balanceService.getEmployeeBalances(tenantId, employeeId, year);
    const balance = balances.find((b) => b.policyId === dto.policyId);

    if (!balance || balance.available < days) {
      throw new BadRequestException(
        `Insufficient balance. Available: ${balance?.available ?? 0} days, Requested: ${days} days`,
      );
    }

    // Create request
    const request = await this.requestRepository.create({
      tenantId,
      employeeId,
      policyId: dto.policyId,
      startDate,
      endDate,
      days,
      halfDay: dto.halfDay || false,
      halfDayPart: dto.halfDayPart,
      reason: dto.reason,
      status: 'PENDING',
    });

    // Update pending balance
    await this.balanceRepository.incrementPending(employeeId, dto.policyId, year, days);

    return request;
  }

  async approve(
    tenantId: string,
    requestId: string,
    approverId: string,
    dto: ApproveRequestDto,
  ) {
    const request = await this.findById(tenantId, requestId);

    // Cannot approve own request
    if (request.employeeId === approverId) {
      throw new ForbiddenException('You cannot approve your own request');
    }

    // Check if approver is the manager
    const managerId = request.employee.orgRelations?.primaryManagerId;
    if (managerId !== approverId) {
      throw new ForbiddenException('You can only approve requests from your direct reports');
    }

    // Rejection requires comment
    if (dto.status === 'REJECTED' && !dto.comment) {
      throw new BadRequestException('Rejection requires a comment');
    }

    const year = request.startDate.getFullYear();

    // Use transaction to ensure atomicity of status + balance updates
    // This prevents partial updates if one operation fails
    await this.prisma.$transaction(async (tx) => {
      // Update request status with precondition check to prevent race conditions
      // Only update if status is still PENDING (handles concurrent approval attempts)
      const result = await tx.timeOffRequest.updateMany({
        where: {
          id: requestId,
          tenantId,
          status: 'PENDING', // Precondition: must still be pending
        },
        data: {
          status: dto.status,
          approverId,
          approverComment: dto.comment,
          approvedAt: new Date(),
        },
      });

      if (result.count === 0) {
        throw new BadRequestException('Request has already been processed');
      }

      // Update balance atomically within same transaction
      if (dto.status === 'APPROVED') {
        // Move from pending to used
        await tx.timeOffBalance.update({
          where: {
            employeeId_policyId_year: {
              employeeId: request.employeeId,
              policyId: request.policyId,
              year,
            },
          },
          data: {
            used: { increment: request.days },
            pending: { decrement: request.days },
          },
        });
      } else if (dto.status === 'REJECTED') {
        // Remove from pending
        await tx.timeOffBalance.update({
          where: {
            employeeId_policyId_year: {
              employeeId: request.employeeId,
              policyId: request.policyId,
              year,
            },
          },
          data: {
            pending: { decrement: request.days },
          },
        });
      }
    });

    return this.findById(tenantId, requestId);
  }

  async cancel(tenantId: string, requestId: string, employeeId: string) {
    const request = await this.findById(tenantId, requestId);

    // Can only cancel own requests
    if (request.employeeId !== employeeId) {
      throw new ForbiddenException('You can only cancel your own requests');
    }

    // Can only cancel pending requests (checked again in transaction for safety)
    if (request.status !== 'PENDING') {
      throw new BadRequestException('Can only cancel pending requests');
    }

    const year = request.startDate.getFullYear();

    // Use transaction to ensure atomicity of status + balance updates
    await this.prisma.$transaction(async (tx) => {
      // Update status with precondition check to prevent race conditions
      const result = await tx.timeOffRequest.updateMany({
        where: {
          id: requestId,
          tenantId,
          status: 'PENDING', // Precondition: must still be pending
        },
        data: {
          status: 'CANCELLED',
        },
      });

      if (result.count === 0) {
        throw new BadRequestException('Request has already been processed');
      }

      // Remove from pending balance atomically
      await tx.timeOffBalance.update({
        where: {
          employeeId_policyId_year: {
            employeeId: request.employeeId,
            policyId: request.policyId,
            year,
          },
        },
        data: {
          pending: { decrement: request.days },
        },
      });
    });

    return { success: true };
  }

  async getCalendar(
    tenantId: string,
    startDate: Date,
    endDate: Date,
    options?: { departmentId?: string; teamId?: string },
  ) {
    return this.requestRepository.getCalendarData(tenantId, startDate, endDate, options);
  }

  private calculateBusinessDays(startDate: Date, endDate: Date): number {
    // Note: For MVP, cross-year requests (Dec 28 - Jan 3) use the start date's year
    // for balance deduction. Future enhancement: split days proportionally between years.
    let count = 0;
    const current = new Date(startDate);

    while (current <= endDate) {
      const dayOfWeek = current.getDay();
      if (dayOfWeek !== 0 && dayOfWeek !== 6) {
        count++;
      }
      current.setDate(current.getDate() + 1);
    }

    return count;
  }
}

Gate

cd apps/api
npm run build

Common Errors

ErrorCauseFix
Cannot approve own requestApprover is the employeeDifferent user must approve
Insufficient balanceNot enough days availableCheck balance calculation
Rejection requires commentNo comment providedInclude comment in dto

Rollback

rm apps/api/src/time-off/services/time-off-request.service.ts
rm apps/api/src/time-off/dto/create-time-off-request.dto.ts
rm apps/api/src/time-off/dto/approve-request.dto.ts

Lock

apps/api/src/time-off/services/time-off-request.service.ts
apps/api/src/time-off/dto/create-time-off-request.dto.ts
apps/api/src/time-off/dto/approve-request.dto.ts

Checkpoint

  • Service created
  • Balance validation on submit
  • Manager approval permission check
  • Self-approval prevention
  • Rejection requires comment
  • Balance updates on approval/rejection/cancel
  • npm run build succeeds

Step 87: Create TimeOffRequestController

Input

  • Step 86 complete
  • Request service with all business logic

Constraints

  • Employees can submit and view own requests
  • Managers can view pending approvals for direct reports
  • Calendar endpoint for all approved time-off

Task

Note: The CurrentUser decorator was created in Phase 01 (Step 23). Import it from ../../auth/current-user.decorator. It extracts user info from the authenticated request, including employeeId from the token payload.

Create the controller:

Create apps/api/src/time-off/controllers/time-off-request.controller.ts:

import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
} from '@nestjs/common';
import { TimeOffRequestService } from '../services/time-off-request.service';
import { TimeOffBalanceService } from '../services/time-off-balance.service';
import { CreateTimeOffRequestDto } from '../dto/create-time-off-request.dto';
import { ApproveRequestDto } from '../dto/approve-request.dto';
import { TenantGuard } from '../../tenant/tenant.guard';
import { TenantId } from '../../tenant/tenant.decorator';
import { CurrentUser } from '../../auth/current-user.decorator';

@Controller('api/v1/timeoff')
@UseGuards(TenantGuard)
export class TimeOffRequestController {
  constructor(
    private requestService: TimeOffRequestService,
    private balanceService: TimeOffBalanceService,
  ) {}

  // ==========================================
  // REQUEST ENDPOINTS
  // ==========================================

  @Get('requests')
  async findAll(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') employeeId: string,
    @Query('status') status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED',
  ) {
    const requests = await this.requestService.findAll(tenantId, {
      employeeId,
      status,
    });
    return { data: requests, error: null };
  }

  @Get('requests/pending-approvals')
  async getPendingApprovals(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') managerId: string,
  ) {
    const requests = await this.requestService.getPendingForManager(tenantId, managerId);
    return { data: requests, error: null };
  }

  @Get('requests/:id')
  async findOne(
    @TenantId() tenantId: string,
    @Param('id') id: string,
  ) {
    const request = await this.requestService.findById(tenantId, id);
    return { data: request, error: null };
  }

  @Post('requests')
  async submit(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') employeeId: string,
    @Body() dto: CreateTimeOffRequestDto,
  ) {
    const request = await this.requestService.submit(tenantId, employeeId, dto);
    return { data: request, error: null };
  }

  @Patch('requests/:id')
  async approve(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') approverId: string,
    @Param('id') id: string,
    @Body() dto: ApproveRequestDto,
  ) {
    const request = await this.requestService.approve(tenantId, id, approverId, dto);
    return { data: request, error: null };
  }

  @Delete('requests/:id')
  async cancel(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') employeeId: string,
    @Param('id') id: string,
  ) {
    await this.requestService.cancel(tenantId, id, employeeId);
    return { data: { success: true }, error: null };
  }

  // ==========================================
  // BALANCE ENDPOINTS
  // ==========================================

  @Get('balances')
  async getMyBalances(
    @TenantId() tenantId: string,
    @CurrentUser('employeeId') employeeId: string,
    @Query('year') year?: string,
  ) {
    const balances = await this.balanceService.getEmployeeBalances(
      tenantId,
      employeeId,
      year ? parseInt(year) : undefined,
    );
    return { data: balances, error: null };
  }

  @Get('balances/:employeeId')
  async getEmployeeBalances(
    @TenantId() tenantId: string,
    @Param('employeeId') employeeId: string,
    @Query('year') year?: string,
  ) {
    const balances = await this.balanceService.getEmployeeBalances(
      tenantId,
      employeeId,
      year ? parseInt(year) : undefined,
    );
    return { data: balances, error: null };
  }

  // ==========================================
  // CALENDAR ENDPOINT
  // ==========================================

  @Get('calendar')
  async getCalendar(
    @TenantId() tenantId: string,
    @Query('startDate') startDate: string,
    @Query('endDate') endDate: string,
    @Query('departmentId') departmentId?: string,
    @Query('teamId') teamId?: string,
  ) {
    const data = await this.requestService.getCalendar(
      tenantId,
      new Date(startDate),
      new Date(endDate),
      { departmentId, teamId },
    );
    return { data, error: null };
  }
}

Create apps/api/src/time-off/time-off.module.ts:

import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { TimeOffPolicyRepository } from './repositories/time-off-policy.repository';
import { TimeOffBalanceRepository } from './repositories/time-off-balance.repository';
import { TimeOffRequestRepository } from './repositories/time-off-request.repository';
import { TimeOffPolicyService } from './services/time-off-policy.service';
import { TimeOffBalanceService } from './services/time-off-balance.service';
import { TimeOffRequestService } from './services/time-off-request.service';
import { TimeOffPolicyController } from './controllers/time-off-policy.controller';
import { TimeOffRequestController } from './controllers/time-off-request.controller';

@Module({
  imports: [PrismaModule],
  controllers: [TimeOffPolicyController, TimeOffRequestController],
  providers: [
    TimeOffPolicyRepository,
    TimeOffBalanceRepository,
    TimeOffRequestRepository,
    TimeOffPolicyService,
    TimeOffBalanceService,
    TimeOffRequestService,
  ],
  exports: [TimeOffPolicyService, TimeOffBalanceService, TimeOffRequestService],
})
export class TimeOffModule {}

Register in apps/api/src/app.module.ts:

import { TimeOffModule } from './time-off/time-off.module';

@Module({
  imports: [
    // ... existing modules
    TimeOffModule,
  ],
})
export class AppModule {}

Gate

cd apps/api
npm run build

# Test endpoints with curl
curl -X GET http://localhost:3001/api/v1/timeoff/policies/active \
  -H "x-tenant-id: your-tenant-id"
# Should return list of active policies

Rollback

rm apps/api/src/time-off/controllers/time-off-request.controller.ts
rm apps/api/src/time-off/time-off.module.ts
# Remove TimeOffModule from app.module.ts

Lock

apps/api/src/time-off/controllers/time-off-request.controller.ts
apps/api/src/time-off/time-off.module.ts
apps/api/src/app.module.ts (TimeOffModule import)

Checkpoint

  • Request controller created
  • Module created and registered
  • npm run build succeeds
  • GET /api/v1/timeoff/policies/active returns data

Step 88: Create Time-Off Request Form

Input

  • Step 87 complete
  • API endpoints working

Constraints

  • Use Shadcn form components
  • Show available balance before submit
  • Date picker for start/end dates
  • Policy selector from active policies

Task

First, install required dependencies:

cd apps/web
npm install react-hook-form @hookform/resolvers date-fns
# Note: zod should already be installed per tech stack (Zod 3.x)

Second, add the patch method to apps/web/lib/api.ts (created in Phase 04):

// Add this method to the api object in lib/api.ts:
async patch<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
  const tenantId = await getTenantId();
  const res = await fetch(`${API_URL}/api/v1${path}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      'x-tenant-id': tenantId,
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const errorBody = await res.json().catch(() => ({}));
    throw new Error(errorBody.error?.message || `API request failed: ${res.status}`);
  }
  return res.json();
}

Then create the query hooks:

Create apps/web/lib/queries/time-off.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';

export interface TimeOffPolicy {
  id: string;
  name: string;
  code: string | null;
  leaveType: string;
  annualAllowance: number;
}

export interface TimeOffBalance {
  id: string;
  policyId: string;
  policyName: string;
  policyCode: string | null;
  year: number;
  entitled: number;
  used: number;
  pending: number;
  carryOver: number;
  adjustment: number;
  available: number;
}

export interface TimeOffRequest {
  id: string;
  policyId: string;
  startDate: string;
  endDate: string;
  days: number;
  halfDay: boolean;
  halfDayPart: 'MORNING' | 'AFTERNOON' | null;
  status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELLED';
  reason: string | null;
  approverComment: string | null;
  createdAt: string;
  employee: {
    id: string;
    firstName: string;
    lastName: string;
    pictureUrl: string | null;
  };
  policy: {
    id: string;
    name: string;
    code: string | null;
  };
}

export function useActivePolicies() {
  return useQuery({
    queryKey: ['timeoff-policies', 'active'],
    queryFn: async (): Promise<TimeOffPolicy[]> => {
      const response = await api.get<TimeOffPolicy[]>('/timeoff/policies/active');
      return response.data;
    },
  });
}

export function useMyBalances(year?: number) {
  return useQuery({
    queryKey: ['timeoff-balances', 'me', year],
    queryFn: async (): Promise<TimeOffBalance[]> => {
      const params = year ? `?year=${year}` : '';
      const response = await api.get<TimeOffBalance[]>(`/timeoff/balances${params}`);
      return response.data;
    },
  });
}

export function useMyRequests(status?: string) {
  return useQuery({
    queryKey: ['timeoff-requests', 'me', status],
    queryFn: async (): Promise<TimeOffRequest[]> => {
      const params = status ? `?status=${status}` : '';
      const response = await api.get<TimeOffRequest[]>(`/timeoff/requests${params}`);
      return response.data;
    },
  });
}

export function usePendingApprovals() {
  return useQuery({
    queryKey: ['timeoff-requests', 'pending-approvals'],
    queryFn: async (): Promise<TimeOffRequest[]> => {
      const response = await api.get<TimeOffRequest[]>('/timeoff/requests/pending-approvals');
      return response.data;
    },
  });
}

export function useSubmitRequest() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: {
      policyId: string;
      startDate: string;
      endDate: string;
      halfDay?: boolean;
      halfDayPart?: 'MORNING' | 'AFTERNOON';
      reason?: string;
    }) => {
      const response = await api.post<TimeOffRequest>('/timeoff/requests', data);
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['timeoff-requests'] });
      queryClient.invalidateQueries({ queryKey: ['timeoff-balances'] });
    },
  });
}

export function useApproveRequest() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      requestId,
      status,
      comment,
    }: {
      requestId: string;
      status: 'APPROVED' | 'REJECTED';
      comment?: string;
    }) => {
      const response = await api.patch<TimeOffRequest>(`/timeoff/requests/${requestId}`, {
        status,
        comment,
      });
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['timeoff-requests'] });
      queryClient.invalidateQueries({ queryKey: ['timeoff-balances'] });
    },
  });
}

export function useCancelRequest() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (requestId: string) => {
      await api.delete(`/timeoff/requests/${requestId}`);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['timeoff-requests'] });
      queryClient.invalidateQueries({ queryKey: ['timeoff-balances'] });
    },
  });
}

Create apps/web/app/dashboard/time-off/request/page.tsx:

import { Metadata } from 'next';
import { TimeOffRequestForm } from './request-form';

export const metadata: Metadata = {
  title: 'Request Time Off',
};

export default function TimeOffRequestPage() {
  return (
    <div className="container max-w-2xl py-6">
      <h1 className="text-2xl font-bold mb-6">Request Time Off</h1>
      <TimeOffRequestForm />
    </div>
  );
}

Create apps/web/app/dashboard/time-off/request/request-form.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { useActivePolicies, useMyBalances, useSubmitRequest } from '@/lib/queries/time-off';

const formSchema = z.object({
  policyId: z.string().min(1, 'Please select a leave type'),
  startDate: z.date({ required_error: 'Start date is required' }),
  endDate: z.date({ required_error: 'End date is required' }),
  halfDay: z.boolean().default(false),
  halfDayPart: z.enum(['MORNING', 'AFTERNOON']).optional(),
  reason: z.string().optional(),
}).refine((data) => data.endDate >= data.startDate, {
  message: 'End date must be on or after start date',
  path: ['endDate'],
});

type FormValues = z.infer<typeof formSchema>;

export function TimeOffRequestForm() {
  const router = useRouter();
  const { data: policies, isLoading: policiesLoading } = useActivePolicies();
  const { data: balances } = useMyBalances();
  const submitRequest = useSubmitRequest();

  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      halfDay: false,
    },
  });

  const selectedPolicyId = form.watch('policyId');
  const selectedBalance = balances?.find((b) => b.policyId === selectedPolicyId);

  async function onSubmit(values: FormValues) {
    try {
      await submitRequest.mutateAsync({
        policyId: values.policyId,
        startDate: format(values.startDate, 'yyyy-MM-dd'),
        endDate: format(values.endDate, 'yyyy-MM-dd'),
        halfDay: values.halfDay,
        halfDayPart: values.halfDayPart,
        reason: values.reason,
      });
      toast.success('Time-off request submitted');
      router.push('/dashboard/time-off');
    } catch (error) {
      toast.error(error instanceof Error ? error.message : 'Failed to submit request');
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="policyId"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Leave Type</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Select leave type" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  {policies?.map((policy) => (
                    <SelectItem key={policy.id} value={policy.id}>
                      {policy.name}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {selectedBalance && (
          <Card>
            <CardHeader className="py-3">
              <CardTitle className="text-sm font-medium">Available Balance</CardTitle>
            </CardHeader>
            <CardContent className="py-3">
              <p className="text-2xl font-bold">{selectedBalance.available} days</p>
              <p className="text-sm text-muted-foreground">
                Entitled: {selectedBalance.entitled} | Used: {selectedBalance.used} | Pending: {selectedBalance.pending}
              </p>
            </CardContent>
          </Card>
        )}

        <div className="grid grid-cols-2 gap-4">
          <FormField
            control={form.control}
            name="startDate"
            render={({ field }) => (
              <FormItem className="flex flex-col">
                <FormLabel>Start Date</FormLabel>
                <Popover>
                  <PopoverTrigger asChild>
                    <FormControl>
                      <Button
                        variant="outline"
                        className={cn(
                          'pl-3 text-left font-normal',
                          !field.value && 'text-muted-foreground'
                        )}
                      >
                        {field.value ? format(field.value, 'PPP') : 'Pick a date'}
                        <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
                      </Button>
                    </FormControl>
                  </PopoverTrigger>
                  <PopoverContent className="w-auto p-0" align="start">
                    <Calendar
                      mode="single"
                      selected={field.value}
                      onSelect={field.onChange}
                      disabled={(date) => date < new Date()}
                    />
                  </PopoverContent>
                </Popover>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="endDate"
            render={({ field }) => (
              <FormItem className="flex flex-col">
                <FormLabel>End Date</FormLabel>
                <Popover>
                  <PopoverTrigger asChild>
                    <FormControl>
                      <Button
                        variant="outline"
                        className={cn(
                          'pl-3 text-left font-normal',
                          !field.value && 'text-muted-foreground'
                        )}
                      >
                        {field.value ? format(field.value, 'PPP') : 'Pick a date'}
                        <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
                      </Button>
                    </FormControl>
                  </PopoverTrigger>
                  <PopoverContent className="w-auto p-0" align="start">
                    <Calendar
                      mode="single"
                      selected={field.value}
                      onSelect={field.onChange}
                      disabled={(date) => date < new Date()}
                    />
                  </PopoverContent>
                </Popover>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>

        <FormField
          control={form.control}
          name="halfDay"
          render={({ field }) => (
            <FormItem className="flex flex-row items-center justify-between rounded-2xl bg-gray-50 p-6">
              <div className="space-y-0.5">
                <FormLabel className="text-base">Half Day</FormLabel>
                <FormDescription>
                  Request only half a day off
                </FormDescription>
              </div>
              <FormControl>
                <Switch
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
            </FormItem>
          )}
        />

        {form.watch('halfDay') && (
          <FormField
            control={form.control}
            name="halfDayPart"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Which half?</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl>
                    <SelectTrigger>
                      <SelectValue placeholder="Select morning or afternoon" />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    <SelectItem value="MORNING">Morning</SelectItem>
                    <SelectItem value="AFTERNOON">Afternoon</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
        )}

        <FormField
          control={form.control}
          name="reason"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Reason (Optional)</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Add a note for your manager..."
                  className="resize-none"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" disabled={submitRequest.isPending}>
          {submitRequest.isPending ? 'Submitting...' : 'Submit Request'}
        </Button>
      </form>
    </Form>
  );
}

Gate

cd apps/web

# Install required Shadcn components if not present
npx shadcn@latest add form
npx shadcn@latest add select
npx shadcn@latest add calendar
npx shadcn@latest add popover
npx shadcn@latest add switch
npx shadcn@latest add textarea

npm run build
# Should build without errors

Rollback

rm -rf apps/web/app/dashboard/time-off/request
rm apps/web/lib/queries/time-off.ts

Lock

apps/web/lib/queries/time-off.ts
apps/web/app/dashboard/time-off/request/page.tsx
apps/web/app/dashboard/time-off/request/request-form.tsx

Checkpoint

  • Query hooks created
  • Request form page created
  • Policy selector shows active policies
  • Balance displayed for selected policy
  • Date pickers work
  • Half-day toggle works
  • Form submits successfully
  • npm run build succeeds

Step 89: Create My Requests Page

Input

  • Step 88 complete
  • Request form works

Constraints

  • Show list of user's requests
  • Filter by status
  • Show request details
  • Cancel pending requests

Task

Create apps/web/app/dashboard/time-off/page.tsx:

import { Metadata } from 'next';
import { MyRequestsView } from './my-requests-view';

export const metadata: Metadata = {
  title: 'My Time Off',
};

export default function TimeOffPage() {
  return (
    <div className="container py-6">
      <MyRequestsView />
    </div>
  );
}

Create apps/web/app/dashboard/time-off/my-requests-view.tsx:

'use client';

import { useState } from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { Plus, Calendar, Clock, XCircle } from 'lucide-react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useMyRequests, useMyBalances, useCancelRequest } from '@/lib/queries/time-off';

const statusColors = {
  PENDING: 'bg-yellow-100 text-yellow-800',
  APPROVED: 'bg-green-100 text-green-800',
  REJECTED: 'bg-red-100 text-red-800',
  CANCELLED: 'bg-gray-100 text-gray-800',
};

export function MyRequestsView() {
  const [statusFilter, setStatusFilter] = useState<string>('');
  const { data: requests, isLoading: requestsLoading } = useMyRequests(statusFilter || undefined);
  const { data: balances, isLoading: balancesLoading } = useMyBalances();
  const cancelRequest = useCancelRequest();

  const handleCancel = async (requestId: string) => {
    try {
      await cancelRequest.mutateAsync(requestId);
      toast.success('Request cancelled');
    } catch (error) {
      toast.error('Failed to cancel request');
    }
  };

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">My Time Off</h1>
        <Button asChild>
          <Link href="/dashboard/time-off/request">
            <Plus className="mr-2 h-4 w-4" />
            Request Time Off
          </Link>
        </Button>
      </div>

      {/* Balances */}
      <div className="grid gap-4 md:grid-cols-3">
        {balancesLoading ? (
          <>
            <Skeleton className="h-24" />
            <Skeleton className="h-24" />
            <Skeleton className="h-24" />
          </>
        ) : (
          balances?.map((balance) => (
            <Card key={balance.id}>
              <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
                <CardTitle className="text-sm font-medium">
                  {balance.policyName}
                </CardTitle>
                <Calendar className="h-4 w-4 text-muted-foreground" />
              </CardHeader>
              <CardContent>
                <div className="text-2xl font-bold">{balance.available} days</div>
                <p className="text-xs text-muted-foreground">
                  {balance.used} used, {balance.pending} pending
                </p>
              </CardContent>
            </Card>
          ))
        )}
      </div>

      {/* Requests */}
      <Card>
        <CardHeader>
          <div className="flex items-center justify-between">
            <CardTitle>My Requests</CardTitle>
            <Select value={statusFilter} onValueChange={setStatusFilter}>
              <SelectTrigger className="w-[150px]">
                <SelectValue placeholder="All statuses" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="">All statuses</SelectItem>
                <SelectItem value="PENDING">Pending</SelectItem>
                <SelectItem value="APPROVED">Approved</SelectItem>
                <SelectItem value="REJECTED">Rejected</SelectItem>
                <SelectItem value="CANCELLED">Cancelled</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </CardHeader>
        <CardContent>
          {requestsLoading ? (
            <div className="space-y-2">
              <Skeleton className="h-12" />
              <Skeleton className="h-12" />
              <Skeleton className="h-12" />
            </div>
          ) : requests?.length === 0 ? (
            <div className="text-center py-8 text-muted-foreground">
              No requests found
            </div>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead>Type</TableHead>
                  <TableHead>Dates</TableHead>
                  <TableHead>Days</TableHead>
                  <TableHead>Status</TableHead>
                  <TableHead>Submitted</TableHead>
                  <TableHead></TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {requests?.map((request) => (
                  <TableRow key={request.id}>
                    <TableCell className="font-medium">
                      {request.policy.name}
                    </TableCell>
                    <TableCell>
                      {format(new Date(request.startDate), 'MMM d')} -{' '}
                      {format(new Date(request.endDate), 'MMM d, yyyy')}
                    </TableCell>
                    <TableCell>
                      {request.days} {request.halfDay && '(half day)'}
                    </TableCell>
                    <TableCell>
                      <Badge className={statusColors[request.status]}>
                        {request.status}
                      </Badge>
                    </TableCell>
                    <TableCell>
                      {format(new Date(request.createdAt), 'MMM d, yyyy')}
                    </TableCell>
                    <TableCell>
                      {request.status === 'PENDING' && (
                        <AlertDialog>
                          <AlertDialogTrigger asChild>
                            <Button variant="ghost" size="sm">
                              <XCircle className="h-4 w-4" />
                            </Button>
                          </AlertDialogTrigger>
                          <AlertDialogContent>
                            <AlertDialogHeader>
                              <AlertDialogTitle>Cancel Request?</AlertDialogTitle>
                              <AlertDialogDescription>
                                Are you sure you want to cancel this time-off request?
                              </AlertDialogDescription>
                            </AlertDialogHeader>
                            <AlertDialogFooter>
                              <AlertDialogCancel>No, keep it</AlertDialogCancel>
                              <AlertDialogAction
                                onClick={() => handleCancel(request.id)}
                              >
                                Yes, cancel
                              </AlertDialogAction>
                            </AlertDialogFooter>
                          </AlertDialogContent>
                        </AlertDialog>
                      )}
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

Gate

cd apps/web
npm run build

npm run dev
# Navigate to /dashboard/time-off
# - Should show balance cards
# - Should show requests table
# - Status filter should work

Rollback

rm apps/web/app/dashboard/time-off/page.tsx
rm apps/web/app/dashboard/time-off/my-requests-view.tsx

Lock

apps/web/app/dashboard/time-off/page.tsx
apps/web/app/dashboard/time-off/my-requests-view.tsx

Checkpoint

  • Page created
  • Balance cards display
  • Requests table displays
  • Status filter works
  • Cancel button works for pending requests
  • npm run build succeeds

Note: Add Navigation Links

Add time-off routes to your sidebar navigation component:

// In your sidebar/navigation component, add:
{
  title: 'Time Off',
  href: '/dashboard/time-off',
  icon: Calendar, // from lucide-react
  children: [
    { title: 'My Requests', href: '/dashboard/time-off' },
    { title: 'Request Time Off', href: '/dashboard/time-off/request' },
    { title: 'Approvals', href: '/dashboard/time-off/approvals' },
    { title: 'Team Calendar', href: '/dashboard/time-off/calendar' },
  ],
}

Step 90: Create Pending Approvals Page

Input

  • Step 89 complete
  • My requests page works

Constraints

  • Show only pending requests from direct reports
  • Manager-only page

Task

Create apps/web/app/dashboard/time-off/approvals/page.tsx:

import { Metadata } from 'next';
import { PendingApprovalsView } from './pending-approvals-view';

export const metadata: Metadata = {
  title: 'Pending Approvals',
};

export default function PendingApprovalsPage() {
  return (
    <div className="container py-6">
      <PendingApprovalsView />
    </div>
  );
}

Create apps/web/app/dashboard/time-off/approvals/pending-approvals-view.tsx:

'use client';

import { format } from 'date-fns';
import { CheckCircle, XCircle, User } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { usePendingApprovals } from '@/lib/queries/time-off';
import { ApproveDialog } from './approve-dialog';

export function PendingApprovalsView() {
  const { data: requests, isLoading } = usePendingApprovals();

  if (isLoading) {
    return (
      <div className="space-y-4">
        <Skeleton className="h-8 w-48" />
        <Skeleton className="h-32" />
        <Skeleton className="h-32" />
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Pending Approvals</h1>
        <Badge variant="secondary">{requests?.length || 0} pending</Badge>
      </div>

      {requests?.length === 0 ? (
        <Card>
          <CardContent className="py-12 text-center text-muted-foreground">
            <CheckCircle className="mx-auto h-12 w-12 mb-4 opacity-50" />
            <p>No pending approvals</p>
          </CardContent>
        </Card>
      ) : (
        <div className="space-y-4">
          {requests?.map((request) => {
            const initials = `${request.employee.firstName[0]}${request.employee.lastName[0]}`;

            return (
              <Card key={request.id}>
                <CardHeader>
                  <div className="flex items-center justify-between">
                    <div className="flex items-center gap-3">
                      <Avatar>
                        <AvatarImage src={request.employee.pictureUrl ?? undefined} />
                        <AvatarFallback>{initials}</AvatarFallback>
                      </Avatar>
                      <div>
                        <CardTitle className="text-base">
                          {request.employee.firstName} {request.employee.lastName}
                        </CardTitle>
                        <p className="text-sm text-muted-foreground">
                          {request.policy.name}
                        </p>
                      </div>
                    </div>
                    <Badge variant="outline">{request.days} days</Badge>
                  </div>
                </CardHeader>
                <CardContent>
                  <div className="flex items-center justify-between">
                    <div className="space-y-1">
                      <p className="text-sm">
                        {format(new Date(request.startDate), 'MMM d')} -{' '}
                        {format(new Date(request.endDate), 'MMM d, yyyy')}
                      </p>
                      {request.reason && (
                        <p className="text-sm text-muted-foreground">
                          "{request.reason}"
                        </p>
                      )}
                    </div>
                    <div className="flex gap-2">
                      <ApproveDialog
                        requestId={request.id}
                        employeeName={`${request.employee.firstName} ${request.employee.lastName}`}
                        action="REJECTED"
                      >
                        <Button variant="outline" size="sm">
                          <XCircle className="mr-2 h-4 w-4" />
                          Reject
                        </Button>
                      </ApproveDialog>
                      <ApproveDialog
                        requestId={request.id}
                        employeeName={`${request.employee.firstName} ${request.employee.lastName}`}
                        action="APPROVED"
                      >
                        <Button size="sm">
                          <CheckCircle className="mr-2 h-4 w-4" />
                          Approve
                        </Button>
                      </ApproveDialog>
                    </div>
                  </div>
                </CardContent>
              </Card>
            );
          })}
        </div>
      )}
    </div>
  );
}

Gate

cd apps/web
npm run build

npm run dev
# Navigate to /dashboard/time-off/approvals
# - Should show pending requests from direct reports
# - Approve/Reject buttons should be visible

Rollback

rm -rf apps/web/app/dashboard/time-off/approvals

Lock

apps/web/app/dashboard/time-off/approvals/page.tsx
apps/web/app/dashboard/time-off/approvals/pending-approvals-view.tsx

Checkpoint

  • Approvals page created
  • Shows pending requests
  • Employee info displayed
  • Approve/Reject buttons visible
  • npm run build succeeds

Step 91: Add Approve/Reject Actions

Input

  • Step 90 complete
  • Pending approvals page exists

Constraints

  • Rejection requires comment
  • Show success/error toast
  • Refresh list after action

Task

Create apps/web/app/dashboard/time-off/approvals/approve-dialog.tsx:

'use client';

import { useState } from 'react';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useApproveRequest } from '@/lib/queries/time-off';

interface ApproveDialogProps {
  requestId: string;
  employeeName: string;
  action: 'APPROVED' | 'REJECTED';
  children: React.ReactNode;
}

export function ApproveDialog({
  requestId,
  employeeName,
  action,
  children,
}: ApproveDialogProps) {
  const [open, setOpen] = useState(false);
  const [comment, setComment] = useState('');
  const approveRequest = useApproveRequest();

  const isReject = action === 'REJECTED';

  const handleSubmit = async () => {
    if (isReject && !comment.trim()) {
      toast.error('Please provide a reason for rejection');
      return;
    }

    try {
      await approveRequest.mutateAsync({
        requestId,
        status: action,
        comment: comment.trim() || undefined,
      });
      toast.success(
        isReject
          ? `Request from ${employeeName} rejected`
          : `Request from ${employeeName} approved`
      );
      setOpen(false);
      setComment('');
    } catch (error) {
      toast.error(
        error instanceof Error ? error.message : 'Failed to process request'
      );
    }
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {isReject ? 'Reject' : 'Approve'} Time-Off Request
          </DialogTitle>
          <DialogDescription>
            {isReject
              ? `Are you sure you want to reject ${employeeName}'s time-off request? You must provide a reason.`
              : `Approve ${employeeName}'s time-off request?`}
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-4">
          <div className="space-y-2">
            <Label htmlFor="comment">
              Comment {isReject && <span className="text-red-500">*</span>}
            </Label>
            <Textarea
              id="comment"
              placeholder={
                isReject
                  ? 'Please explain why this request is being rejected...'
                  : 'Add an optional comment...'
              }
              value={comment}
              onChange={(e) => setComment(e.target.value)}
            />
          </div>
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={() => setOpen(false)}>
            Cancel
          </Button>
          <Button
            variant={isReject ? 'destructive' : 'default'}
            onClick={handleSubmit}
            disabled={approveRequest.isPending}
          >
            {approveRequest.isPending
              ? 'Processing...'
              : isReject
              ? 'Reject Request'
              : 'Approve Request'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Gate

cd apps/web

# Install dialog if not present
npx shadcn@latest add dialog
npx shadcn@latest add label

npm run build

npm run dev
# Navigate to /dashboard/time-off/approvals
# - Click Approve → dialog appears → can approve
# - Click Reject → dialog appears → requires comment → can reject

Rollback

rm apps/web/app/dashboard/time-off/approvals/approve-dialog.tsx

Lock

apps/web/app/dashboard/time-off/approvals/approve-dialog.tsx

Checkpoint

  • Approve dialog works
  • Reject dialog requires comment
  • Toast notifications appear
  • List refreshes after action
  • npm run build succeeds

Step 92: Create Leave Calendar View

Input

  • Step 91 complete
  • Approve/reject works

Constraints

  • Show approved time-off for date range
  • Filter by department
  • Simple calendar/list view

Task

Create apps/web/lib/queries/time-off-calendar.ts:

import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

export interface CalendarEvent {
  id: string;
  startDate: string;
  endDate: string;
  days: number;
  employee: {
    id: string;
    firstName: string;
    lastName: string;
    pictureUrl: string | null;
  };
  policy: {
    name: string;
    code: string | null;
    leaveType: string;
  };
}

export function useTimeOffCalendar(
  startDate: string,
  endDate: string,
  departmentId?: string,
) {
  return useQuery({
    queryKey: ['timeoff-calendar', startDate, endDate, departmentId],
    queryFn: async (): Promise<CalendarEvent[]> => {
      const params = new URLSearchParams({
        startDate,
        endDate,
      });
      if (departmentId) {
        params.set('departmentId', departmentId);
      }
      const response = await api.get<CalendarEvent[]>(
        `/timeoff/calendar?${params}`
      );
      return response.data;
    },
    enabled: !!startDate && !!endDate,
  });
}

Create apps/web/app/dashboard/time-off/calendar/page.tsx:

import { Metadata } from 'next';
import { CalendarView } from './calendar-view';

export const metadata: Metadata = {
  title: 'Team Calendar',
};

export default function CalendarPage() {
  return (
    <div className="container py-6">
      <CalendarView />
    </div>
  );
}

Create apps/web/app/dashboard/time-off/calendar/calendar-view.tsx:

'use client';

import { useState, useMemo } from 'react';
import {
  format,
  startOfMonth,
  endOfMonth,
  startOfWeek,
  endOfWeek,
  eachDayOfInterval,
  isSameMonth,
  isWithinInterval,
  addMonths,
  subMonths,
} from 'date-fns';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';

import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { useTimeOffCalendar, type CalendarEvent } from '@/lib/queries/time-off-calendar';

const leaveTypeColors: Record<string, string> = {
  VACATION: 'bg-blue-100 text-blue-800 shadow-sm shadow-blue-200/50',
  SICK: 'bg-red-100 text-red-800 shadow-sm shadow-red-200/50',
  PERSONAL: 'bg-purple-100 text-purple-800 shadow-sm shadow-purple-200/50',
  PARENTAL: 'bg-green-100 text-green-800 shadow-sm shadow-green-200/50',
  OTHER: 'bg-gray-100 text-gray-800 shadow-sm shadow-gray-200/50',
};

export function CalendarView() {
  const [currentDate, setCurrentDate] = useState(new Date());
  const monthStart = startOfMonth(currentDate);
  const monthEnd = endOfMonth(currentDate);

  const { data: events, isLoading } = useTimeOffCalendar(
    format(monthStart, 'yyyy-MM-dd'),
    format(monthEnd, 'yyyy-MM-dd'),
  );

  // Pad calendar to start on Sunday and end on Saturday for proper grid alignment
  const calendarStart = startOfWeek(monthStart);
  const calendarEnd = endOfWeek(monthEnd);

  const days = useMemo(
    () => eachDayOfInterval({ start: calendarStart, end: calendarEnd }),
    [calendarStart, calendarEnd],
  );

  const getEventsForDay = (day: Date): CalendarEvent[] => {
    if (!events) return [];
    return events.filter((event) =>
      isWithinInterval(day, {
        start: new Date(event.startDate),
        end: new Date(event.endDate),
      }),
    );
  };

  const prevMonth = () => setCurrentDate(subMonths(currentDate, 1));
  const nextMonth = () => setCurrentDate(addMonths(currentDate, 1));
  const today = () => setCurrentDate(new Date());

  if (isLoading) {
    return (
      <div className="space-y-4">
        <Skeleton className="h-8 w-48" />
        <Skeleton className="h-[600px]" />
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Team Calendar</h1>
        <div className="flex items-center gap-2">
          <Button variant="outline" size="icon" onClick={prevMonth}>
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button variant="outline" onClick={today}>
            Today
          </Button>
          <Button variant="outline" size="icon" onClick={nextMonth}>
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
      </div>

      <Card>
        <CardHeader>
          <CardTitle className="flex items-center gap-2">
            <Calendar className="h-5 w-5" />
            {format(currentDate, 'MMMM yyyy')}
          </CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-7 gap-px bg-muted rounded-2xl overflow-hidden">
            {/* Day headers */}
            {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
              <div
                key={day}
                className="bg-background p-2 text-center text-sm font-medium text-muted-foreground"
              >
                {day}
              </div>
            ))}

            {/* Calendar grid */}
            {days.map((day) => {
              const dayEvents = getEventsForDay(day);
              const isToday =
                format(day, 'yyyy-MM-dd') === format(new Date(), 'yyyy-MM-dd');

              return (
                <div
                  key={day.toISOString()}
                  className={cn(
                    'bg-background min-h-[100px] p-2',
                    !isSameMonth(day, currentDate) && 'opacity-50',
                  )}
                >
                  <div
                    className={cn(
                      'text-sm font-medium mb-1',
                      isToday &&
                        'bg-primary text-primary-foreground w-6 h-6 rounded-full flex items-center justify-center',
                    )}
                  >
                    {format(day, 'd')}
                  </div>
                  <div className="space-y-1">
                    {dayEvents.slice(0, 3).map((event) => {
                      const initials = `${event.employee.firstName[0]}${event.employee.lastName[0]}`;
                      const colorClass =
                        leaveTypeColors[event.policy.leaveType] ||
                        leaveTypeColors.OTHER;

                      return (
                        <div
                          key={event.id}
                          className={cn(
                            'text-xs p-1 rounded border truncate',
                            colorClass,
                          )}
                          title={`${event.employee.firstName} ${event.employee.lastName} - ${event.policy.name}`}
                        >
                          {event.employee.firstName}
                        </div>
                      );
                    })}
                    {dayEvents.length > 3 && (
                      <div className="text-xs text-muted-foreground">
                        +{dayEvents.length - 3} more
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>
        </CardContent>
      </Card>

      {/* Legend */}
      <div className="flex flex-wrap gap-2">
        {Object.entries(leaveTypeColors).map(([type, color]) => (
          <Badge key={type} variant="outline" className={color}>
            {type}
          </Badge>
        ))}
      </div>

      {/* List view of current month events */}
      {events && events.length > 0 && (
        <Card>
          <CardHeader>
            <CardTitle>This Month's Time Off</CardTitle>
          </CardHeader>
          <CardContent>
            <div className="space-y-3">
              {events.map((event) => {
                const initials = `${event.employee.firstName[0]}${event.employee.lastName[0]}`;

                return (
                  <div
                    key={event.id}
                    className="flex items-center justify-between p-4 rounded-2xl bg-gray-50 hover:bg-gray-100 transition-colors"
                  >
                    <div className="flex items-center gap-3">
                      <Avatar className="h-8 w-8">
                        <AvatarImage src={event.employee.pictureUrl ?? undefined} />
                        <AvatarFallback className="text-xs">
                          {initials}
                        </AvatarFallback>
                      </Avatar>
                      <div>
                        <p className="font-medium text-sm">
                          {event.employee.firstName} {event.employee.lastName}
                        </p>
                        <p className="text-xs text-muted-foreground">
                          {event.policy.name}
                        </p>
                      </div>
                    </div>
                    <div className="text-right">
                      <p className="text-sm">
                        {format(new Date(event.startDate), 'MMM d')} -{' '}
                        {format(new Date(event.endDate), 'MMM d')}
                      </p>
                      <p className="text-xs text-muted-foreground">
                        {event.days} days
                      </p>
                    </div>
                  </div>
                );
              })}
            </div>
          </CardContent>
        </Card>
      )}
    </div>
  );
}

Gate

cd apps/web
npm run build

npm run dev
# Navigate to /dashboard/time-off/calendar
# - Should show calendar grid
# - Should show approved time-off events
# - Navigation between months works
# - Legend displays

Rollback

rm apps/web/lib/queries/time-off-calendar.ts
rm -rf apps/web/app/dashboard/time-off/calendar

Lock

apps/web/lib/queries/time-off-calendar.ts
apps/web/app/dashboard/time-off/calendar/page.tsx
apps/web/app/dashboard/time-off/calendar/calendar-view.tsx

Checkpoint

  • Calendar page created
  • Month navigation works
  • Events display on calendar
  • List view shows events
  • Leave type colors work
  • npm run build succeeds

Phase 05 Complete Checklist

Database

  • All 5 enums added (LeaveType, AccrualType, RequestStatus, PolicyStatus, HalfDayPart)
  • TimeOffPolicy model with Tenant relation
  • TimeOffBalance model with unique constraint
  • TimeOffRequest model with indexes
  • Migration ran successfully

Backend

  • TimeOffPolicyRepository with CRUD
  • TimeOffPolicyService with validation
  • TimeOffPolicyController with role guards
  • TimeOffBalanceRepository with calculations
  • TimeOffBalanceService with available balance
  • TimeOffRequestRepository with filtering
  • TimeOffRequestService with business rules
  • TimeOffRequestController with all endpoints
  • TimeOffModule registered

Frontend

  • Query hooks created
  • Request form with date pickers
  • My requests page with balances
  • Pending approvals page
  • Approve/reject dialogs
  • Calendar view with events

Business Rules

  • Balance: Available = Entitled + CarryOver - Used - Pending + Adjustment
  • Cannot approve own requests
  • Manager can only approve direct reports
  • Rejection requires comment
  • No overlapping requests

Manual QA Verification

Before marking Phase 05 complete, manually verify these scenarios work correctly:

Submit Flow:

  • Submit a time-off request → pending balance increases by requested days
  • Submit overlapping request → should be rejected with error

Approval Flow:

  • Manager approves request → used balance increases, pending decreases
  • Manager rejects request → pending balance decreases
  • Try to approve own request → should fail with "cannot approve own request"
  • Non-manager tries to approve → should fail with "only direct reports"

Cancel Flow:

  • Employee cancels pending request → pending balance decreases
  • Try to cancel approved request → should fail

Calendar:

  • Calendar shows approved requests overlapping the visible date range
  • Cancelled/rejected requests don't appear on calendar

Balance Formula Verification:

  • Available = Entitled + CarryOver - Used - Pending + Adjustment
  • Set known values and verify calculation is correct

Quick Reference: API Endpoints

# Policies
GET    /api/v1/timeoff/policies          # List all (with status filter)
GET    /api/v1/timeoff/policies/active   # Active policies only
GET    /api/v1/timeoff/policies/:id      # Get one
POST   /api/v1/timeoff/policies          # Create (HR_ADMIN+)
PUT    /api/v1/timeoff/policies/:id      # Update (HR_ADMIN+)
DELETE /api/v1/timeoff/policies/:id      # Archive (HR_ADMIN+)

# Requests
GET    /api/v1/timeoff/requests          # My requests
GET    /api/v1/timeoff/requests/pending-approvals  # For manager
GET    /api/v1/timeoff/requests/:id      # Get one
POST   /api/v1/timeoff/requests          # Submit
PATCH  /api/v1/timeoff/requests/:id      # Approve/reject
DELETE /api/v1/timeoff/requests/:id      # Cancel

# Balances
GET    /api/v1/timeoff/balances          # My balances
GET    /api/v1/timeoff/balances/:employeeId  # Employee balances

# Calendar
GET    /api/v1/timeoff/calendar          # Team calendar

Known Limitations (MVP)

The following are intentional simplifications for MVP:

  1. Cross-year requests - Requests spanning Dec 28 - Jan 3 use start date's year for balance deduction. Future enhancement: split days proportionally between years.

  2. No holiday calendar - Business days calculation counts weekends only, not company holidays. All calendar days between start/end are counted minus weekends.

  3. No accrual engine - Balances are manually set or adjusted, not auto-calculated monthly based on hire date or accrual rules.

  4. Limited half-day support - We support a simple 0.5-day option via halfDay + halfDayPart (morning/afternoon), but no finer granularity (e.g., quarter days) and no special handling in the calendar UI.

  5. No delegation - Managers cannot delegate approval authority when on vacation.

  6. No carry-over rules - Carry-over balances are manually entered; no automatic calculation of max carry-over limits.

  7. No waitlist/capacity - No limit on how many people can be off on the same day; no "department capacity" rules.

These limitations can be addressed in future phases or as add-on features.



Step 93: Add Half-Day Request Support (TO-06)

Input

  • Step 92 complete
  • Time-off request flow exists

Constraints

  • Add half-day option to request form
  • Calculate 0.5 days for half-day requests
  • ONLY modify existing request form

Task

1. Update TimeOffRequest model (if not already done) in packages/database/prisma/schema.prisma:

model TimeOffRequest {
  // ... existing fields ...
  halfDay       Boolean     @default(false)
  halfDayPart   HalfDayPart?  // MORNING or AFTERNOON
}

enum HalfDayPart {
  MORNING
  AFTERNOON
}

2. Run migration if needed:

cd packages/database
npm run db:push
npm run db:generate

3. Update CreateTimeOffRequestDto in apps/api/src/time-off/dto/create-request.dto.ts:

import { IsOptional, IsBoolean, IsEnum } from 'class-validator';

// Add to existing DTO
@IsOptional()
@IsBoolean()
halfDay?: boolean;

@IsOptional()
@IsEnum(['MORNING', 'AFTERNOON'])
halfDayPart?: 'MORNING' | 'AFTERNOON';

4. Update Request Form - Add half-day toggle in apps/web/app/dashboard/time-off/request-form.tsx:

// Add state for half day
const [halfDay, setHalfDay] = useState(false);
const [halfDayPart, setHalfDayPart] = useState<'MORNING' | 'AFTERNOON'>('MORNING');

// Add to form JSX (after date selectors)
<div className="space-y-2">
  <div className="flex items-center space-x-2">
    <Checkbox
      id="halfDay"
      checked={halfDay}
      onCheckedChange={(checked) => {
        setHalfDay(!!checked);
        // If half day, set end date to start date
        if (checked && startDate) {
          setEndDate(startDate);
        }
      }}
    />
    <Label htmlFor="halfDay">Half-day request</Label>
  </div>

  {halfDay && (
    <div className="ml-6">
      <RadioGroup
        value={halfDayPart}
        onValueChange={(value) => setHalfDayPart(value as 'MORNING' | 'AFTERNOON')}
      >
        <div className="flex items-center space-x-2">
          <RadioGroupItem value="MORNING" id="morning" />
          <Label htmlFor="morning">Morning (until 12pm)</Label>
        </div>
        <div className="flex items-center space-x-2">
          <RadioGroupItem value="AFTERNOON" id="afternoon" />
          <Label htmlFor="afternoon">Afternoon (from 12pm)</Label>
        </div>
      </RadioGroup>
    </div>
  )}
</div>

// Update handleSubmit to include half-day fields
const handleSubmit = async () => {
  await submitRequest.mutateAsync({
    policyId,
    startDate: format(startDate, 'yyyy-MM-dd'),
    endDate: format(endDate, 'yyyy-MM-dd'),
    reason,
    halfDay,
    halfDayPart: halfDay ? halfDayPart : undefined,
  });
};

5. Update days calculation in TimeOffRequestService:

// In calculateDays method
calculateDays(startDate: Date, endDate: Date, halfDay: boolean): number {
  const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
  const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;

  // Exclude weekends (simplified)
  let businessDays = 0;
  const current = new Date(startDate);
  while (current <= endDate) {
    const day = current.getDay();
    if (day !== 0 && day !== 6) {
      businessDays++;
    }
    current.setDate(current.getDate() + 1);
  }

  return halfDay ? 0.5 : businessDays;
}

Gate

# Test half-day request
curl -X POST http://localhost:3001/api/v1/timeoff/requests \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -d '{
    "policyId": "...",
    "startDate": "2024-12-20",
    "endDate": "2024-12-20",
    "halfDay": true,
    "halfDayPart": "MORNING"
  }'
# Should return request with days: 0.5

Checkpoint

  • Half-day checkbox in form
  • Morning/Afternoon selector appears when checked
  • Request calculates 0.5 days
  • Balance updated correctly
  • Type "GATE 93 PASSED" to continue

Step 94: Add Balance Adjustment Endpoint (TO-10)

Input

  • Step 93 complete
  • TimeOffBalance model exists

Constraints

  • Only HR_ADMIN can adjust balances
  • Track adjustment reason
  • ONLY add adjustment endpoint

Task

1. Create Adjust Balance DTO at apps/api/src/time-off/dto/adjust-balance.dto.ts:

import { IsString, IsNumber, IsOptional, Min, Max } from 'class-validator';

export class AdjustBalanceDto {
  @IsString()
  employeeId: string;

  @IsString()
  policyId: string;

  @IsNumber()
  @Min(-365)
  @Max(365)
  adjustmentDays: number;

  @IsOptional()
  @IsString()
  reason?: string;
}

2. Add Adjustment Method to TimeOffBalanceService:

// Add to apps/api/src/time-off/time-off-balance.service.ts

async adjustBalance(
  tenantId: string,
  employeeId: string,
  policyId: string,
  adjustmentDays: number,
  reason?: string,
  adjustedBy?: string,
) {
  // Verify employee exists
  const employee = await this.prisma.employee.findFirst({
    where: { id: employeeId, tenantId, deletedAt: null },
  });
  if (!employee) {
    throw new NotFoundException(`Employee with ID ${employeeId} not found`);
  }

  // Get or create balance
  let balance = await this.prisma.timeOffBalance.findUnique({
    where: { employeeId_policyId: { employeeId, policyId } },
  });

  if (!balance) {
    // Create balance if doesn't exist
    balance = await this.prisma.timeOffBalance.create({
      data: {
        employeeId,
        policyId,
        year: new Date().getFullYear(),
        entitledDays: 0,
        usedDays: 0,
        pendingDays: 0,
        adjustmentDays: adjustmentDays,
        adjustmentReason: reason,
        adjustmentDate: new Date(),
        adjustedById: adjustedBy,
      },
    });
  } else {
    // Update existing balance
    balance = await this.prisma.timeOffBalance.update({
      where: { id: balance.id },
      data: {
        adjustmentDays: { increment: adjustmentDays },
        adjustmentReason: reason,
        adjustmentDate: new Date(),
        adjustedById: adjustedBy,
      },
    });
  }

  return balance;
}

3. Add Endpoint to TimeOffController:

// Add to apps/api/src/time-off/time-off.controller.ts

@Post('balances/adjust')
@RequireRoles('HR_ADMIN')
async adjustBalance(
  @TenantId() tenantId: string,
  @Body() dto: AdjustBalanceDto,
  @CurrentUser('id') userId: string,
) {
  const data = await this.balanceService.adjustBalance(
    tenantId,
    dto.employeeId,
    dto.policyId,
    dto.adjustmentDays,
    dto.reason,
    userId,
  );
  return { data, error: null };
}

4. Create Balance Adjustment UI at apps/web/app/dashboard/time-off/admin/adjust-balance.tsx:

'use client';

import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { useAdjustBalance } from '@/lib/queries/time-off';

interface AdjustBalanceDialogProps {
  employees: Array<{ id: string; firstName: string; lastName: string }>;
  policies: Array<{ id: string; name: string }>;
  children: React.ReactNode;
}

export function AdjustBalanceDialog({
  employees,
  policies,
  children,
}: AdjustBalanceDialogProps) {
  const [open, setOpen] = useState(false);
  const [employeeId, setEmployeeId] = useState('');
  const [policyId, setPolicyId] = useState('');
  const [adjustmentDays, setAdjustmentDays] = useState('0');
  const [reason, setReason] = useState('');

  const adjustBalance = useAdjustBalance();

  const handleSubmit = async () => {
    if (!employeeId || !policyId) {
      toast.error('Please select an employee and policy');
      return;
    }

    const days = parseFloat(adjustmentDays);
    if (isNaN(days) || days === 0) {
      toast.error('Please enter a valid adjustment amount');
      return;
    }

    try {
      await adjustBalance.mutateAsync({
        employeeId,
        policyId,
        adjustmentDays: days,
        reason: reason.trim() || undefined,
      });
      toast.success('Balance adjusted successfully');
      setOpen(false);
      resetForm();
    } catch (error) {
      toast.error('Failed to adjust balance');
    }
  };

  const resetForm = () => {
    setEmployeeId('');
    setPolicyId('');
    setAdjustmentDays('0');
    setReason('');
  };

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>{children}</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Adjust Time-Off Balance</DialogTitle>
          <DialogDescription>
            Add or subtract days from an employee's time-off balance.
            Use negative numbers to subtract days.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-4">
          <div className="space-y-2">
            <Label>Employee</Label>
            <Select value={employeeId} onValueChange={setEmployeeId}>
              <SelectTrigger>
                <SelectValue placeholder="Select employee" />
              </SelectTrigger>
              <SelectContent>
                {employees.map((emp) => (
                  <SelectItem key={emp.id} value={emp.id}>
                    {emp.firstName} {emp.lastName}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          <div className="space-y-2">
            <Label>Policy</Label>
            <Select value={policyId} onValueChange={setPolicyId}>
              <SelectTrigger>
                <SelectValue placeholder="Select policy" />
              </SelectTrigger>
              <SelectContent>
                {policies.map((policy) => (
                  <SelectItem key={policy.id} value={policy.id}>
                    {policy.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          <div className="space-y-2">
            <Label>Adjustment Days</Label>
            <Input
              type="number"
              step="0.5"
              value={adjustmentDays}
              onChange={(e) => setAdjustmentDays(e.target.value)}
              placeholder="e.g., 2 or -1.5"
            />
            <p className="text-xs text-muted-foreground">
              Use positive numbers to add days, negative to subtract.
            </p>
          </div>

          <div className="space-y-2">
            <Label>Reason (optional)</Label>
            <Textarea
              value={reason}
              onChange={(e) => setReason(e.target.value)}
              placeholder="Reason for adjustment..."
            />
          </div>
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={() => setOpen(false)}>
            Cancel
          </Button>
          <Button onClick={handleSubmit} disabled={adjustBalance.isPending}>
            {adjustBalance.isPending ? 'Adjusting...' : 'Adjust Balance'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

5. Add Query Hook to apps/web/lib/queries/time-off.ts:

export function useAdjustBalance() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: {
      employeeId: string;
      policyId: string;
      adjustmentDays: number;
      reason?: string;
    }) => {
      const response = await api.post('/timeoff/balances/adjust', data);
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['timeoff-balances'] });
    },
  });
}

Gate

# Test balance adjustment
curl -X POST http://localhost:3001/api/v1/timeoff/balances/adjust \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -H "x-system-role: HR_ADMIN" \
  -d '{
    "employeeId": "...",
    "policyId": "...",
    "adjustmentDays": 2.5,
    "reason": "Bonus days for Q4 performance"
  }'
# Should return updated balance

Checkpoint

  • POST /balances/adjust endpoint works
  • Only HR_ADMIN can access
  • Adjustment updates balance correctly
  • UI dialog works
  • Type "GATE 94 PASSED" to continue

Locked Files After Phase 05

  • All Phase 04 locks, plus:
  • packages/database/prisma/schema.prisma (time-off models)
  • apps/api/src/time-off/*
  • apps/web/app/dashboard/time-off/*
  • apps/web/lib/queries/time-off.ts
  • apps/web/lib/queries/time-off-calendar.ts

Phase Completion Checklist (MANDATORY)

BEFORE MOVING TO NEXT PHASE

Complete ALL items before proceeding. Do NOT skip any step.

1. Gate Verification

  • All step gates passed
  • Time-off policies CRUD working
  • Time-off requests working
  • Approval workflow functional
  • Balance management working

2. Update PROJECT_STATE.md

- Mark Phase 05 as COMPLETED with timestamp
- Update "Current Phase" to Phase 06
- Add session log entry

3. Update WHAT_EXISTS.md

## Database Models
- TimeOffPolicy, TimeOffRequest, TimeOffBalance

## API Endpoints
- /api/v1/timeoff/policies/*
- /api/v1/timeoff/requests/*
- /api/v1/timeoff/balances/*

## Frontend Routes
- /dashboard/timeoff/*

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 05 - Time Off"
git tag phase-05-time-off

Next Phase

After verification, proceed to Phase 06: Document Management


Last Updated: 2025-11-30

On this page

Phase 05: Time-Off SystemStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderStep 73: Add LeaveType EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 74: Add AccrualType EnumInputConstraintsTaskGateRollbackLockCheckpointStep 75: Add RequestStatus EnumInputConstraintsTaskGateRollbackLockCheckpointStep 76: Add PolicyStatus and HalfDayPart EnumsInputConstraintsTaskGateRollbackLockCheckpointStep 77: Add TimeOffPolicy ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 78: Add TimeOffBalance ModelInputConstraintsTaskGateRollbackLockCheckpointStep 79: Add TimeOffRequest Model and All RelationsInputConstraintsTaskGateRollbackLockCheckpointStep 80: Run MigrationInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 81: Create TimeOffPolicyRepositoryInputConstraintsTaskGateRollbackLockCheckpointStep 82: Create TimeOffPolicyServiceInputConstraintsTaskGateRollbackLockCheckpointStep 83: Create TimeOffPolicyControllerInputConstraintsTaskGateRollbackLockCheckpointStep 84: Create TimeOffBalanceServiceInputConstraintsTaskGateRollbackLockCheckpointStep 85: Create TimeOffRequestRepositoryInputConstraintsTaskGateRollbackLockCheckpointStep 86: Create TimeOffRequestServiceInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 87: Create TimeOffRequestControllerInputConstraintsTaskGateRollbackLockCheckpointStep 88: Create Time-Off Request FormInputConstraintsTaskGateRollbackLockCheckpointStep 89: Create My Requests PageInputConstraintsTaskGateRollbackLockCheckpointStep 90: Create Pending Approvals PageInputConstraintsTaskGateRollbackLockCheckpointStep 91: Add Approve/Reject ActionsInputConstraintsTaskGateRollbackLockCheckpointStep 92: Create Leave Calendar ViewInputConstraintsTaskGateRollbackLockCheckpointPhase 05 Complete ChecklistDatabaseBackendFrontendBusiness RulesManual QA VerificationQuick Reference: API EndpointsKnown Limitations (MVP)Step 93: Add Half-Day Request Support (TO-06)InputConstraintsTaskGateCheckpointStep 94: Add Balance Adjustment Endpoint (TO-10)InputConstraintsTaskGateCheckpointLocked Files After Phase 05Phase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase