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.
| Attribute | Value |
|---|---|
| Steps | 73-92 |
| Estimated Time | 10-14 hours |
| Dependencies | Phase 04 complete (TanStack Query, API helper available) |
| Completion Gate | Employees can request time-off, managers can approve/reject, balances update correctly |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 73 | Add LeaveType enum | 10 min |
| 74 | Add AccrualType enum | 10 min |
| 75 | Add RequestStatus enum | 10 min |
| 76 | Add PolicyStatus, HalfDayPart enums | 10 min |
| 77 | Add TimeOffPolicy model | 20 min |
| 78 | Add TimeOffBalance model | 15 min |
| 79 | Add TimeOffRequest model | 20 min |
| 80 | Run migration | 15 min |
| 81 | Create TimeOffPolicyRepository | 25 min |
| 82 | Create TimeOffPolicyService | 20 min |
| 83 | Create TimeOffPolicyController | 25 min |
| 84 | Create TimeOffBalanceService | 30 min |
| 85 | Create TimeOffRequestRepository | 25 min |
| 86 | Create TimeOffRequestService | 45 min |
| 87 | Create TimeOffRequestController | 30 min |
| 88 | Create time-off request form | 35 min |
| 89 | Create my requests page | 30 min |
| 90 | Create pending approvals page | 30 min |
| 91 | Add approve/reject actions | 25 min |
| 92 | Create leave calendar view | 40 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 valuesCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum already exists | Duplicate definition | Remove duplicate |
Invalid enum value | Typo in value | Check spelling matches spec |
Rollback
# Remove the enum block from schema.prismaLock
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 blockLock
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 blockLock
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 5Rollback
# Remove both enum blocksLock
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 formatyet - 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
| Error | Cause | Fix |
|---|---|---|
Unknown type TimeOffRequest | Relations added too early | Wait for Step 79 to add relations |
Rollback
# Remove TimeOffPolicy model and Tenant relationLock
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 formatyet - 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 modelLock
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 existRollback
# Remove all time-off models and their relations from Tenant/EmployeeLock
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 tablesGate
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
| Error | Cause | Fix |
|---|---|---|
Database connection failed | Docker not running | Start PostgreSQL container |
Relation does not exist | Tenant/Employee model missing | Ensure Phase 01-02 migrations ran |
Enum already exists | Re-running migration | Use prisma db push --force-reset (dev only) |
Rollback
# In development: prisma db push --force-reset
# In production: Create down migrationLock
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/dtoCreate 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 errorsRollback
rm apps/api/src/time-off/repositories/time-off-policy.repository.tsLock
apps/api/src/time-off/repositories/time-off-policy.repository.tsCheckpoint
- 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 buildRollback
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.tsLock
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.tsCheckpoint
- 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 buildRollback
rm apps/api/src/time-off/controllers/time-off-policy.controller.tsLock
apps/api/src/time-off/controllers/time-off-policy.controller.tsCheckpoint
- 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 buildRollback
rm apps/api/src/time-off/repositories/time-off-balance.repository.ts
rm apps/api/src/time-off/services/time-off-balance.service.tsLock
apps/api/src/time-off/repositories/time-off-balance.repository.ts
apps/api/src/time-off/services/time-off-balance.service.tsCheckpoint
- 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 buildRollback
rm apps/api/src/time-off/repositories/time-off-request.repository.tsLock
apps/api/src/time-off/repositories/time-off-request.repository.tsCheckpoint
- 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 buildCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot approve own request | Approver is the employee | Different user must approve |
Insufficient balance | Not enough days available | Check balance calculation |
Rejection requires comment | No comment provided | Include 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.tsLock
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.tsCheckpoint
- 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
CurrentUserdecorator was created in Phase 01 (Step 23). Import it from../../auth/current-user.decorator. It extracts user info from the authenticated request, includingemployeeIdfrom 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 policiesRollback
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.tsLock
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 errorsRollback
rm -rf apps/web/app/dashboard/time-off/request
rm apps/web/lib/queries/time-off.tsLock
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.tsxCheckpoint
- 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 workRollback
rm apps/web/app/dashboard/time-off/page.tsx
rm apps/web/app/dashboard/time-off/my-requests-view.tsxLock
apps/web/app/dashboard/time-off/page.tsx
apps/web/app/dashboard/time-off/my-requests-view.tsxCheckpoint
- 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 visibleRollback
rm -rf apps/web/app/dashboard/time-off/approvalsLock
apps/web/app/dashboard/time-off/approvals/page.tsx
apps/web/app/dashboard/time-off/approvals/pending-approvals-view.tsxCheckpoint
- 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 rejectRollback
rm apps/web/app/dashboard/time-off/approvals/approve-dialog.tsxLock
apps/web/app/dashboard/time-off/approvals/approve-dialog.tsxCheckpoint
- 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 displaysRollback
rm apps/web/lib/queries/time-off-calendar.ts
rm -rf apps/web/app/dashboard/time-off/calendarLock
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.tsxCheckpoint
- 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 calendarKnown Limitations (MVP)
The following are intentional simplifications for MVP:
-
Cross-year requests - Requests spanning Dec 28 - Jan 3 use start date's year for balance deduction. Future enhancement: split days proportionally between years.
-
No holiday calendar - Business days calculation counts weekends only, not company holidays. All calendar days between start/end are counted minus weekends.
-
No accrual engine - Balances are manually set or adjusted, not auto-calculated monthly based on hire date or accrual rules.
-
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. -
No delegation - Managers cannot delegate approval authority when on vacation.
-
No carry-over rules - Carry-over balances are manually entered; no automatic calculation of max carry-over limits.
-
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:generate3. 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.5Checkpoint
- 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 balanceCheckpoint
- 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.tsapps/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 entry3. 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-offNext Phase
After verification, proceed to Phase 06: Document Management
Last Updated: 2025-11-30