workspace-v1/projects/erp-construccion/docs/02-definicion-modulos/MAI-007-rrhh-asistencias/especificaciones/ET-HR-001-empleados-cuadrillas.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

33 KiB

ET-HR-001: Implementación de Empleados y Cuadrillas

Epic: MAI-007 - RRHH, Asistencias y Nómina RF: RF-HR-001 Tipo: Especificación Técnica Prioridad: Alta Estado: 🚧 En Implementación Última actualización: 2025-11-17


🏗️ Arquitectura

┌─────────────────────────────────────────────────────┐
│                    Frontend (React)                  │
│  ┌──────────────┐  ┌──────────────┐  ┌────────────┐ │
│  │ EmployeeList │  │ EmployeeForm │  │ CrewManager│ │
│  └──────────────┘  └──────────────┘  └────────────┘ │
└────────────────────────┬────────────────────────────┘
                         │ REST API
┌────────────────────────▼────────────────────────────┐
│              Backend (NestJS + TypeORM)              │
│  ┌──────────────────────────────────────────────┐   │
│  │            HR Module                          │   │
│  │  ┌──────────────┐  ┌────────────────────┐    │   │
│  │  │EmployeeService│  │CrewService         │    │   │
│  │  └──────────────┘  └────────────────────┘    │   │
│  │  ┌──────────────┐  ┌────────────────────┐    │   │
│  │  │EmployeeCtrl  │  │CrewController      │    │   │
│  │  └──────────────┘  └────────────────────┘    │   │
│  └──────────────────────────────────────────────┘   │
└────────────────────────┬────────────────────────────┘
                         │ TypeORM
┌────────────────────────▼────────────────────────────┐
│                PostgreSQL Database                   │
│  Schemas: hr                                         │
│  Tables: employees, crews, crew_members, trades,    │
│          employee_work_assignments, salary_history   │
│  Triggers: generate_employee_code, generate_qr,     │
│           track_salary_changes                       │
└──────────────────────────────────────────────────────┘

📁 Estructura de Archivos

apps/backend/src/modules/hr/
├── employees/
│   ├── dto/
│   │   ├── create-employee.dto.ts
│   │   ├── update-employee.dto.ts
│   │   └── employee-response.dto.ts
│   ├── entities/
│   │   ├── employee.entity.ts
│   │   ├── trade.entity.ts
│   │   └── salary-history.entity.ts
│   ├── employees.controller.ts
│   ├── employees.service.ts
│   └── employees.module.ts
├── crews/
│   ├── dto/
│   │   ├── create-crew.dto.ts
│   │   └── assign-member.dto.ts
│   ├── entities/
│   │   ├── crew.entity.ts
│   │   └── crew-member.entity.ts
│   ├── crews.controller.ts
│   ├── crews.service.ts
│   └── crews.module.ts
├── enums/
│   ├── employee-status.enum.ts
│   ├── contract-type.enum.ts
│   └── work-shift.enum.ts
└── hr.module.ts

apps/frontend/src/features/hr/
├── employees/
│   ├── components/
│   │   ├── EmployeeList.tsx
│   │   ├── EmployeeForm.tsx
│   │   ├── EmployeeCard.tsx
│   │   └── DocumentUpload.tsx
│   ├── pages/
│   │   ├── EmployeesPage.tsx
│   │   └── EmployeeDetailPage.tsx
│   └── hooks/
│       ├── useEmployees.ts
│       └── useEmployeeMutations.ts
└── crews/
    ├── components/
    │   ├── CrewList.tsx
    │   └── CrewForm.tsx
    └── pages/
        └── CrewsPage.tsx

🔧 Implementación Backend

1. Employee Entity

Archivo: apps/backend/src/modules/hr/employees/entities/employee.entity.ts

import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
  UpdateDateColumn,
  DeleteDateColumn,
  OneToMany,
} from 'typeorm';
import { Constructora } from '../../../auth/entities/constructora.entity';
import { Trade } from './trade.entity';
import { SalaryHistory } from './salary-history.entity';
import { EmployeeStatus } from '../../enums/employee-status.enum';
import { ContractType } from '../../enums/contract-type.enum';
import { WorkShift } from '../../enums/work-shift.enum';

@Entity('employees', { schema: 'hr' })
export class Employee {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 20, unique: true })
  employeeCode: string;

  @Column({ type: 'uuid' })
  constructoraId: string;

  @ManyToOne(() => Constructora)
  @JoinColumn({ name: 'constructoraId' })
  constructora: Constructora;

  // Datos personales
  @Column({ type: 'varchar', length: 100 })
  firstName: string;

  @Column({ type: 'varchar', length: 100 })
  lastName: string;

  @Column({ type: 'date' })
  dateOfBirth: Date;

  @Column({ type: 'varchar', length: 20 })
  gender: 'male' | 'female' | 'other';

  @Column({ type: 'varchar', length: 20, nullable: true })
  maritalStatus: 'single' | 'married' | 'divorced' | 'widowed';

  // Datos fiscales (CRÍTICOS)
  @Column({ type: 'varchar', length: 18, unique: true })
  curp: string;

  @Column({ type: 'varchar', length: 13 })
  rfc: string;

  @Column({ type: 'varchar', length: 11, unique: true })
  nss: string;

  @Column({ type: 'varchar', length: 11, nullable: true })
  infonavitNumber: string;

  // Contacto
  @Column({ type: 'varchar', length: 20 })
  phone: string;

  @Column({ type: 'varchar', length: 255, nullable: true })
  email: string;

  @Column({ type: 'text', nullable: true })
  address: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  city: string;

  @Column({ type: 'varchar', length: 100, nullable: true })
  state: string;

  @Column({ type: 'varchar', length: 10, nullable: true })
  postalCode: string;

  // Datos laborales
  @Column({ type: 'uuid', nullable: true })
  primaryTradeId: string;

  @ManyToOne(() => Trade)
  @JoinColumn({ name: 'primaryTradeId' })
  primaryTrade: Trade;

  @Column({ type: 'date' })
  hireDate: Date;

  @Column({
    type: 'enum',
    enum: ContractType,
    default: ContractType.PERMANENT,
  })
  contractType: ContractType;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  baseDailySalary: number;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  currentSalary: number;

  @Column({
    type: 'enum',
    enum: WorkShift,
    default: WorkShift.DAY,
  })
  workShift: WorkShift;

  // Estado
  @Column({
    type: 'enum',
    enum: EmployeeStatus,
    default: EmployeeStatus.ACTIVE,
  })
  status: EmployeeStatus;

  @Column({ type: 'date', nullable: true })
  terminationDate: Date;

  @Column({ type: 'text', nullable: true })
  terminationReason: string;

  @Column({ type: 'date', nullable: true })
  suspendedUntil: Date;

  @Column({ type: 'text', nullable: true })
  suspensionReason: string;

  // QR para asistencia
  @Column({ type: 'text', unique: true, nullable: true })
  qrCode: string;

  // Documentos (URLs a S3)
  @Column({ type: 'jsonb', default: [] })
  documents: Array<{
    type: string;
    url: string;
    uploadedAt: Date;
  }>;

  // Relaciones
  @OneToMany(() => SalaryHistory, (history) => history.employee)
  salaryHistory: SalaryHistory[];

  // Metadata
  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date;

  @Column({ type: 'uuid', nullable: true })
  createdBy: string;

  // Computed properties
  get fullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }

  get age(): number {
    const today = new Date();
    const birthDate = new Date(this.dateOfBirth);
    let age = today.getFullYear() - birthDate.getFullYear();
    const monthDiff = today.getMonth() - birthDate.getMonth();
    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
      age--;
    }
    return age;
  }
}

2. CreateEmployeeDto

Archivo: apps/backend/src/modules/hr/employees/dto/create-employee.dto.ts

import {
  IsString,
  IsNotEmpty,
  IsEmail,
  IsOptional,
  IsDateString,
  IsEnum,
  IsNumber,
  Min,
  Length,
  Matches,
  IsUUID,
  ValidateIf,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ContractType } from '../../enums/contract-type.enum';
import { WorkShift } from '../../enums/work-shift.enum';

export class CreateEmployeeDto {
  @ApiProperty({ example: 'Juan' })
  @IsString()
  @IsNotEmpty()
  @Length(2, 100)
  firstName: string;

  @ApiProperty({ example: 'Pérez López' })
  @IsString()
  @IsNotEmpty()
  @Length(2, 100)
  lastName: string;

  @ApiProperty({ example: '1990-01-15' })
  @IsDateString()
  dateOfBirth: string;

  @ApiProperty({ enum: ['male', 'female', 'other'] })
  @IsEnum(['male', 'female', 'other'])
  gender: 'male' | 'female' | 'other';

  @ApiPropertyOptional({ enum: ['single', 'married', 'divorced', 'widowed'] })
  @IsOptional()
  @IsEnum(['single', 'married', 'divorced', 'widowed'])
  maritalStatus?: 'single' | 'married' | 'divorced' | 'widowed';

  // Datos fiscales
  @ApiProperty({ example: 'ABCD123456HDFRNN09', description: 'CURP (18 caracteres)' })
  @IsString()
  @Length(18, 18)
  @Matches(/^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9]{2}$/, {
    message: 'CURP inválido. Formato: 4 letras + 6 dígitos + H/M + 5 letras + 2 dígitos',
  })
  curp: string;

  @ApiProperty({ example: 'ABCD123456ABC', description: 'RFC (13 caracteres)' })
  @IsString()
  @Length(13, 13)
  @Matches(/^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, {
    message: 'RFC inválido',
  })
  rfc: string;

  @ApiProperty({ example: '12345678901', description: 'NSS (11 dígitos)' })
  @IsString()
  @Length(11, 11)
  @Matches(/^[0-9]{11}$/, {
    message: 'NSS debe tener 11 dígitos',
  })
  nss: string;

  @ApiPropertyOptional({ example: '9876543210' })
  @IsOptional()
  @IsString()
  @Length(10, 11)
  infonavitNumber?: string;

  // Contacto
  @ApiProperty({ example: '5512345678' })
  @IsString()
  @Matches(/^[0-9]{10}$/, {
    message: 'Teléfono debe tener 10 dígitos',
  })
  phone: string;

  @ApiPropertyOptional({ example: 'juan.perez@example.com' })
  @IsOptional()
  @IsEmail()
  email?: string;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  address?: string;

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

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

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

  // Datos laborales
  @ApiPropertyOptional({ description: 'UUID del oficio principal' })
  @IsOptional()
  @IsUUID()
  primaryTradeId?: string;

  @ApiProperty({ example: '2025-01-15' })
  @IsDateString()
  hireDate: string;

  @ApiProperty({ enum: ContractType })
  @IsEnum(ContractType)
  contractType: ContractType;

  @ApiProperty({ example: 350.0, description: 'Salario Diario Integrado (SDI)' })
  @IsNumber()
  @Min(248.93) // Salario mínimo México 2025
  baseDailySalary: number;

  @ApiProperty({ enum: WorkShift })
  @IsEnum(WorkShift)
  workShift: WorkShift;

  @ApiPropertyOptional({ description: 'UUID de constructora (opcional si se infiere del usuario)' })
  @IsOptional()
  @IsUUID()
  constructoraId?: string;
}

3. EmployeeService

Archivo: apps/backend/src/modules/hr/employees/employees.service.ts

import {
  Injectable,
  NotFoundException,
  ConflictException,
  BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Employee } from './entities/employee.entity';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { PaginationDto } from '../../../common/dto/pagination.dto';
import { EmployeeStatus } from '../enums/employee-status.enum';
import { randomBytes } from 'crypto';

@Injectable()
export class EmployeesService {
  constructor(
    @InjectRepository(Employee)
    private employeeRepo: Repository<Employee>,
  ) {}

  /**
   * Crear nuevo empleado
   */
  async create(createDto: CreateEmployeeDto, userId: string): Promise<Employee> {
    // Validar edad mínima (18 años)
    const birthDate = new Date(createDto.dateOfBirth);
    const age = this.calculateAge(birthDate);
    if (age < 18) {
      throw new BadRequestException('El empleado debe ser mayor de 18 años');
    }

    // Validar NSS único
    const existingNss = await this.employeeRepo.findOne({
      where: { nss: createDto.nss },
    });
    if (existingNss) {
      throw new ConflictException('El NSS ya está registrado');
    }

    // Validar CURP único
    const existingCurp = await this.employeeRepo.findOne({
      where: { curp: createDto.curp },
    });
    if (existingCurp) {
      throw new ConflictException('El CURP ya está registrado');
    }

    // Crear empleado
    const employee = this.employeeRepo.create({
      ...createDto,
      currentSalary: createDto.baseDailySalary,
      status: EmployeeStatus.ACTIVE,
      createdBy: userId,
    });

    // Guardar (triggers de BD generarán employeeCode y qrCode)
    return await this.employeeRepo.save(employee);
  }

  /**
   * Listar empleados con paginación y filtros
   */
  async findAll(
    paginationDto: PaginationDto,
    constructoraId: string,
    filters?: {
      status?: EmployeeStatus;
      search?: string;
      tradeId?: string;
    },
  ) {
    const { page, limit, skip } = paginationDto;

    const query = this.employeeRepo
      .createQueryBuilder('employee')
      .leftJoinAndSelect('employee.primaryTrade', 'trade')
      .where('employee.constructoraId = :constructoraId', { constructoraId })
      .andWhere('employee.deletedAt IS NULL');

    // Filtros
    if (filters?.status) {
      query.andWhere('employee.status = :status', { status: filters.status });
    }

    if (filters?.search) {
      query.andWhere(
        '(employee.firstName ILIKE :search OR employee.lastName ILIKE :search OR employee.employeeCode ILIKE :search)',
        { search: `%${filters.search}%` },
      );
    }

    if (filters?.tradeId) {
      query.andWhere('employee.primaryTradeId = :tradeId', { tradeId: filters.tradeId });
    }

    // Paginación
    const [items, total] = await query
      .orderBy('employee.createdAt', 'DESC')
      .skip(skip)
      .take(limit)
      .getManyAndCount();

    return { items, total };
  }

  /**
   * Obtener empleado por ID
   */
  async findOne(id: string): Promise<Employee> {
    const employee = await this.employeeRepo.findOne({
      where: { id },
      relations: ['primaryTrade', 'salaryHistory'],
    });

    if (!employee) {
      throw new NotFoundException('Empleado no encontrado');
    }

    return employee;
  }

  /**
   * Actualizar empleado
   */
  async update(id: string, updateDto: UpdateEmployeeDto): Promise<Employee> {
    const employee = await this.findOne(id);

    // Validar NSS único (si cambió)
    if (updateDto.nss && updateDto.nss !== employee.nss) {
      const existingNss = await this.employeeRepo.findOne({
        where: { nss: updateDto.nss },
      });
      if (existingNss) {
        throw new ConflictException('El NSS ya está registrado');
      }
    }

    // Actualizar
    Object.assign(employee, updateDto);
    return await this.employeeRepo.save(employee);
  }

  /**
   * Modificar salario
   */
  async updateSalary(
    id: string,
    newSalary: number,
    reason: string,
    authorizedBy: string,
  ): Promise<Employee> {
    const employee = await this.findOne(id);

    // Validar salario mínimo
    if (newSalary < 248.93) {
      throw new BadRequestException('El salario no puede ser menor al salario mínimo');
    }

    // Advertencia si reducción > 20%
    const reduction = ((employee.currentSalary - newSalary) / employee.currentSalary) * 100;
    if (reduction > 20) {
      // Log warning (podría enviar notificación)
      console.warn(`Reducción salarial de ${reduction.toFixed(2)}% para empleado ${id}`);
    }

    employee.currentSalary = newSalary;
    return await this.employeeRepo.save(employee);
    // El trigger de BD guardará en salary_history automáticamente
  }

  /**
   * Suspender empleado
   */
  async suspend(
    id: string,
    reason: string,
    suspendedUntil?: Date,
  ): Promise<Employee> {
    const employee = await this.findOne(id);

    employee.status = EmployeeStatus.SUSPENDED;
    employee.suspensionReason = reason;
    employee.suspendedUntil = suspendedUntil;

    return await this.employeeRepo.save(employee);
  }

  /**
   * Reactivar empleado suspendido
   */
  async reactivate(id: string): Promise<Employee> {
    const employee = await this.findOne(id);

    if (employee.status !== EmployeeStatus.SUSPENDED) {
      throw new BadRequestException('El empleado no está suspendido');
    }

    employee.status = EmployeeStatus.ACTIVE;
    employee.suspensionReason = null;
    employee.suspendedUntil = null;

    return await this.employeeRepo.save(employee);
  }

  /**
   * Dar de baja empleado
   */
  async terminate(
    id: string,
    reason: string,
    terminationDate: Date,
  ): Promise<Employee> {
    const employee = await this.findOne(id);

    // Validar que no haya asistencias futuras (se implementará en RF-HR-002)

    employee.status = EmployeeStatus.TERMINATED;
    employee.terminationReason = reason;
    employee.terminationDate = terminationDate;

    return await this.employeeRepo.save(employee);
  }

  /**
   * Soft delete
   */
  async remove(id: string): Promise<void> {
    const employee = await this.findOne(id);
    await this.employeeRepo.softRemove(employee);
  }

  /**
   * Generar QR code único
   */
  private generateQrCode(employeeId: string): string {
    const timestamp = Date.now();
    const randomPart = randomBytes(8).toString('hex');
    const payload = `${employeeId}-${timestamp}-${randomPart}`;
    return Buffer.from(payload).toString('base64');
  }

  /**
   * Calcular edad
   */
  private calculateAge(birthDate: Date): number {
    const today = new Date();
    let age = today.getFullYear() - birthDate.getFullYear();
    const monthDiff = today.getMonth() - birthDate.getMonth();
    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
      age--;
    }
    return age;
  }
}

4. EmployeeController

Archivo: apps/backend/src/modules/hr/employees/employees.controller.ts

import {
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { EmployeesService } from './employees.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { PaginationDto } from '../../../common/dto/pagination.dto';
import { Roles } from '../../../common/decorators/roles.decorator';
import { ConstructionRole } from '../../../common/enums/construction-role.enum';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { CurrentConstructora } from '../../../common/decorators/current-constructora.decorator';
import { EmployeeStatus } from '../enums/employee-status.enum';

@ApiTags('HR - Employees')
@ApiBearerAuth('JWT-auth')
@Controller('hr/employees')
export class EmployeesController {
  constructor(private readonly employeesService: EmployeesService) {}

  @Post()
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR)
  @ApiOperation({ summary: 'Crear nuevo empleado' })
  @ApiResponse({ status: 201, description: 'Empleado creado' })
  async create(
    @Body() createDto: CreateEmployeeDto,
    @CurrentUser('id') userId: string,
    @CurrentConstructora() constructoraId: string,
  ) {
    const employee = await this.employeesService.create(
      { ...createDto, constructoraId },
      userId,
    );

    return {
      statusCode: 201,
      message: 'Empleado creado exitosamente',
      data: employee,
    };
  }

  @Get()
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR, ConstructionRole.FINANCE)
  @ApiOperation({ summary: 'Listar empleados' })
  async findAll(
    @Query() paginationDto: PaginationDto,
    @Query('status') status?: EmployeeStatus,
    @Query('search') search?: string,
    @Query('tradeId') tradeId?: string,
    @CurrentConstructora() constructoraId?: string,
  ) {
    const { items, total } = await this.employeesService.findAll(
      paginationDto,
      constructoraId,
      { status, search, tradeId },
    );

    return {
      statusCode: 200,
      message: 'Empleados obtenidos exitosamente',
      data: { items, total, page: paginationDto.page, limit: paginationDto.limit },
    };
  }

  @Get(':id')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR, ConstructionRole.RESIDENT)
  @ApiOperation({ summary: 'Obtener empleado por ID' })
  async findOne(@Param('id', ParseUUIDPipe) id: string) {
    const employee = await this.employeesService.findOne(id);

    return {
      statusCode: 200,
      message: 'Empleado obtenido exitosamente',
      data: employee,
    };
  }

  @Patch(':id')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR)
  @ApiOperation({ summary: 'Actualizar empleado' })
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateDto: UpdateEmployeeDto,
  ) {
    const employee = await this.employeesService.update(id, updateDto);

    return {
      statusCode: 200,
      message: 'Empleado actualizado exitosamente',
      data: employee,
    };
  }

  @Patch(':id/salary')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR)
  @ApiOperation({ summary: 'Modificar salario de empleado' })
  async updateSalary(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('newSalary') newSalary: number,
    @Body('reason') reason: string,
    @CurrentUser('id') userId: string,
  ) {
    const employee = await this.employeesService.updateSalary(
      id,
      newSalary,
      reason,
      userId,
    );

    return {
      statusCode: 200,
      message: 'Salario actualizado exitosamente',
      data: employee,
    };
  }

  @Patch(':id/suspend')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR)
  @ApiOperation({ summary: 'Suspender empleado' })
  async suspend(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('reason') reason: string,
    @Body('suspendedUntil') suspendedUntil?: string,
  ) {
    const employee = await this.employeesService.suspend(
      id,
      reason,
      suspendedUntil ? new Date(suspendedUntil) : null,
    );

    return {
      statusCode: 200,
      message: 'Empleado suspendido exitosamente',
      data: employee,
    };
  }

  @Patch(':id/reactivate')
  @Roles(ConstructionRole.DIRECTOR, ConstructionRole.HR)
  @ApiOperation({ summary: 'Reactivar empleado suspendido' })
  async reactivate(@Param('id', ParseUUIDPipe) id: string) {
    const employee = await this.employeesService.reactivate(id);

    return {
      statusCode: 200,
      message: 'Empleado reactivado exitosamente',
      data: employee,
    };
  }

  @Delete(':id')
  @Roles(ConstructionRole.DIRECTOR)
  @ApiOperation({ summary: 'Dar de baja empleado' })
  async terminate(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('reason') reason: string,
    @Body('terminationDate') terminationDate: string,
  ) {
    const employee = await this.employeesService.terminate(
      id,
      reason,
      new Date(terminationDate),
    );

    return {
      statusCode: 200,
      message: 'Empleado dado de baja exitosamente',
      data: employee,
    };
  }
}

🎨 Implementación Frontend

EmployeeForm Component

Archivo: apps/frontend/src/features/hr/employees/components/EmployeeForm.tsx

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { toast } from 'sonner';

const employeeSchema = z.object({
  firstName: z.string().min(2, 'Mínimo 2 caracteres').max(100),
  lastName: z.string().min(2, 'Mínimo 2 caracteres').max(100),
  dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  gender: z.enum(['male', 'female', 'other']),
  curp: z
    .string()
    .length(18, 'CURP debe tener 18 caracteres')
    .regex(/^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9]{2}$/, 'CURP inválido'),
  rfc: z
    .string()
    .length(13, 'RFC debe tener 13 caracteres')
    .regex(/^[A-ZÑ&]{3,4}[0-9]{6}[A-Z0-9]{3}$/, 'RFC inválido'),
  nss: z
    .string()
    .length(11, 'NSS debe tener 11 dígitos')
    .regex(/^[0-9]{11}$/, 'NSS debe ser numérico'),
  phone: z.string().regex(/^[0-9]{10}$/, 'Teléfono debe tener 10 dígitos'),
  email: z.string().email('Email inválido').optional(),
  hireDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  contractType: z.enum(['permanent', 'temporary', 'per_project']),
  baseDailySalary: z.number().min(248.93, 'No puede ser menor al salario mínimo'),
  workShift: z.enum(['day', 'night', 'mixed']),
});

type EmployeeFormData = z.infer<typeof employeeSchema>;

export function EmployeeForm({ onSubmit, initialData }: { onSubmit: (data: EmployeeFormData) => void, initialData?: Partial<EmployeeFormData> }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<EmployeeFormData>({
    resolver: zodResolver(employeeSchema),
    defaultValues: initialData,
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      {/* Datos Personales */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Datos Personales</h3>

        <div className="grid grid-cols-2 gap-4">
          <div>
            <label>Nombre(s)</label>
            <Input {...register('firstName')} placeholder="Juan" />
            {errors.firstName && (
              <p className="text-sm text-error">{errors.firstName.message}</p>
            )}
          </div>

          <div>
            <label>Apellidos</label>
            <Input {...register('lastName')} placeholder="Pérez López" />
            {errors.lastName && (
              <p className="text-sm text-error">{errors.lastName.message}</p>
            )}
          </div>

          <div>
            <label>Fecha de Nacimiento</label>
            <Input type="date" {...register('dateOfBirth')} />
            {errors.dateOfBirth && (
              <p className="text-sm text-error">{errors.dateOfBirth.message}</p>
            )}
          </div>

          <div>
            <label>Género</label>
            <Select {...register('gender')}>
              <option value="male">Masculino</option>
              <option value="female">Femenino</option>
              <option value="other">Otro</option>
            </Select>
          </div>
        </div>
      </div>

      {/* Datos Fiscales */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Datos Fiscales</h3>

        <div className="grid grid-cols-3 gap-4">
          <div>
            <label>CURP</label>
            <Input
              {...register('curp')}
              placeholder="ABCD123456HDFRNN09"
              maxLength={18}
              className="uppercase"
            />
            {errors.curp && <p className="text-sm text-error">{errors.curp.message}</p>}
          </div>

          <div>
            <label>RFC</label>
            <Input
              {...register('rfc')}
              placeholder="ABCD123456ABC"
              maxLength={13}
              className="uppercase"
            />
            {errors.rfc && <p className="text-sm text-error">{errors.rfc.message}</p>}
          </div>

          <div>
            <label>NSS (IMSS)</label>
            <Input {...register('nss')} placeholder="12345678901" maxLength={11} />
            {errors.nss && <p className="text-sm text-error">{errors.nss.message}</p>}
          </div>
        </div>
      </div>

      {/* Contacto */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Contacto</h3>

        <div className="grid grid-cols-2 gap-4">
          <div>
            <label>Teléfono</label>
            <Input {...register('phone')} placeholder="5512345678" maxLength={10} />
            {errors.phone && <p className="text-sm text-error">{errors.phone.message}</p>}
          </div>

          <div>
            <label>Email (opcional)</label>
            <Input {...register('email')} type="email" placeholder="juan@example.com" />
            {errors.email && <p className="text-sm text-error">{errors.email.message}</p>}
          </div>
        </div>
      </div>

      {/* Datos Laborales */}
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Datos Laborales</h3>

        <div className="grid grid-cols-3 gap-4">
          <div>
            <label>Fecha de Ingreso</label>
            <Input type="date" {...register('hireDate')} />
            {errors.hireDate && (
              <p className="text-sm text-error">{errors.hireDate.message}</p>
            )}
          </div>

          <div>
            <label>Tipo de Contrato</label>
            <Select {...register('contractType')}>
              <option value="permanent">Planta</option>
              <option value="temporary">Eventual</option>
              <option value="per_project">Por Obra</option>
            </Select>
          </div>

          <div>
            <label>Jornada</label>
            <Select {...register('workShift')}>
              <option value="day">Diurna</option>
              <option value="night">Nocturna</option>
              <option value="mixed">Mixta</option>
            </Select>
          </div>

          <div>
            <label>Salario Diario Integrado</label>
            <Input
              type="number"
              step="0.01"
              {...register('baseDailySalary', { valueAsNumber: true })}
              placeholder="350.00"
            />
            {errors.baseDailySalary && (
              <p className="text-sm text-error">{errors.baseDailySalary.message}</p>
            )}
            <p className="text-xs text-muted-foreground">
              Mínimo: $248.93 (salario mínimo 2025)
            </p>
          </div>
        </div>
      </div>

      {/* Botones */}
      <div className="flex justify-end gap-4">
        <Button type="button" variant="outline">
          Cancelar
        </Button>
        <Button type="submit" loading={isSubmitting}>
          {initialData ? 'Actualizar' : 'Crear'} Empleado
        </Button>
      </div>
    </form>
  );
}

🧪 Tests

describe('EmployeesService', () => {
  it('should create employee with valid data', async () => {
    const dto = {
      firstName: 'Juan',
      lastName: 'Pérez',
      dateOfBirth: '1990-01-15',
      gender: 'male',
      curp: 'PEPJ900115HDFRNN09',
      rfc: 'PEPJ900115ABC',
      nss: '12345678901',
      phone: '5512345678',
      hireDate: '2025-01-15',
      contractType: ContractType.PERMANENT,
      baseDailySalary: 350.0,
      workShift: WorkShift.DAY,
    };

    const employee = await service.create(dto, 'user-id');
    expect(employee.employeeCode).toMatch(/^EMP-\d{5}$/);
    expect(employee.qrCode).toBeDefined();
    expect(employee.status).toBe(EmployeeStatus.ACTIVE);
  });

  it('should reject employee under 18 years', async () => {
    const dto = {
      // ... otros campos
      dateOfBirth: '2010-01-15', // 15 años
    };

    await expect(service.create(dto, 'user-id')).rejects.toThrow(
      'El empleado debe ser mayor de 18 años',
    );
  });

  it('should reject duplicate NSS', async () => {
    // ... crear empleado
    await expect(
      service.create({ ...dto, curp: 'OTHER123456HDFRNN09' }, 'user-id'),
    ).rejects.toThrow('El NSS ya está registrado');
  });
});

Fecha de creación: 2025-11-17 Versión: 1.0