Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 02: Employee Entity

Complete Employee CRUD with tenant isolation and soft delete

Phase 02: Employee Entity

Goal: Implement complete Employee management with tenant-isolated CRUD operations.

Architecture Note

This phase uses the Service pattern because it includes business logic (duplicate email check, pagination formatting). For simpler CRUD without business logic, use the Controller → Prisma pattern directly (see patterns.mdx).

AttributeValue
Steps25-42
Estimated Time6-8 hours
DependenciesPhase 01 complete (auth working, tenant context available)
Completion GateEmployee CRUD works via API and frontend, data is tenant-isolated, soft delete implemented

Step Timing Estimates

StepTaskEst. Time
25Add EmploymentType enum5 min
26Add WorkMode enum5 min
27Add EmployeeStatus enum5 min
28Add Employee model10 min
29Link User to Employee5 min
30Run migration5 min
31Create CreateEmployeeDto15 min
32Create UpdateEmployeeDto5 min
33Create EmployeeRepository20 min
34Create EmployeeService20 min
35Create EmployeeController15 min
36Register EmployeeModule10 min
37Test Employee CRUD via curl15 min
38Create employee list page30 min
39Create employee form component30 min
40Create new employee page20 min
41Create edit employee page20 min
42Add delete functionality15 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • Employee model with all core fields in database
  • User ↔ Employee optional 1:1 relationship
  • Backend CRUD (DTOs, Repository, Service, Controller)
  • Frontend pages (list, create, edit, delete)
  • Tenant isolation on all employee queries
  • Soft delete via EmployeeStatus (not hard delete)

What This Phase Does NOT Include

  • Organization structure (Department, Team, Manager) - that's Phase 03
  • Manager assignment or reporting structure - Phase 03
  • Time-off or documents - Phase 05/06
  • Skills, tags, or custom fields - Phase 07
  • Complex filtering or search - Future enhancement

Known Limitations (MVP)

Search Architecture

Current Implementation:

  • Employee search: GET /api/v1/employees?search=query (Phase 02)
  • Document search: GET /api/v1/documents?search=query (Phase 06)
  • AI-powered search: Natural language via chat interface (Phase 09)

Not included in MVP:

  • Unified global search bar searching all entities simultaneously
  • Cross-entity search results (employees + documents + teams in one query)
  • Dedicated search UI component with combined results

Future Enhancement: Create GlobalSearch component that queries multiple APIs in parallel and displays categorized results.

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Generic CRUD base classes (explicit repositories are clearer)
  • Complex ORM abstractions (simple Prisma queries)
  • Org relations (Department/Team/Manager) - Phase 03
  • Hard delete (use EmployeeStatus.TERMINATED)
  • Form validation libraries beyond class-validator

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


Step 25: Add EmploymentType Enum

Input

  • Phase 01 complete
  • Database running with Tenant, User, Auth.js tables
  • Schema at: packages/database/prisma/schema.prisma

Constraints

  • DO NOT add any models yet (just enum)
  • DO NOT run migrations yet
  • DO NOT add other enums yet (one step at a time)
  • ONLY modify: packages/database/prisma/schema.prisma

Task

Add EmploymentType enum to schema.prisma (after existing enums):

// Add after UserStatus enum

enum EmploymentType {
  FULL_TIME
  PART_TIME
  CONTRACTOR
  INTERN
  TEMPORARY
}

Gate

cat packages/database/prisma/schema.prisma | grep -A 6 "enum EmploymentType"
# Should show: FULL_TIME, PART_TIME, CONTRACTOR, INTERN, TEMPORARY

Common Errors

ErrorCauseFix
Enum not foundNot added to schemaAdd enum definition
Duplicate enumEnum already existsRemove duplicate

Rollback

# Remove EmploymentType enum from schema.prisma manually

Lock

  • No files locked yet (schema not pushed)

Checkpoint

Before proceeding to Step 26:

  • EmploymentType enum in schema.prisma
  • Contains all 5 values
  • Type "GATE 25 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma

Step 26: Add WorkMode Enum

Input

  • Step 25 complete
  • EmploymentType enum exists

Constraints

  • DO NOT add Employee model yet
  • DO NOT run migrations yet
  • ONLY modify: packages/database/prisma/schema.prisma

Task

Add WorkMode enum to schema.prisma (after EmploymentType):

enum WorkMode {
  ONSITE
  REMOTE
  HYBRID
}

Gate

cat packages/database/prisma/schema.prisma | grep -A 4 "enum WorkMode"
# Should show: ONSITE, REMOTE, HYBRID

Common Errors

ErrorCauseFix
Enum not foundNot added to schemaAdd enum definition

Rollback

# Remove WorkMode enum from schema.prisma manually

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 27:

  • WorkMode enum in schema.prisma
  • Contains all 3 values
  • Type "GATE 26 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma

Step 27: Add EmployeeStatus Enum

Input

  • Step 26 complete
  • WorkMode enum exists

Constraints

  • DO NOT add Employee model yet
  • DO NOT run migrations yet
  • ONLY modify: packages/database/prisma/schema.prisma

Task

Add EmployeeStatus enum to schema.prisma (after WorkMode):

enum EmployeeStatus {
  ACTIVE
  INACTIVE
  ON_LEAVE
  TERMINATED
}

Gate

cat packages/database/prisma/schema.prisma | grep -A 5 "enum EmployeeStatus"
# Should show: ACTIVE, INACTIVE, ON_LEAVE, TERMINATED

Common Errors

ErrorCauseFix
Enum not foundNot added to schemaAdd enum definition

Rollback

# Remove EmployeeStatus enum from schema.prisma manually

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 28:

  • EmployeeStatus enum in schema.prisma
  • Contains all 4 values
  • Type "GATE 27 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma

Step 28: Add Employee Model (Full Schema)

Input

  • Step 27 complete
  • All 3 employee-related enums exist

Constraints

  • DO NOT add User relation yet (that's Step 29)
  • DO NOT add org relations (Department, Team, Manager) - that's Phase 03
  • DO NOT run migrations yet
  • ONLY modify: packages/database/prisma/schema.prisma

Task

Add Employee model to schema.prisma (after enums, before Auth.js models):

// Phase 02: Employee
model Employee {
  id             String   @id @default(cuid())
  tenantId       String
  employeeNumber String?

  // Personal Info
  firstName  String
  lastName   String
  email      String
  phone      String?
  pictureUrl String?

  // Job Information
  jobTitle  String?
  jobFamily String?
  jobLevel  String?

  // Employment Details
  employmentType  EmploymentType @default(FULL_TIME)
  workMode        WorkMode       @default(ONSITE)
  status          EmployeeStatus @default(ACTIVE)
  hireDate        DateTime?
  terminationDate DateTime?

  // Metadata
  customFields Json     @default("{}")
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  // Relations
  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@unique([tenantId, email])
  @@unique([tenantId, employeeNumber])
  @@index([tenantId, status])
  @@map("employees")
}

Also add to Tenant model:

model Tenant {
  // ... existing fields ...

  users     User[]
  employees Employee[]  // ADD THIS LINE

  @@map("tenants")
}

Gate

cat packages/database/prisma/schema.prisma | grep -A 35 "model Employee"
# Should show Employee model with all fields
# Should show: tenantId, firstName, lastName, email, etc.
# Should show: @@unique([tenantId, email])
# Should show: @@unique([tenantId, employeeNumber])

Common Errors

ErrorCauseFix
Unknown type "EmploymentType"Enum not definedCheck Step 25 complete
Unknown type "Tenant"Tenant model missingVerify Phase 01 schema
Field "employees" references missing modelEmployee after TenantMove Employee model definition

Rollback

# Remove Employee model and `employees Employee[]` from Tenant

Lock

  • No files locked yet (schema not pushed)

Checkpoint

Before proceeding to Step 29:

  • Employee model has all fields from database-schema.mdx
  • Tenant relation defined
  • Both unique constraints present
  • Index on [tenantId, status]
  • Type "GATE 28 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma

Input

  • Step 28 complete
  • Employee model exists

Constraints

  • DO NOT change any Employee fields
  • DO NOT run migrations yet
  • ONLY modify: User model in packages/database/prisma/schema.prisma

Task

Update User model to add optional Employee relation:

model User {
  id            String     @id @default(cuid())
  tenantId      String?
  email         String
  emailVerified DateTime?
  name          String?
  image         String?
  systemRole    SystemRole @default(EMPLOYEE)
  status        UserStatus @default(ACTIVE)
  employeeId    String?    @unique  // ADD THIS LINE
  lastLoginAt   DateTime?
  createdAt     DateTime   @default(now())
  updatedAt     DateTime   @updatedAt

  tenant   Tenant?   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  employee Employee? @relation(fields: [employeeId], references: [id])  // ADD THIS LINE
  accounts Account[]
  sessions Session[]

  @@unique([email])
  @@index([tenantId])
  @@map("users")
}

Update Employee model to add User back-reference:

model Employee {
  // ... existing fields ...

  // Relations
  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  user   User?  // ADD THIS LINE - back-reference from User.employee

  // ... existing constraints ...
}

Gate

# Validate schema compiles
cd packages/database && npx prisma validate
# Should succeed with no errors

# Check User has employeeId
cat packages/database/prisma/schema.prisma | grep "employeeId"
# Should show: employeeId String? @unique

Common Errors

ErrorCauseFix
Prisma validation errorMissing back-referenceAdd user User? to Employee
Ambiguous relationMultiple relations without nameAdd explicit relation names if needed

Rollback

# Remove employeeId from User
# Remove user from Employee

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 30:

  • User.employeeId exists (String? @unique)
  • User.employee relation exists
  • Employee.user back-reference exists
  • npx prisma validate succeeds
  • Type "GATE 29 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma

Step 30: Run Migration

Input

  • Step 29 complete
  • Schema validates successfully
  • PostgreSQL running

Constraints

  • DO NOT modify schema after push
  • DO NOT add seed data yet
  • ONLY run: database commands

Task

cd packages/database

# Push schema to database
npm run db:push

# Regenerate Prisma client
npm run db:generate

# Verify tables exist
npm run db:studio

Gate

# Check Employee table exists
cd packages/database && npx prisma db execute --stdin <<< "SELECT table_name FROM information_schema.tables WHERE table_name = 'employees';"
# Should return: employees

# Or open Prisma Studio and verify employees table is visible
npm run db:studio

Common Errors

ErrorCauseFix
P1001: Can't reach databasePostgreSQL not runningdocker compose up -d
Unique constraint failedData conflictsdocker compose down -v && docker compose up -d
Migration failedSchema syntax errorFix schema, re-run push

Rollback

# Reset database completely
docker compose down -v
docker compose up -d
cd packages/database
git checkout -- prisma/schema.prisma
npm run db:push

Lock

After this step, these files are locked:

  • packages/database/prisma/schema.prisma (Employee model, enums)

Checkpoint

Before proceeding to Step 31:

  • npm run db:push succeeded
  • npm run db:generate succeeded
  • Prisma Studio shows employees table
  • Users table has employeeId column
  • Type "GATE 30 PASSED" to continue

Files Created/Modified This Step

ActionFile
Modifiedpackages/database/prisma/schema.prisma (locked)
Generatedpackages/database/node_modules/.prisma/client/

Step 31: Create CreateEmployeeDto

Input

  • Step 30 complete
  • Employee model exists in database
  • NestJS API running

Constraints

  • DO NOT create service or controller yet
  • DO NOT add org-related fields (departmentId, managerId)
  • ONLY create: DTO file with class-validator decorators

Task

cd apps/api

# Install validation dependencies
npm install class-validator class-transformer

# Create DTO directory
mkdir -p src/employees/dto

Create apps/api/src/employees/dto/create-employee.dto.ts:

import {
  IsString,
  IsEmail,
  IsOptional,
  IsEnum,
  IsDateString,
  IsObject,
  MinLength,
  MaxLength,
} from 'class-validator';

export enum EmploymentType {
  FULL_TIME = 'FULL_TIME',
  PART_TIME = 'PART_TIME',
  CONTRACTOR = 'CONTRACTOR',
  INTERN = 'INTERN',
  TEMPORARY = 'TEMPORARY',
}

export enum WorkMode {
  ONSITE = 'ONSITE',
  REMOTE = 'REMOTE',
  HYBRID = 'HYBRID',
}

export enum EmployeeStatus {
  ACTIVE = 'ACTIVE',
  INACTIVE = 'INACTIVE',
  ON_LEAVE = 'ON_LEAVE',
  TERMINATED = 'TERMINATED',
}

export class CreateEmployeeDto {
  @IsOptional()
  @IsString()
  @MaxLength(50)
  employeeNumber?: string;

  @IsString()
  @MinLength(1)
  @MaxLength(100)
  firstName: string;

  @IsString()
  @MinLength(1)
  @MaxLength(100)
  lastName: string;

  @IsEmail()
  email: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  phone?: string;

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

  @IsOptional()
  @IsString()
  @MaxLength(100)
  jobTitle?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  jobFamily?: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  jobLevel?: string;

  @IsOptional()
  @IsEnum(EmploymentType)
  employmentType?: EmploymentType;

  @IsOptional()
  @IsEnum(WorkMode)
  workMode?: WorkMode;

  @IsOptional()
  @IsEnum(EmployeeStatus)
  status?: EmployeeStatus;

  @IsOptional()
  @IsDateString()
  hireDate?: string;

  @IsOptional()
  @IsDateString()
  terminationDate?: string;

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

Gate

# Verify file exists with correct exports
cat apps/api/src/employees/dto/create-employee.dto.ts | grep "export class CreateEmployeeDto"
# Should show: export class CreateEmployeeDto

# Verify all required fields have decorators
cat apps/api/src/employees/dto/create-employee.dto.ts | grep -c "@Is"
# Should show at least 10 decorator usages

Common Errors

ErrorCauseFix
Cannot find module 'class-validator'Not installedcd apps/api && npm install class-validator class-transformer
Duplicate identifierEnum already importedUse Prisma-generated enums or local enums

Rollback

rm apps/api/src/employees/dto/create-employee.dto.ts

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 32:

  • CreateEmployeeDto file exists
  • All required fields have @IsString/@IsEmail decorators
  • Optional fields have @IsOptional
  • Enums defined with @IsEnum
  • Type "GATE 31 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/dto/create-employee.dto.ts

Step 32: Create UpdateEmployeeDto

Input

  • Step 31 complete
  • CreateEmployeeDto exists

Constraints

  • DO NOT create service or controller yet
  • DO NOT duplicate validation logic
  • ONLY create: UpdateEmployeeDto using PartialType

Task

cd apps/api

# Install mapped-types for PartialType
npm install @nestjs/mapped-types

Create apps/api/src/employees/dto/update-employee.dto.ts:

import { PartialType } from '@nestjs/mapped-types';
import { CreateEmployeeDto } from './create-employee.dto';

export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {}

Create apps/api/src/employees/dto/index.ts (barrel export):

export * from './create-employee.dto';
export * from './update-employee.dto';

Gate

# Verify both DTOs export correctly
cat apps/api/src/employees/dto/index.ts
# Should show exports for both DTOs

# Verify UpdateEmployeeDto extends PartialType
cat apps/api/src/employees/dto/update-employee.dto.ts | grep "PartialType"
# Should show: extends PartialType(CreateEmployeeDto)

Common Errors

ErrorCauseFix
Cannot find module '@nestjs/mapped-types'Not installedcd apps/api && npm install @nestjs/mapped-types

Rollback

rm apps/api/src/employees/dto/update-employee.dto.ts
rm apps/api/src/employees/dto/index.ts

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 33:

  • UpdateEmployeeDto extends PartialType
  • index.ts exports both DTOs
  • No TypeScript errors
  • Type "GATE 32 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/dto/update-employee.dto.ts
Createdapps/api/src/employees/dto/index.ts

Step 33: Create EmployeeRepository

Input

  • Step 32 complete
  • DTOs exist
  • PrismaService available from Phase 00/01

Constraints

  • DO NOT create service or controller yet
  • DO NOT add org filtering (department, team)
  • ALL queries MUST filter by tenantId
  • ONLY create: Repository with tenant-isolated queries

Task

Create apps/api/src/employees/employee.repository.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEmployeeDto, UpdateEmployeeDto } from './dto';
import { Prisma, EmployeeStatus } from '@prisma/client';

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

  async create(tenantId: string, data: CreateEmployeeDto) {
    return this.prisma.employee.create({
      data: {
        ...data,
        tenantId,
        hireDate: data.hireDate ? new Date(data.hireDate) : null,
        terminationDate: data.terminationDate ? new Date(data.terminationDate) : null,
      },
    });
  }

  async findAll(
    tenantId: string,
    options?: {
      status?: EmployeeStatus;
      search?: string;
      skip?: number;
      take?: number;
    },
  ) {
    const where: Prisma.EmployeeWhereInput = {
      tenantId,
      // Exclude TERMINATED by default (soft delete)
      status: options?.status ?? { not: EmployeeStatus.TERMINATED },
    };

    if (options?.search) {
      where.OR = [
        { firstName: { contains: options.search, mode: 'insensitive' } },
        { lastName: { contains: options.search, mode: 'insensitive' } },
        { email: { contains: options.search, mode: 'insensitive' } },
        { employeeNumber: { contains: options.search, mode: 'insensitive' } },
      ];
    }

    const [employees, total] = await Promise.all([
      this.prisma.employee.findMany({
        where,
        skip: options?.skip,
        take: options?.take,
        orderBy: { createdAt: 'desc' },
      }),
      this.prisma.employee.count({ where }),
    ]);

    return { employees, total };
  }

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

  async findByEmail(tenantId: string, email: string) {
    return this.prisma.employee.findFirst({
      where: {
        tenantId,
        email,
      },
    });
  }

  async update(tenantId: string, id: string, data: UpdateEmployeeDto) {
    return this.prisma.employee.updateMany({
      where: {
        id,
        tenantId,
      },
      data: {
        ...data,
        hireDate: data.hireDate ? new Date(data.hireDate) : undefined,
        terminationDate: data.terminationDate ? new Date(data.terminationDate) : undefined,
      },
    });
  }

  async softDelete(tenantId: string, id: string) {
    return this.prisma.employee.updateMany({
      where: {
        id,
        tenantId,
      },
      data: {
        status: EmployeeStatus.TERMINATED,
        terminationDate: new Date(),
      },
    });
  }

  async hardDelete(tenantId: string, id: string) {
    return this.prisma.employee.deleteMany({
      where: {
        id,
        tenantId,
      },
    });
  }
}

Gate

# Verify file exists with all methods
cat apps/api/src/employees/employee.repository.ts | grep "async "
# Should show: create, findAll, findById, findByEmail, update, softDelete, hardDelete

# Verify ALL methods filter by tenantId
grep -c "tenantId" apps/api/src/employees/employee.repository.ts
# Should show at least 7 (one per method)

Common Errors

ErrorCauseFix
Cannot find module '../prisma/prisma.service'Wrong import pathVerify PrismaService location
Property 'employee' does not existPrisma client not regeneratedRun npm run db:generate
Type 'string' is not assignableDate conversion neededUse new Date(data.hireDate)

Rollback

rm apps/api/src/employees/employee.repository.ts

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 34:

  • Repository has all CRUD methods
  • ALL methods filter by tenantId
  • softDelete sets status to TERMINATED
  • findAll excludes TERMINATED by default
  • Type "GATE 33 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/employee.repository.ts

Step 34: Create EmployeeService

Input

  • Step 33 complete
  • EmployeeRepository exists

Constraints

  • DO NOT create controller yet
  • DO NOT add complex business logic
  • ONLY create: Service with basic CRUD operations

Task

Create apps/api/src/employees/employee.service.ts:

import {
  Injectable,
  NotFoundException,
  ConflictException,
} from '@nestjs/common';
import { EmployeeRepository } from './employee.repository';
import { CreateEmployeeDto, UpdateEmployeeDto, EmployeeStatus } from './dto';

@Injectable()
export class EmployeeService {
  constructor(private employeeRepository: EmployeeRepository) {}

  async create(tenantId: string, dto: CreateEmployeeDto) {
    // Check for duplicate email within tenant
    const existing = await this.employeeRepository.findByEmail(tenantId, dto.email);
    if (existing) {
      throw new ConflictException('Employee with this email already exists');
    }

    return this.employeeRepository.create(tenantId, dto);
  }

  async findAll(
    tenantId: string,
    options?: {
      status?: EmployeeStatus;
      search?: string;
      page?: number;
      limit?: number;
    },
  ) {
    const page = options?.page ?? 1;
    const limit = options?.limit ?? 20;
    const skip = (page - 1) * limit;

    const { employees, total } = await this.employeeRepository.findAll(tenantId, {
      status: options?.status,
      search: options?.search,
      skip,
      take: limit,
    });

    return {
      data: employees,
      meta: {
        total,
        page,
        limit,
        totalPages: Math.ceil(total / limit),
      },
    };
  }

  async findById(tenantId: string, id: string) {
    const employee = await this.employeeRepository.findById(tenantId, id);
    if (!employee) {
      throw new NotFoundException('Employee not found');
    }
    return employee;
  }

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

    // Check email uniqueness if email is being updated
    if (dto.email) {
      const existing = await this.employeeRepository.findByEmail(tenantId, dto.email);
      if (existing && existing.id !== id) {
        throw new ConflictException('Employee with this email already exists');
      }
    }

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

  async delete(tenantId: string, id: string) {
    // Verify employee exists
    await this.findById(tenantId, id);

    // Soft delete by default
    await this.employeeRepository.softDelete(tenantId, id);
    return { success: true };
  }
}

Gate

# Verify file exists with all methods
cat apps/api/src/employees/employee.service.ts | grep "async "
# Should show: create, findAll, findById, update, delete

# Verify proper exception handling
grep -c "throw new" apps/api/src/employees/employee.service.ts
# Should show at least 3 (NotFoundException, ConflictException)

Common Errors

ErrorCauseFix
Cannot find module './employee.repository'File not createdComplete Step 33
Property 'findById' is privateMethod visibilityMake repository methods public

Rollback

rm apps/api/src/employees/employee.service.ts

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 35:

  • Service has all CRUD methods
  • Proper NotFoundException for missing employees
  • ConflictException for duplicate emails
  • Pagination in findAll
  • Type "GATE 34 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/employee.service.ts

Step 35: Create EmployeeController

Input

  • Step 34 complete
  • EmployeeService exists
  • TenantGuard exists from Phase 01

Constraints

  • DO NOT add complex auth beyond TenantGuard
  • DO NOT add file upload endpoints
  • ONLY create: Controller with standard REST endpoints

Task

Create apps/api/src/employees/employee.controller.ts:

import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
} from '@nestjs/common';
import { EmployeeService } from './employee.service';
import { CreateEmployeeDto, UpdateEmployeeDto } from './dto';
import { EmployeeStatus } from '@prisma/client';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';

@Controller('api/v1/employees')
@UseGuards(TenantGuard)
export class EmployeeController {
  constructor(private employeeService: EmployeeService) {}

  @Post()
  async create(
    @TenantId() tenantId: string,
    @Body() dto: CreateEmployeeDto,
  ) {
    const employee = await this.employeeService.create(tenantId, dto);
    return { data: employee, error: null };
  }

  @Get()
  async findAll(
    @TenantId() tenantId: string,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
    @Query('status') status?: EmployeeStatus,
    @Query('search') search?: string,
  ) {
    const result = await this.employeeService.findAll(tenantId, {
      page: page ? parseInt(page, 10) : undefined,
      limit: limit ? parseInt(limit, 10) : undefined,
      status,
      search,
    });
    return { data: result.data, meta: result.meta, error: null };
  }

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

  @Patch(':id')
  async update(
    @TenantId() tenantId: string,
    @Param('id') id: string,
    @Body() dto: UpdateEmployeeDto,
  ) {
    const employee = await this.employeeService.update(tenantId, id, dto);
    return { data: employee, error: null };
  }

  @Delete(':id')
  async delete(
    @TenantId() tenantId: string,
    @Param('id') id: string,
  ) {
    await this.employeeService.delete(tenantId, id);
    return { data: null, error: null };
  }
}

Gate

# Verify file exists with all endpoints
cat apps/api/src/employees/employee.controller.ts | grep "@"
# Should show: @Controller, @UseGuards, @Post, @Get (x2), @Patch, @Delete

# Verify TenantGuard is applied
grep "TenantGuard" apps/api/src/employees/employee.controller.ts
# Should show: @UseGuards(TenantGuard)

Common Errors

ErrorCauseFix
Cannot find module '../guards/tenant.guard'Wrong pathVerify TenantGuard location from Phase 01
No response mappedMissing returnAdd return statement to all methods

Rollback

rm apps/api/src/employees/employee.controller.ts

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 36:

  • Controller has POST/GET/PATCH/DELETE endpoints
  • TenantGuard applied at controller level
  • tenantId via @TenantId() decorator (consistent with Phase 01)
  • Consistent response format: { data, error }
  • Type "GATE 35 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/employee.controller.ts

Step 36: Register EmployeeModule

Input

  • Step 35 complete
  • All Employee files created

Constraints

  • DO NOT modify existing modules beyond imports
  • ONLY create: Module file and update AppModule

Task

Create apps/api/src/employees/employee.module.ts:

import { Module } from '@nestjs/common';
import { EmployeeController } from './employee.controller';
import { EmployeeService } from './employee.service';
import { EmployeeRepository } from './employee.repository';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [EmployeeController],
  providers: [EmployeeService, EmployeeRepository],
  exports: [EmployeeService],
})
export class EmployeeModule {}

Create apps/api/src/employees/index.ts (barrel export):

export * from './employee.module';
export * from './employee.service';
export * from './employee.repository';
export * from './dto';

Update apps/api/src/app.module.ts:

import { Module } from '@nestjs/common';
// ... existing imports ...
import { EmployeeModule } from './employees';

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

IMPORTANT: Enable ValidationPipe in apps/api/src/main.ts:

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Enable CORS for frontend
  app.enableCors({
    origin: 'http://localhost:3000',
    credentials: true,
  });

  // Enable validation - REQUIRED for DTO decorators to work
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,      // Strip properties not in DTO
    transform: true,      // Auto-transform payloads to DTO instances
    forbidNonWhitelisted: true,  // Throw error on extra properties
  }));

  await app.listen(3001);
}
bootstrap();

Without ValidationPipe, the @IsString/@IsEmail decorators in DTOs will not validate anything!

Gate

# Build the API
cd apps/api && npm run build
# Should succeed with no errors

# Verify module registered
cat apps/api/src/app.module.ts | grep "EmployeeModule"
# Should show: EmployeeModule in imports

Common Errors

ErrorCauseFix
Cannot find module './employees'Missing index.tsCreate barrel export
Nest can't resolve dependenciesPrismaModule not importedAdd PrismaModule to imports
Build failedTypeScript errorsFix errors shown in output

Rollback

rm apps/api/src/employees/employee.module.ts
rm apps/api/src/employees/index.ts
# Remove EmployeeModule from app.module.ts imports

Lock

After this step, these files are locked:

  • apps/api/src/employees/* (all employee module files)

Checkpoint

Before proceeding to Step 37:

  • EmployeeModule created
  • Module registered in AppModule
  • npm run build succeeds
  • No TypeScript errors
  • Type "GATE 36 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/api/src/employees/employee.module.ts
Createdapps/api/src/employees/index.ts
Modifiedapps/api/src/app.module.ts

Step 37: Test Employee CRUD via curl

Input

  • Step 36 complete
  • API builds and runs
  • You have a valid tenant ID from Phase 01

Constraints

  • DO NOT modify code (testing only)
  • DO NOT proceed if any test fails
  • ONLY run: curl commands to test endpoints

Task

Start the API if not running:

cd apps/api && npm run dev

Get a tenant ID (from Phase 01 - check via Prisma Studio or previous login):

# Replace YOUR_TENANT_ID with actual tenant ID
export TENANT_ID="YOUR_TENANT_ID"

Test CRUD operations:

# 1. Create Employee
curl -X POST http://localhost:3001/api/v1/employees \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: $TENANT_ID" \
  -d '{
    "firstName": "John",
    "lastName": "Doe",
    "email": "john.doe@example.com",
    "jobTitle": "Software Engineer",
    "employmentType": "FULL_TIME",
    "workMode": "HYBRID"
  }'
# Should return: { "data": { "id": "...", ... }, "error": null }

# Save the employee ID
export EMPLOYEE_ID="<id-from-response>"

# 2. List Employees
curl -X GET "http://localhost:3001/api/v1/employees" \
  -H "x-tenant-id: $TENANT_ID"
# Should return: { "data": [...], "meta": { "total": 1, ... }, "error": null }

# 3. Get Single Employee
curl -X GET "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
  -H "x-tenant-id: $TENANT_ID"
# Should return: { "data": { "id": "...", ... }, "error": null }

# 4. Update Employee
curl -X PATCH "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: $TENANT_ID" \
  -d '{
    "jobTitle": "Senior Software Engineer"
  }'
# Should return updated employee

# 5. Delete Employee (soft delete)
curl -X DELETE "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
  -H "x-tenant-id: $TENANT_ID"
# Should return: { "data": null, "error": null }

# 6. Verify soft delete (employee still exists but TERMINATED)
curl -X GET "http://localhost:3001/api/v1/employees?status=TERMINATED" \
  -H "x-tenant-id: $TENANT_ID"
# Should return the deleted employee with status: TERMINATED

Gate

All 6 curl commands must succeed:

  • POST creates employee, returns data
  • GET list returns employees array with meta
  • GET single returns employee data
  • PATCH updates and returns updated data
  • DELETE returns success
  • GET with status=TERMINATED shows deleted employee

Common Errors

ErrorCauseFix
401 UnauthorizedTenantGuard blockingVerify x-tenant-id header
403 ForbiddenInvalid tenant IDUse valid tenant ID from database
404 Not FoundWrong endpoint URLCheck URL path matches controller
500 Internal Server ErrorCode bugCheck API logs for error details

Rollback

# No rollback needed - testing only
# If tests fail, debug and fix code in previous steps

Lock

  • No new files locked

Checkpoint

Before proceeding to Step 38:

  • All 6 curl tests pass
  • Response format consistent: { data, meta?, error }
  • Soft delete works (status becomes TERMINATED)
  • Type "GATE 37 PASSED" to continue

Files Created/Modified This Step

ActionFile
NoneTesting only

Step 38: Create Employee List Page (Frontend)

Input

  • Step 37 complete
  • API endpoints working
  • Next.js app running

Prerequisites

1. Add API URL to environment:

# Add to apps/web/.env.local (create if doesn't exist)
echo 'NEXT_PUBLIC_API_URL=http://localhost:3001' >> apps/web/.env.local

2. Ensure Tailwind CSS is configured: If Tailwind wasn't set up in Phase 00, install now:

cd apps/web
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Update apps/web/tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add to apps/web/app/globals.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

Constraints

  • DO NOT add complex state management
  • DO NOT add sorting/filtering UI yet
  • ONLY create: Basic list page with table

Task

Create apps/web/app/dashboard/employees/page.tsx:

import { auth } from "@/auth"
import { redirect } from 'next/navigation';
import Link from 'next/link';

interface Employee {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  jobTitle: string | null;
  status: string;
  employmentType: string;
}

interface EmployeesResponse {
  data: Employee[];
  meta: {
    total: number;
    page: number;
    limit: number;
    totalPages: number;
  };
  error: null | { code: string; message: string };
}

async function getEmployees(tenantId: string): Promise<EmployeesResponse> {
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`,
    {
      headers: {
        'x-tenant-id': tenantId,
      },
      cache: 'no-store',
    }
  );

  if (!res.ok) {
    throw new Error('Failed to fetch employees');
  }

  return res.json();
}

export default async function EmployeesPage() {
  const session = await auth()

  if (!session?.user?.tenantId) {
    redirect('/login');
  }

  const { data: employees, meta } = await getEmployees(session.user.tenantId);

  return (
    <div className="p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Employees</h1>
        <Link
          href="/dashboard/employees/new"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Add Employee
        </Link>
      </div>

      <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl overflow-hidden">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Email
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Job Title
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Status
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {employees.map((employee) => (
              <tr key={employee.id}>
                <td className="px-6 py-4 whitespace-nowrap">
                  {employee.firstName} {employee.lastName}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">{employee.email}</td>
                <td className="px-6 py-4 whitespace-nowrap">
                  {employee.jobTitle || '-'}
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <span
                    className={`px-2 py-1 text-xs rounded ${
                      employee.status === 'ACTIVE'
                        ? 'bg-green-100 text-green-800'
                        : 'bg-gray-100 text-gray-800'
                    }`}
                  >
                    {employee.status}
                  </span>
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <Link
                    href={`/dashboard/employees/${employee.id}`}
                    className="text-blue-600 hover:text-blue-900"
                  >
                    Edit
                  </Link>
                </td>
              </tr>
            ))}
            {employees.length === 0 && (
              <tr>
                <td colSpan={5} className="px-6 py-4 text-center text-gray-500">
                  No employees found
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      <div className="mt-4 text-sm text-gray-500">
        Showing {employees.length} of {meta.total} employees
      </div>
    </div>
  );
}

Gate

# Navigate to employees page in browser
# URL: http://localhost:3000/dashboard/employees
# Should show:
# - "Employees" heading
# - "Add Employee" button
# - Table with columns: Name, Email, Job Title, Status, Actions
# - Either employee data or "No employees found"

Common Errors

ErrorCauseFix
NEXT_PUBLIC_API_URL not definedMissing env varAdd to .env.local: NEXT_PUBLIC_API_URL=http://localhost:3001
Failed to fetch employeesAPI not runningStart API: cd apps/api && npm run dev
tenantId undefinedSession not configuredVerify Phase 01 auth working

Rollback

rm apps/web/app/dashboard/employees/page.tsx

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 39:

  • Page renders at /dashboard/employees
  • Shows table with employee data (or empty state)
  • "Add Employee" button visible
  • Edit links work (even if edit page doesn't exist yet)
  • Type "GATE 38 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/web/app/dashboard/employees/page.tsx

Step 39: Create Employee Form Component

Input

  • Step 38 complete
  • List page working

Constraints

  • DO NOT use form libraries (just native HTML + React)
  • DO NOT add file upload
  • ONLY create: Reusable form component with all fields

Task

Create apps/web/app/dashboard/employees/components/employee-form.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

interface EmployeeFormData {
  firstName: string;
  lastName: string;
  email: string;
  phone?: string;
  employeeNumber?: string;
  jobTitle?: string;
  jobFamily?: string;
  jobLevel?: string;
  employmentType: string;
  workMode: string;
  status: string;
  hireDate?: string;
}

interface EmployeeFormProps {
  initialData?: Partial<EmployeeFormData>;
  onSubmit: (data: EmployeeFormData) => Promise<void>;
  submitLabel: string;
}

const EMPLOYMENT_TYPES = [
  { value: 'FULL_TIME', label: 'Full Time' },
  { value: 'PART_TIME', label: 'Part Time' },
  { value: 'CONTRACTOR', label: 'Contractor' },
  { value: 'INTERN', label: 'Intern' },
  { value: 'TEMPORARY', label: 'Temporary' },
];

const WORK_MODES = [
  { value: 'ONSITE', label: 'Onsite' },
  { value: 'REMOTE', label: 'Remote' },
  { value: 'HYBRID', label: 'Hybrid' },
];

const STATUSES = [
  { value: 'ACTIVE', label: 'Active' },
  { value: 'INACTIVE', label: 'Inactive' },
  { value: 'ON_LEAVE', label: 'On Leave' },
];

export function EmployeeForm({
  initialData,
  onSubmit,
  submitLabel,
}: EmployeeFormProps) {
  const router = useRouter();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const [formData, setFormData] = useState<EmployeeFormData>({
    firstName: initialData?.firstName ?? '',
    lastName: initialData?.lastName ?? '',
    email: initialData?.email ?? '',
    phone: initialData?.phone ?? '',
    employeeNumber: initialData?.employeeNumber ?? '',
    jobTitle: initialData?.jobTitle ?? '',
    jobFamily: initialData?.jobFamily ?? '',
    jobLevel: initialData?.jobLevel ?? '',
    employmentType: initialData?.employmentType ?? 'FULL_TIME',
    workMode: initialData?.workMode ?? 'ONSITE',
    status: initialData?.status ?? 'ACTIVE',
    hireDate: initialData?.hireDate ?? '',
  });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);

    try {
      await onSubmit(formData);
      router.push('/dashboard/employees');
      router.refresh();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {error && (
        <div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
          {error}
        </div>
      )}

      {/* Personal Information */}
      <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
        <h2 className="text-lg font-medium mb-4">Personal Information</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              First Name *
            </label>
            <input
              type="text"
              name="firstName"
              value={formData.firstName}
              onChange={handleChange}
              required
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Last Name *
            </label>
            <input
              type="text"
              name="lastName"
              value={formData.lastName}
              onChange={handleChange}
              required
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Email *
            </label>
            <input
              type="email"
              name="email"
              value={formData.email}
              onChange={handleChange}
              required
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Phone
            </label>
            <input
              type="tel"
              name="phone"
              value={formData.phone}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
        </div>
      </div>

      {/* Job Information */}
      <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
        <h2 className="text-lg font-medium mb-4">Job Information</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Employee Number
            </label>
            <input
              type="text"
              name="employeeNumber"
              value={formData.employeeNumber}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Job Title
            </label>
            <input
              type="text"
              name="jobTitle"
              value={formData.jobTitle}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Job Family
            </label>
            <input
              type="text"
              name="jobFamily"
              value={formData.jobFamily}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Job Level
            </label>
            <input
              type="text"
              name="jobLevel"
              value={formData.jobLevel}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
        </div>
      </div>

      {/* Employment Details */}
      <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
        <h2 className="text-lg font-medium mb-4">Employment Details</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Employment Type
            </label>
            <select
              name="employmentType"
              value={formData.employmentType}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            >
              {EMPLOYMENT_TYPES.map((type) => (
                <option key={type.value} value={type.value}>
                  {type.label}
                </option>
              ))}
            </select>
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Work Mode
            </label>
            <select
              name="workMode"
              value={formData.workMode}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            >
              {WORK_MODES.map((mode) => (
                <option key={mode.value} value={mode.value}>
                  {mode.label}
                </option>
              ))}
            </select>
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Status
            </label>
            <select
              name="status"
              value={formData.status}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            >
              {STATUSES.map((status) => (
                <option key={status.value} value={status.value}>
                  {status.label}
                </option>
              ))}
            </select>
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700">
              Hire Date
            </label>
            <input
              type="date"
              name="hireDate"
              value={formData.hireDate}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
        </div>
      </div>

      {/* Actions */}
      <div className="flex justify-end space-x-4">
        <button
          type="button"
          onClick={() => router.back()}
          className="px-6 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
        >
          Cancel
        </button>
        <button
          type="submit"
          disabled={isSubmitting}
          className="px-4 py-2 rounded-2xl shadow-lg shadow-blue-600/30 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
        >
          {isSubmitting ? 'Saving...' : submitLabel}
        </button>
      </div>
    </form>
  );
}

Gate

# Verify file exists
cat apps/web/app/dashboard/employees/components/employee-form.tsx | head -20
# Should show: 'use client' directive and imports

# Verify all form fields present
grep -c "name=" apps/web/app/dashboard/employees/components/employee-form.tsx
# Should show at least 10 (one per field)

Common Errors

ErrorCauseFix
'use client' must be firstOther code before directiveMove 'use client' to line 1
useRouter not foundWrong importUse next/navigation not next/router

Rollback

rm -rf apps/web/app/dashboard/employees/components

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 40:

  • Form component created
  • All fields from Employee model included
  • Select dropdowns for enums
  • Submit/Cancel buttons work
  • Type "GATE 39 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/web/app/dashboard/employees/components/employee-form.tsx

Step 40: Create New Employee Page

Input

  • Step 39 complete
  • EmployeeForm component exists

Prerequisites

Ensure SessionProvider wraps your app. If not already done:

  1. Create apps/web/app/providers.tsx:
'use client';
import { SessionProvider } from 'next-auth/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}
  1. Update apps/web/app/layout.tsx to wrap children:
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Constraints

  • DO NOT add complex validation
  • DO NOT add multi-step wizard
  • ONLY create: New employee page using EmployeeForm

Task

Create apps/web/app/dashboard/employees/new/page.tsx:

'use client';

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';

export default function NewEmployeePage() {
  const { data: session, status } = useSession();
  const router = useRouter();

  if (status === 'loading') {
    return <div className="p-6">Loading...</div>;
  }

  if (!session?.user?.tenantId) {
    router.push('/login');
    return null;
  }

  const handleSubmit = async (data: Record<string, unknown>) => {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-tenant-id': session.user.tenantId!,
        },
        body: JSON.stringify(data),
      }
    );

    if (!res.ok) {
      const error = await res.json();
      throw new Error(error.error?.message || 'Failed to create employee');
    }
  };

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">Add New Employee</h1>
      <EmployeeForm onSubmit={handleSubmit} submitLabel="Create Employee" />
    </div>
  );
}

Gate

# Navigate to new employee page in browser
# URL: http://localhost:3000/dashboard/employees/new
# Should show:
# - "Add New Employee" heading
# - Employee form with all fields
# - "Create Employee" button

# Test form submission:
# 1. Fill required fields (firstName, lastName, email)
# 2. Click "Create Employee"
# 3. Should redirect to /dashboard/employees
# 4. New employee should appear in list

Common Errors

ErrorCauseFix
useSession must be wrappedMissing SessionProviderWrap app in SessionProvider
tenantId undefinedSession not extendedVerify Phase 01 session callback
CORS errorAPI blocking frontendCheck API CORS config

Rollback

rm -rf apps/web/app/dashboard/employees/new

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 41:

  • Page renders at /dashboard/employees/new
  • Form displays all fields
  • Submit creates employee in database
  • Redirects to list after success
  • Type "GATE 40 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/web/app/dashboard/employees/new/page.tsx

Step 41: Create Edit Employee Page

Input

  • Step 40 complete
  • Create page working

Constraints

  • DO NOT add complex state management
  • ONLY create: Edit page using EmployeeForm with initial data

Task

Create apps/web/app/dashboard/employees/[id]/page.tsx:

Note: In Next.js 15, params is a Promise. Use React.use() to unwrap it.

'use client';

import { use, useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';

interface Employee {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  phone: string | null;
  employeeNumber: string | null;
  jobTitle: string | null;
  jobFamily: string | null;
  jobLevel: string | null;
  employmentType: string;
  workMode: string;
  status: string;
  hireDate: string | null;
}

export default function EditEmployeePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // Next.js 15: params is a Promise, use React.use() to unwrap
  const { id } = use(params);

  const { data: session, status } = useSession();
  const router = useRouter();
  const [employee, setEmployee] = useState<Employee | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (status === 'authenticated' && session?.user?.tenantId) {
      fetchEmployee();
    }
  }, [status, session]);

  const fetchEmployee = async () => {
    try {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
        {
          headers: {
            'x-tenant-id': session!.user.tenantId!,
          },
        }
      );

      if (!res.ok) {
        throw new Error('Employee not found');
      }

      const { data } = await res.json();
      setEmployee(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load employee');
    } finally {
      setLoading(false);
    }
  };

  if (status === 'loading' || loading) {
    return <div className="p-6">Loading...</div>;
  }

  if (!session?.user?.tenantId) {
    router.push('/login');
    return null;
  }

  if (error) {
    return (
      <div className="p-6">
        <div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
          {error}
        </div>
      </div>
    );
  }

  if (!employee) {
    return <div className="p-6">Employee not found</div>;
  }

  const handleSubmit = async (data: Record<string, unknown>) => {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
      {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          'x-tenant-id': session.user.tenantId!,
        },
        body: JSON.stringify(data),
      }
    );

    if (!res.ok) {
      const error = await res.json();
      throw new Error(error.error?.message || 'Failed to update employee');
    }
  };

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">
        Edit Employee: {employee.firstName} {employee.lastName}
      </h1>
      <EmployeeForm
        initialData={{
          firstName: employee.firstName,
          lastName: employee.lastName,
          email: employee.email,
          phone: employee.phone ?? undefined,
          employeeNumber: employee.employeeNumber ?? undefined,
          jobTitle: employee.jobTitle ?? undefined,
          jobFamily: employee.jobFamily ?? undefined,
          jobLevel: employee.jobLevel ?? undefined,
          employmentType: employee.employmentType,
          workMode: employee.workMode,
          status: employee.status,
          hireDate: employee.hireDate
            ? new Date(employee.hireDate).toISOString().split('T')[0]
            : undefined,
        }}
        onSubmit={handleSubmit}
        submitLabel="Save Changes"
      />
    </div>
  );
}

Gate

# Navigate to edit employee page in browser
# URL: http://localhost:3000/dashboard/employees/{employee-id}
# Should show:
# - "Edit Employee: [Name]" heading
# - Form pre-filled with employee data
# - "Save Changes" button

# Test form submission:
# 1. Modify a field (e.g., jobTitle)
# 2. Click "Save Changes"
# 3. Should redirect to /dashboard/employees
# 4. Changes should be reflected in list

Common Errors

ErrorCauseFix
Employee not foundInvalid ID or wrong tenantVerify ID in URL is correct
Cannot read property 'id'params not awaited (Next.js 15)Use await params if Next.js 15
Date formatting errorInvalid dateHandle null dates

Rollback

rm -rf apps/web/app/dashboard/employees/[id]

Lock

  • No files locked yet

Checkpoint

Before proceeding to Step 42:

  • Page renders at /dashboard/employees/[id]
  • Form pre-filled with employee data
  • Save updates employee in database
  • Redirects to list after success
  • Type "GATE 41 PASSED" to continue

Files Created/Modified This Step

ActionFile
Createdapps/web/app/dashboard/employees/[id]/page.tsx

Step 42: Add Delete Functionality

Input

  • Step 41 complete
  • Edit page working

Constraints

  • DO NOT add hard delete
  • DO NOT add bulk delete
  • ONLY add: Delete button with confirmation

Task

Update apps/web/app/dashboard/employees/[id]/page.tsx to add delete button:

// Add this state near the top of the component
const [isDeleting, setIsDeleting] = useState(false);

// Add this function before the return statement
const handleDelete = async () => {
  if (!confirm('Are you sure you want to delete this employee? This action will mark them as terminated.')) {
    return;
  }

  setIsDeleting(true);
  try {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
      {
        method: 'DELETE',
        headers: {
          'x-tenant-id': session!.user.tenantId!,
        },
      }
    );

    if (!res.ok) {
      throw new Error('Failed to delete employee');
    }

    router.push('/dashboard/employees');
    router.refresh();
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Failed to delete employee');
    setIsDeleting(false);
  }
};

// Add this button in the return statement, after the heading:
<div className="flex justify-between items-center mb-6">
  <h1 className="text-2xl font-bold">
    Edit Employee: {employee.firstName} {employee.lastName}
  </h1>
  <button
    onClick={handleDelete}
    disabled={isDeleting}
    className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
  >
    {isDeleting ? 'Deleting...' : 'Delete Employee'}
  </button>
</div>

Full updated component with delete:

'use client';

import { use, useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';

interface Employee {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  phone: string | null;
  employeeNumber: string | null;
  jobTitle: string | null;
  jobFamily: string | null;
  jobLevel: string | null;
  employmentType: string;
  workMode: string;
  status: string;
  hireDate: string | null;
}

export default function EditEmployeePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // Next.js 15: params is a Promise, use React.use() to unwrap
  const { id } = use(params);

  const { data: session, status } = useSession();
  const router = useRouter();
  const [employee, setEmployee] = useState<Employee | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [isDeleting, setIsDeleting] = useState(false);

  useEffect(() => {
    if (status === 'authenticated' && session?.user?.tenantId) {
      fetchEmployee();
    }
  }, [status, session]);

  const fetchEmployee = async () => {
    try {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
        {
          headers: {
            'x-tenant-id': session!.user.tenantId!,
          },
        }
      );

      if (!res.ok) {
        throw new Error('Employee not found');
      }

      const { data } = await res.json();
      setEmployee(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load employee');
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async () => {
    if (
      !confirm(
        'Are you sure you want to delete this employee? This action will mark them as terminated.'
      )
    ) {
      return;
    }

    setIsDeleting(true);
    try {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
        {
          method: 'DELETE',
          headers: {
            'x-tenant-id': session!.user.tenantId!,
          },
        }
      );

      if (!res.ok) {
        throw new Error('Failed to delete employee');
      }

      router.push('/dashboard/employees');
      router.refresh();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to delete employee');
      setIsDeleting(false);
    }
  };

  if (status === 'loading' || loading) {
    return <div className="p-6">Loading...</div>;
  }

  if (!session?.user?.tenantId) {
    router.push('/login');
    return null;
  }

  if (error) {
    return (
      <div className="p-6">
        <div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
          {error}
        </div>
      </div>
    );
  }

  if (!employee) {
    return <div className="p-6">Employee not found</div>;
  }

  const handleSubmit = async (data: Record<string, unknown>) => {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
      {
        method: 'PATCH',
        headers: {
          'Content-Type': 'application/json',
          'x-tenant-id': session.user.tenantId!,
        },
        body: JSON.stringify(data),
      }
    );

    if (!res.ok) {
      const error = await res.json();
      throw new Error(error.error?.message || 'Failed to update employee');
    }
  };

  return (
    <div className="p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">
          Edit Employee: {employee.firstName} {employee.lastName}
        </h1>
        <button
          onClick={handleDelete}
          disabled={isDeleting}
          className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
        >
          {isDeleting ? 'Deleting...' : 'Delete Employee'}
        </button>
      </div>
      <EmployeeForm
        initialData={{
          firstName: employee.firstName,
          lastName: employee.lastName,
          email: employee.email,
          phone: employee.phone ?? undefined,
          employeeNumber: employee.employeeNumber ?? undefined,
          jobTitle: employee.jobTitle ?? undefined,
          jobFamily: employee.jobFamily ?? undefined,
          jobLevel: employee.jobLevel ?? undefined,
          employmentType: employee.employmentType,
          workMode: employee.workMode,
          status: employee.status,
          hireDate: employee.hireDate
            ? new Date(employee.hireDate).toISOString().split('T')[0]
            : undefined,
        }}
        onSubmit={handleSubmit}
        submitLabel="Save Changes"
      />
    </div>
  );
}

Gate

# Navigate to edit employee page in browser
# URL: http://localhost:3000/dashboard/employees/{employee-id}
# Should show:
# - "Delete Employee" button (red)
# - Clicking shows confirmation dialog
# - Confirming deletes employee and redirects to list
# - Employee should no longer appear in list (soft deleted)

# Verify soft delete:
curl -X GET "http://localhost:3001/api/v1/employees?status=TERMINATED" \
  -H "x-tenant-id: $TENANT_ID"
# Should show deleted employee with status: TERMINATED

Common Errors

ErrorCauseFix
Failed to deleteAPI errorCheck API logs
Employee still showingCache not refreshedAdd router.refresh()

Rollback

# Revert to Step 41 version of the file
# Remove handleDelete function and Delete button

Lock

After this step, these files are locked:

  • apps/web/app/dashboard/employees/* (all frontend employee files)

Checkpoint

Phase 02 complete! Verify:

  • Delete button visible on edit page
  • Confirmation dialog appears
  • Employee removed from list after delete
  • Employee has status TERMINATED in database
  • Type "PHASE 02 COMPLETE" to continue

Files Created/Modified This Step

ActionFile
Modifiedapps/web/app/dashboard/employees/[id]/page.tsx

Phase 02 Summary

What Was Built

ComponentFiles
DatabaseEmployee model, 3 enums, User relation
BackendEmployeeRepository, EmployeeService, EmployeeController, EmployeeModule
FrontendList page, Form component, Create page, Edit page with delete

Locked Files

After Phase 02, these files should not be modified without good reason:

  • packages/database/prisma/schema.prisma (Employee model, enums)
  • apps/api/src/employees/*
  • apps/web/app/dashboard/employees/*

Key Patterns Established

  1. Tenant Isolation: All repository methods filter by tenantId
  2. Soft Delete: Use status TERMINATED, not hard delete
  3. Response Format: { data, meta?, error }
  4. Form Component: Reusable form with initialData prop
  5. Error Handling: NotFoundException, ConflictException in service

Next Phase

Phase 03: Org Structure (Steps 43-62)

  • Department model and CRUD
  • Team model and CRUD
  • Manager assignment
  • Org chart visualization (Phase 04)

Anti-Patterns to Avoid in Future Phases

  • DO NOT add generic base classes (keep explicit)
  • DO NOT add complex state management (simple useState is fine)
  • DO NOT add real-time updates (not in MVP)
  • DO NOT add bulk operations (keep it simple)

Step 43: Add Address and Emergency Contact Fields (UO-07)

Input

  • Phase 02 core steps complete
  • Employee model exists

Constraints

  • Add fields to existing Employee model
  • DO NOT create separate address table
  • ONLY modify schema and DTOs

Task

1. Update Employee model in packages/database/prisma/schema.prisma:

model Employee {
  // ... existing fields ...

  // Address fields
  addressLine1    String?
  addressLine2    String?
  city            String?
  state           String?
  postalCode      String?
  country         String?

  // Emergency contact
  emergencyContactName     String?
  emergencyContactPhone    String?
  emergencyContactRelation String?
}

2. Run migration:

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

3. Update CreateEmployeeDto in apps/api/src/employees/dto/create-employee.dto.ts:

// Add to existing DTO
@IsOptional()
@IsString()
addressLine1?: string;

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

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

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

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

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

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

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

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

4. Update EmployeeForm - Add address and emergency contact sections in apps/web/app/dashboard/employees/components/employee-form.tsx:

{/* Address Section */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
  <h2 className="text-lg font-medium mb-4">Address</h2>
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
    <div className="md:col-span-2">
      <label className="block text-sm font-medium text-gray-700">
        Address Line 1
      </label>
      <input
        type="text"
        name="addressLine1"
        value={formData.addressLine1 || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div className="md:col-span-2">
      <label className="block text-sm font-medium text-gray-700">
        Address Line 2
      </label>
      <input
        type="text"
        name="addressLine2"
        value={formData.addressLine2 || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">City</label>
      <input
        type="text"
        name="city"
        value={formData.city || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">
        State / Province
      </label>
      <input
        type="text"
        name="state"
        value={formData.state || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">
        Postal Code
      </label>
      <input
        type="text"
        name="postalCode"
        value={formData.postalCode || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">Country</label>
      <input
        type="text"
        name="country"
        value={formData.country || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
  </div>
</div>

{/* Emergency Contact Section */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
  <h2 className="text-lg font-medium mb-4">Emergency Contact</h2>
  <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
    <div>
      <label className="block text-sm font-medium text-gray-700">
        Contact Name
      </label>
      <input
        type="text"
        name="emergencyContactName"
        value={formData.emergencyContactName || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">
        Phone Number
      </label>
      <input
        type="tel"
        name="emergencyContactPhone"
        value={formData.emergencyContactPhone || ''}
        onChange={handleChange}
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
    <div>
      <label className="block text-sm font-medium text-gray-700">
        Relationship
      </label>
      <input
        type="text"
        name="emergencyContactRelation"
        value={formData.emergencyContactRelation || ''}
        onChange={handleChange}
        placeholder="e.g., Spouse, Parent, Sibling"
        className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
      />
    </div>
  </div>
</div>

Gate

# Verify schema updated
cat packages/database/prisma/schema.prisma | grep -A 3 "addressLine1"
# Should show address fields

# Test API
curl -X POST http://localhost:3001/api/v1/employees \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -d '{
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com",
    "city": "San Francisco",
    "state": "CA",
    "emergencyContactName": "John Doe",
    "emergencyContactPhone": "+1-555-0123"
  }'
# Should return employee with address and emergency contact fields

Checkpoint

  • Address fields added to schema
  • Emergency contact fields added to schema
  • Form shows both sections
  • Data saves correctly
  • Type "GATE 43 PASSED" to continue

Step 44: Add Profile Picture Upload (UO-04)

Input

  • Step 43 complete
  • Employee has pictureUrl field

Constraints

  • Use existing upload infrastructure from Phase 06
  • Store URL in Employee.pictureUrl
  • ONLY add picture upload endpoint and component

Task

1. Add Picture Upload Endpoint to apps/api/src/employees/employees.controller.ts:

import { FileInterceptor } from '@nestjs/platform-express';
import { UploadedFile, UseInterceptors } from '@nestjs/common';

@Post(':id/picture')
@UseInterceptors(FileInterceptor('file'))
async uploadPicture(
  @Param('id') id: string,
  @UploadedFile() file: Express.Multer.File,
  @TenantId() tenantId: string,
) {
  // Use upload service (from Phase 06) or store locally for MVP
  const url = await this.employeesService.uploadPicture(id, file, tenantId);
  return { data: { pictureUrl: url }, error: null };
}

@Delete(':id/picture')
async deletePicture(
  @Param('id') id: string,
  @TenantId() tenantId: string,
) {
  await this.employeesService.deletePicture(id, tenantId);
  return { success: true, error: null };
}

2. Add Picture Methods to EmployeesService:

// Add to apps/api/src/employees/employees.service.ts

import { writeFile, unlink, mkdir } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';

async uploadPicture(employeeId: string, file: Express.Multer.File, tenantId: string) {
  const employee = await this.repository.findById(tenantId, employeeId);
  if (!employee) {
    throw new NotFoundException(`Employee with ID ${employeeId} not found`);
  }

  // Create uploads directory if it doesn't exist
  const uploadsDir = join(process.cwd(), 'uploads', 'profile-pictures');
  if (!existsSync(uploadsDir)) {
    await mkdir(uploadsDir, { recursive: true });
  }

  // Generate unique filename
  const ext = file.originalname.split('.').pop();
  const filename = `${employeeId}-${Date.now()}.${ext}`;
  const filepath = join(uploadsDir, filename);

  // Save file
  await writeFile(filepath, file.buffer);

  // Update employee with picture URL
  const pictureUrl = `/uploads/profile-pictures/${filename}`;
  await this.repository.update(tenantId, employeeId, { pictureUrl });

  return pictureUrl;
}

async deletePicture(employeeId: string, tenantId: string) {
  const employee = await this.repository.findById(tenantId, employeeId);
  if (!employee) {
    throw new NotFoundException(`Employee with ID ${employeeId} not found`);
  }

  if (employee.pictureUrl) {
    // Delete file from disk
    const filepath = join(process.cwd(), employee.pictureUrl);
    if (existsSync(filepath)) {
      await unlink(filepath);
    }

    // Clear picture URL
    await this.repository.update(tenantId, employeeId, { pictureUrl: null });
  }
}

3. Serve Static Files - Update apps/api/src/main.ts:

import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // Serve uploaded files
  app.useStaticAssets(join(__dirname, '..', 'uploads'), {
    prefix: '/uploads/',
  });

  // ... rest of bootstrap
}

4. Create Profile Picture Upload Component at apps/web/components/profile-picture-upload.tsx:

'use client';

import { useState, useRef } from 'react';
import { useSession } from 'next-auth/react';

interface ProfilePictureUploadProps {
  employeeId: string;
  currentUrl?: string | null;
  onUpload?: (url: string) => void;
}

export function ProfilePictureUpload({
  employeeId,
  currentUrl,
  onUpload,
}: ProfilePictureUploadProps) {
  const { data: session } = useSession();
  const [uploading, setUploading] = useState(false);
  const [preview, setPreview] = useState<string | null>(currentUrl || null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Validate file type
    if (!file.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    // Validate file size (max 5MB)
    if (file.size > 5 * 1024 * 1024) {
      alert('File size must be less than 5MB');
      return;
    }

    setUploading(true);

    try {
      const formData = new FormData();
      formData.append('file', file);

      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/picture`,
        {
          method: 'POST',
          headers: {
            'x-tenant-id': session?.user?.tenantId || '',
          },
          body: formData,
        }
      );

      if (!response.ok) {
        throw new Error('Failed to upload picture');
      }

      const { data } = await response.json();
      setPreview(data.pictureUrl);
      onUpload?.(data.pictureUrl);
    } catch (error) {
      console.error('Upload error:', error);
      alert('Failed to upload picture');
    } finally {
      setUploading(false);
    }
  };

  const handleDelete = async () => {
    if (!confirm('Delete profile picture?')) return;

    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/picture`,
        {
          method: 'DELETE',
          headers: {
            'x-tenant-id': session?.user?.tenantId || '',
          },
        }
      );

      if (response.ok) {
        setPreview(null);
        onUpload?.('');
      }
    } catch (error) {
      console.error('Delete error:', error);
    }
  };

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="relative">
        {preview ? (
          <img
            src={`${process.env.NEXT_PUBLIC_API_URL}${preview}`}
            alt="Profile"
            className="w-32 h-32 rounded-full object-cover shadow-lg shadow-gray-200/50"
          />
        ) : (
          <div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center text-gray-400">
            <svg
              className="w-12 h-12"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
              />
            </svg>
          </div>
        )}
      </div>

      <input
        ref={fileInputRef}
        type="file"
        accept="image/*"
        onChange={handleFileChange}
        className="hidden"
      />

      <div className="flex gap-2">
        <button
          type="button"
          onClick={() => fileInputRef.current?.click()}
          disabled={uploading}
          className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {uploading ? 'Uploading...' : preview ? 'Change Photo' : 'Upload Photo'}
        </button>

        {preview && (
          <button
            type="button"
            onClick={handleDelete}
            className="px-4 py-2 border border-red-200 text-red-600 rounded-2xl hover:bg-red-50 shadow-sm shadow-red-100/50"
          >
            Remove
          </button>
        )}
      </div>
    </div>
  );
}

Gate

# Test picture upload
curl -X POST http://localhost:3001/api/v1/employees/EMPLOYEE_ID/picture \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -F "file=@/path/to/image.jpg"
# Should return { data: { pictureUrl: "/uploads/profile-pictures/..." } }

# Verify file exists
ls uploads/profile-pictures/
# Should show uploaded file

Checkpoint

  • Picture upload endpoint works
  • File saved to uploads directory
  • Picture URL stored in employee record
  • Component shows preview
  • Type "GATE 44 PASSED" to continue

Step 45: Add Self-Profile Endpoint (EMP-02)

Input

  • Step 44 complete
  • Employee model has all fields

Constraints

  • Employees can only update personal fields (not job title, department, etc.)
  • Use CurrentUser decorator to get employee
  • ONLY add /me endpoints

Task

1. Add Self-Profile Endpoints to apps/api/src/employees/employees.controller.ts:

import { CurrentUser } from '../auth/current-user.decorator';

@Get('me')
async getMyProfile(@CurrentUser() user: any) {
  if (!user?.employeeId) {
    throw new NotFoundException('No employee profile linked to this user');
  }
  const data = await this.employeesService.findByUserId(user.id);
  return { data, error: null };
}

@Patch('me')
async updateMyProfile(
  @CurrentUser() user: any,
  @Body() dto: UpdateMyProfileDto,
) {
  if (!user?.employeeId) {
    throw new NotFoundException('No employee profile linked to this user');
  }
  const data = await this.employeesService.updateSelfProfile(user.id, dto);
  return { data, error: null };
}

2. Create UpdateMyProfileDto at apps/api/src/employees/dto/update-my-profile.dto.ts:

import { IsOptional, IsString, IsEmail } from 'class-validator';

// Only fields an employee can update themselves
export class UpdateMyProfileDto {
  @IsOptional()
  @IsString()
  phone?: string;

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

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

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

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

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

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

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

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

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

3. Add Self-Profile Methods to EmployeesService:

// Add to apps/api/src/employees/employees.service.ts

async findByUserId(userId: string) {
  const employee = await this.prisma.employee.findFirst({
    where: { userId, deletedAt: null },
    include: {
      orgRelations: {
        include: {
          department: true,
          team: true,
          primaryManager: {
            select: {
              id: true,
              firstName: true,
              lastName: true,
              jobTitle: true,
            },
          },
        },
      },
    },
  });

  if (!employee) {
    throw new NotFoundException('Employee profile not found');
  }

  return employee;
}

async updateSelfProfile(userId: string, dto: UpdateMyProfileDto) {
  const employee = await this.prisma.employee.findFirst({
    where: { userId, deletedAt: null },
  });

  if (!employee) {
    throw new NotFoundException('Employee profile not found');
  }

  // Only update allowed fields
  return this.prisma.employee.update({
    where: { id: employee.id },
    data: {
      phone: dto.phone,
      addressLine1: dto.addressLine1,
      addressLine2: dto.addressLine2,
      city: dto.city,
      state: dto.state,
      postalCode: dto.postalCode,
      country: dto.country,
      emergencyContactName: dto.emergencyContactName,
      emergencyContactPhone: dto.emergencyContactPhone,
      emergencyContactRelation: dto.emergencyContactRelation,
    },
  });
}

4. Create My Profile Page at apps/web/app/dashboard/profile/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { ProfilePictureUpload } from '@/components/profile-picture-upload';

export default function MyProfilePage() {
  const { data: session } = useSession();
  const [profile, setProfile] = useState<any>(null);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [formData, setFormData] = useState({
    phone: '',
    addressLine1: '',
    addressLine2: '',
    city: '',
    state: '',
    postalCode: '',
    country: '',
    emergencyContactName: '',
    emergencyContactPhone: '',
    emergencyContactRelation: '',
  });

  useEffect(() => {
    if (session?.user?.tenantId) {
      fetchProfile();
    }
  }, [session]);

  const fetchProfile = async () => {
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/me`,
        {
          headers: {
            'x-tenant-id': session!.user.tenantId!,
          },
        }
      );
      if (response.ok) {
        const { data } = await response.json();
        setProfile(data);
        setFormData({
          phone: data.phone || '',
          addressLine1: data.addressLine1 || '',
          addressLine2: data.addressLine2 || '',
          city: data.city || '',
          state: data.state || '',
          postalCode: data.postalCode || '',
          country: data.country || '',
          emergencyContactName: data.emergencyContactName || '',
          emergencyContactPhone: data.emergencyContactPhone || '',
          emergencyContactRelation: data.emergencyContactRelation || '',
        });
      }
    } catch (error) {
      console.error('Failed to fetch profile:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaving(true);

    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/me`,
        {
          method: 'PATCH',
          headers: {
            'Content-Type': 'application/json',
            'x-tenant-id': session!.user.tenantId!,
          },
          body: JSON.stringify(formData),
        }
      );

      if (response.ok) {
        alert('Profile updated successfully!');
      }
    } catch (error) {
      console.error('Failed to update profile:', error);
      alert('Failed to update profile');
    } finally {
      setSaving(false);
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormData(prev => ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  };

  if (loading) return <div className="p-6">Loading...</div>;
  if (!profile) return <div className="p-6">Profile not found</div>;

  return (
    <div className="p-6 max-w-4xl">
      <h1 className="text-2xl font-bold mb-6">My Profile</h1>

      {/* Read-only Info */}
      <div className="bg-gray-50 rounded-2xl p-6 mb-6">
        <div className="flex items-start gap-6">
          <ProfilePictureUpload
            employeeId={profile.id}
            currentUrl={profile.pictureUrl}
          />
          <div>
            <h2 className="text-xl font-semibold">
              {profile.firstName} {profile.lastName}
            </h2>
            <p className="text-gray-600">{profile.jobTitle || 'No title'}</p>
            <p className="text-gray-500">{profile.email}</p>
            <p className="text-sm text-gray-400 mt-2">
              Employee #{profile.employeeNumber || 'N/A'}
            </p>
          </div>
        </div>
      </div>

      {/* Editable Form */}
      <form onSubmit={handleSubmit} className="space-y-6">
        {/* Contact Info */}
        <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
          <h2 className="text-lg font-medium mb-4">Contact Information</h2>
          <div>
            <label className="block text-sm font-medium text-gray-700">Phone</label>
            <input
              type="tel"
              name="phone"
              value={formData.phone}
              onChange={handleChange}
              className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
            />
          </div>
        </div>

        {/* Address */}
        <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
          <h2 className="text-lg font-medium mb-4">Address</h2>
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <div className="md:col-span-2">
              <label className="block text-sm font-medium text-gray-700">Address Line 1</label>
              <input
                type="text"
                name="addressLine1"
                value={formData.addressLine1}
                onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200"
              />
            </div>
            <div className="md:col-span-2">
              <label className="block text-sm font-medium text-gray-700">Address Line 2</label>
              <input
                type="text"
                name="addressLine2"
                value={formData.addressLine2}
                onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200"
              />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">City</label>
              <input type="text" name="city" value={formData.city} onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">State/Province</label>
              <input type="text" name="state" value={formData.state} onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">Postal Code</label>
              <input type="text" name="postalCode" value={formData.postalCode} onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">Country</label>
              <input type="text" name="country" value={formData.country} onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
          </div>
        </div>

        {/* Emergency Contact */}
        <div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
          <h2 className="text-lg font-medium mb-4">Emergency Contact</h2>
          <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
            <div>
              <label className="block text-sm font-medium text-gray-700">Contact Name</label>
              <input type="text" name="emergencyContactName" value={formData.emergencyContactName}
                onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">Phone</label>
              <input type="tel" name="emergencyContactPhone" value={formData.emergencyContactPhone}
                onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
            <div>
              <label className="block text-sm font-medium text-gray-700">Relationship</label>
              <input type="text" name="emergencyContactRelation" value={formData.emergencyContactRelation}
                onChange={handleChange}
                className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
            </div>
          </div>
        </div>

        {/* Submit */}
        <div className="flex justify-end">
          <button
            type="submit"
            disabled={saving}
            className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
          >
            {saving ? 'Saving...' : 'Save Changes'}
          </button>
        </div>
      </form>
    </div>
  );
}

Gate

# Test self-profile endpoint
curl -H "x-tenant-id: YOUR_TENANT_ID" \
     http://localhost:3001/api/v1/employees/me
# Should return current user's employee profile

# Test update
curl -X PATCH http://localhost:3001/api/v1/employees/me \
  -H "Content-Type: application/json" \
  -H "x-tenant-id: YOUR_TENANT_ID" \
  -d '{"phone":"+1-555-0199","city":"Austin"}'
# Should return updated profile

Checkpoint

  • GET /employees/me returns own profile
  • PATCH /employees/me updates personal fields only
  • Cannot update job title, department via /me
  • My Profile page works
  • Type "GATE 45 PASSED" to continue

Step 46: Add Global Search Endpoint (SRCH-01)

Input

  • Step 45 complete
  • Employees, documents, teams exist

Constraints

  • Search across employees, documents, teams
  • Case-insensitive search
  • ONLY create search module

Task

1. Create Search Module at apps/api/src/search/:

mkdir -p apps/api/src/search

Create apps/api/src/search/search.service.ts:

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

interface SearchResults {
  employees: any[];
  documents: any[];
  teams: any[];
}

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

  async search(
    query: string,
    type: 'all' | 'employees' | 'documents' | 'teams',
    tenantId: string,
  ): Promise<SearchResults> {
    const results: SearchResults = {
      employees: [],
      documents: [],
      teams: [],
    };

    if (type === 'all' || type === 'employees') {
      results.employees = await this.prisma.employee.findMany({
        where: {
          tenantId,
          deletedAt: null,
          OR: [
            { firstName: { contains: query, mode: 'insensitive' } },
            { lastName: { contains: query, mode: 'insensitive' } },
            { email: { contains: query, mode: 'insensitive' } },
            { jobTitle: { contains: query, mode: 'insensitive' } },
            { employeeNumber: { contains: query, mode: 'insensitive' } },
          ],
        },
        select: {
          id: true,
          firstName: true,
          lastName: true,
          email: true,
          jobTitle: true,
          pictureUrl: true,
        },
        take: 10,
        orderBy: { lastName: 'asc' },
      });
    }

    if (type === 'all' || type === 'documents') {
      results.documents = await this.prisma.document.findMany({
        where: {
          tenantId,
          deletedAt: null,
          OR: [
            { title: { contains: query, mode: 'insensitive' } },
            { description: { contains: query, mode: 'insensitive' } },
          ],
        },
        select: {
          id: true,
          title: true,
          description: true,
          fileType: true,
          createdAt: true,
        },
        take: 10,
        orderBy: { createdAt: 'desc' },
      });
    }

    if (type === 'all' || type === 'teams') {
      results.teams = await this.prisma.team.findMany({
        where: {
          tenantId,
          name: { contains: query, mode: 'insensitive' },
        },
        select: {
          id: true,
          name: true,
          description: true,
          type: true,
        },
        take: 10,
        orderBy: { name: 'asc' },
      });
    }

    return results;
  }
}

Create apps/api/src/search/search.controller.ts:

import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
import { SearchService } from './search.service';

@Controller('api/v1/search')
@UseGuards(TenantGuard)
export class SearchController {
  constructor(private readonly searchService: SearchService) {}

  @Get()
  async search(
    @Query('q') query: string,
    @Query('type') type: 'all' | 'employees' | 'documents' | 'teams' = 'all',
    @TenantId() tenantId: string,
  ) {
    if (!query || query.length < 2) {
      return { data: { employees: [], documents: [], teams: [] }, error: null };
    }

    const data = await this.searchService.search(query, type, tenantId);
    return { data, error: null };
  }
}

Create apps/api/src/search/search.module.ts:

import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [SearchController],
  providers: [SearchService],
  exports: [SearchService],
})
export class SearchModule {}

Create apps/api/src/search/index.ts:

export * from './search.module';
export * from './search.service';
export * from './search.controller';

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

import { SearchModule } from './search/search.module';

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

3. Create Global Search Component at apps/web/components/global-search.tsx:

'use client';

import { useState, useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useDebounce } from '@/hooks/use-debounce';

export function GlobalSearch() {
  const { data: session } = useSession();
  const router = useRouter();
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<any>(null);
  const [isOpen, setIsOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const debouncedQuery = useDebounce(query, 300);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (debouncedQuery.length >= 2) {
      search(debouncedQuery);
    } else {
      setResults(null);
    }
  }, [debouncedQuery]);

  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  const search = async (q: string) => {
    setLoading(true);
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(q)}`,
        {
          headers: {
            'x-tenant-id': session?.user?.tenantId || '',
          },
        }
      );
      if (response.ok) {
        const { data } = await response.json();
        setResults(data);
        setIsOpen(true);
      }
    } catch (error) {
      console.error('Search error:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleSelect = (type: string, id: string) => {
    setIsOpen(false);
    setQuery('');
    switch (type) {
      case 'employee':
        router.push(`/dashboard/employees/${id}`);
        break;
      case 'document':
        router.push(`/dashboard/documents/${id}`);
        break;
      case 'team':
        router.push(`/dashboard/teams/${id}`);
        break;
    }
  };

  const totalResults = results
    ? results.employees.length + results.documents.length + results.teams.length
    : 0;

  return (
    <div ref={containerRef} className="relative w-full max-w-md">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onFocus={() => results && setIsOpen(true)}
        placeholder="Search employees, documents, teams..."
        className="w-full px-4 py-3 bg-gray-100 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500"
      />

      {loading && (
        <div className="absolute right-3 top-2.5">
          <div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
        </div>
      )}

      {isOpen && results && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-2xl shadow-lg shadow-gray-200/50 z-50 max-h-96 overflow-y-auto">
          {totalResults === 0 ? (
            <div className="p-4 text-center text-gray-500">No results found</div>
          ) : (
            <>
              {results.employees.length > 0 && (
                <div className="p-2">
                  <div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
                    Employees
                  </div>
                  {results.employees.map((emp: any) => (
                    <button
                      key={emp.id}
                      onClick={() => handleSelect('employee', emp.id)}
                      className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded flex items-center gap-3"
                    >
                      {emp.pictureUrl ? (
                        <img src={emp.pictureUrl} className="w-8 h-8 rounded-full" alt="" />
                      ) : (
                        <div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-sm">
                          {emp.firstName[0]}{emp.lastName[0]}
                        </div>
                      )}
                      <div>
                        <div className="font-medium">{emp.firstName} {emp.lastName}</div>
                        <div className="text-sm text-gray-500">{emp.jobTitle || emp.email}</div>
                      </div>
                    </button>
                  ))}
                </div>
              )}

              {results.documents.length > 0 && (
                <div className="p-2 border-t">
                  <div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
                    Documents
                  </div>
                  {results.documents.map((doc: any) => (
                    <button
                      key={doc.id}
                      onClick={() => handleSelect('document', doc.id)}
                      className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded"
                    >
                      <div className="font-medium">{doc.title}</div>
                      <div className="text-sm text-gray-500">{doc.fileType}</div>
                    </button>
                  ))}
                </div>
              )}

              {results.teams.length > 0 && (
                <div className="p-2 border-t">
                  <div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
                    Teams
                  </div>
                  {results.teams.map((team: any) => (
                    <button
                      key={team.id}
                      onClick={() => handleSelect('team', team.id)}
                      className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded"
                    >
                      <div className="font-medium">{team.name}</div>
                      <div className="text-sm text-gray-500">{team.type}</div>
                    </button>
                  ))}
                </div>
              )}
            </>
          )}
        </div>
      )}
    </div>
  );
}

4. Create useDebounce hook at apps/web/hooks/use-debounce.ts:

import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]);

  return debouncedValue;
}

5. Add Global Search to Dashboard Layout - Update header in apps/web/app/dashboard/layout.tsx:

import { GlobalSearch } from '@/components/global-search';

// In the header section:
<header className="...">
  <h1>HRMS Dashboard</h1>
  <div className="flex-1 max-w-md mx-4">
    <GlobalSearch />
  </div>
  <div>...</div>
</header>

Gate

# Test search endpoint
curl "http://localhost:3001/api/v1/search?q=john" \
  -H "x-tenant-id: YOUR_TENANT_ID"
# Should return { data: { employees: [...], documents: [...], teams: [...] } }

# Test with type filter
curl "http://localhost:3001/api/v1/search?q=john&type=employees" \
  -H "x-tenant-id: YOUR_TENANT_ID"
# Should return only employees

Checkpoint

  • Search endpoint returns results from all types
  • Type filter works (employees, documents, teams)
  • Global search component shows in header
  • Clicking result navigates to detail page
  • Type "GATE 46 PASSED" to continue

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
  • Employee CRUD working (create, read, update, soft delete)
  • Profile picture upload functional
  • Global search working

2. Update PROJECT_STATE.md

- Mark Phase 02 as COMPLETED with timestamp
- Add locked files to "Locked Files" section
- Update "Current Phase" to Phase 03
- Add session log entry

3. Update WHAT_EXISTS.md

## Database Models
- Employee (with all org relations)

## API Endpoints
- CRUD for /api/v1/employees
- GET /api/v1/search

## Frontend Routes
- /dashboard/employees/*

## Established Patterns
- EmployeesModule: apps/api/src/employees/
- EmployeeList component pattern

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 02 - Employee Entity"
git tag phase-02-employee-entity

Next Phase

After verification, proceed to Phase 03: Org Structure


Last Updated: 2025-11-30

On this page

Phase 02: Employee EntityStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeKnown Limitations (MVP)Bluewoo Anti-Pattern ReminderStep 25: Add EmploymentType EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 26: Add WorkMode EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 27: Add EmployeeStatus EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 28: Add Employee Model (Full Schema)InputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 29: Link User to Employee (Optional 1:1)InputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 30: Run MigrationInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 31: Create CreateEmployeeDtoInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 32: Create UpdateEmployeeDtoInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 33: Create EmployeeRepositoryInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 34: Create EmployeeServiceInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 35: Create EmployeeControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 36: Register EmployeeModuleInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 37: Test Employee CRUD via curlInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 38: Create Employee List Page (Frontend)InputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 39: Create Employee Form ComponentInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 40: Create New Employee PageInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 41: Create Edit Employee PageInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepStep 42: Add Delete FunctionalityInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointFiles Created/Modified This StepPhase 02 SummaryWhat Was BuiltLocked FilesKey Patterns EstablishedNext PhaseAnti-Patterns to Avoid in Future PhasesStep 43: Add Address and Emergency Contact Fields (UO-07)InputConstraintsTaskGateCheckpointStep 44: Add Profile Picture Upload (UO-04)InputConstraintsTaskGateCheckpointStep 45: Add Self-Profile Endpoint (EMP-02)InputConstraintsTaskGateCheckpointStep 46: Add Global Search Endpoint (SRCH-01)InputConstraintsTaskGateCheckpointPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase