erp-construccion/docs/02-definicion-modulos/MAI-007-rrhh-asistencias/especificaciones/ET-HR-001-empleados-cuadrillas.md

1152 lines
33 KiB
Markdown

# 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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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`
```typescript
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
```typescript
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