diff --git a/src/modules/hr/dto/create-contract.dto.ts b/src/modules/hr/dto/create-contract.dto.ts new file mode 100644 index 0000000..72102c4 --- /dev/null +++ b/src/modules/hr/dto/create-contract.dto.ts @@ -0,0 +1,218 @@ +import { + IsString, + IsUUID, + IsOptional, + IsDateString, + IsEnum, + IsNumber, + IsPositive, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; +import { ContractType, ContractStatus, WageType } from '../entities/contract.entity'; + +/** + * DTO for creating a new employment contract + */ +export class CreateContractDto { + @IsUUID() + companyId: string; + + @IsUUID() + employeeId: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + reference?: string; + + @IsOptional() + @IsEnum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']) + contractType?: ContractType; + + @IsOptional() + @IsEnum(['draft', 'active', 'expired', 'terminated', 'cancelled']) + status?: ContractStatus; + + @IsDateString() + dateStart: string; + + @IsOptional() + @IsDateString() + dateEnd?: string; + + @IsOptional() + @IsUUID() + jobPositionId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + // Compensation + @IsNumber() + @IsPositive() + wage: number; + + @IsOptional() + @IsEnum(['hourly', 'daily', 'weekly', 'biweekly', 'monthly', 'annual']) + wageType?: WageType; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + // Schedule + @IsOptional() + @IsNumber() + @Min(0) + @Max(168) + hoursPerWeek?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + scheduleType?: string; + + // Trial period + @IsOptional() + @IsNumber() + @Min(0) + @Max(12) + trialPeriodMonths?: number; + + @IsOptional() + @IsDateString() + trialDateEnd?: string; + + // Metadata + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + documentUrl?: string; +} + +/** + * DTO for updating an existing contract + */ +export class UpdateContractDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + reference?: string | null; + + @IsOptional() + @IsEnum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']) + contractType?: ContractType; + + @IsOptional() + @IsEnum(['draft', 'active', 'expired', 'terminated', 'cancelled']) + status?: ContractStatus; + + @IsOptional() + @IsDateString() + dateStart?: string; + + @IsOptional() + @IsDateString() + dateEnd?: string | null; + + @IsOptional() + @IsUUID() + jobPositionId?: string | null; + + @IsOptional() + @IsUUID() + departmentId?: string | null; + + // Compensation + @IsOptional() + @IsNumber() + @IsPositive() + wage?: number; + + @IsOptional() + @IsEnum(['hourly', 'daily', 'weekly', 'biweekly', 'monthly', 'annual']) + wageType?: WageType; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + // Schedule + @IsOptional() + @IsNumber() + @Min(0) + @Max(168) + hoursPerWeek?: number | null; + + @IsOptional() + @IsString() + @MaxLength(50) + scheduleType?: string | null; + + // Trial period + @IsOptional() + @IsNumber() + @Min(0) + @Max(12) + trialPeriodMonths?: number | null; + + @IsOptional() + @IsDateString() + trialDateEnd?: string | null; + + // Metadata + @IsOptional() + @IsString() + notes?: string | null; + + @IsOptional() + @IsString() + documentUrl?: string | null; + + // Termination (only for termination flow) + @IsOptional() + @IsString() + terminationReason?: string; +} + +/** + * DTO for activating a contract + */ +export class ActivateContractDto { + @IsOptional() + @IsDateString() + activatedAt?: string; +} + +/** + * DTO for terminating a contract + */ +export class TerminateContractDto { + @IsString() + @MinLength(1) + terminationReason: string; + + @IsOptional() + @IsDateString() + terminatedAt?: string; +} diff --git a/src/modules/hr/dto/create-employee.dto.ts b/src/modules/hr/dto/create-employee.dto.ts new file mode 100644 index 0000000..7f9b83a --- /dev/null +++ b/src/modules/hr/dto/create-employee.dto.ts @@ -0,0 +1,180 @@ +import { + IsString, + IsUUID, + IsOptional, + IsDateString, + IsEmail, + IsEnum, + MaxLength, + MinLength, +} from 'class-validator'; +import { EmployeeStatus } from '../entities/employee.entity'; + +/** + * DTO for creating a new employee + */ +export class CreateEmployeeDto { + @IsUUID() + companyId: string; + + @IsString() + @MinLength(1) + @MaxLength(50) + employeeNumber: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + firstName: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + lastName: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + partnerId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsUUID() + jobPositionId?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + // Work contact + @IsOptional() + @IsEmail() + @MaxLength(255) + workEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + workPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + workMobile?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + workLocation?: string; + + // Personal data + @IsOptional() + @IsEmail() + @MaxLength(255) + personalEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + personalPhone?: string; + + @IsOptional() + @IsDateString() + birthDate?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + gender?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + maritalStatus?: string; + + // Identification + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + identificationNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + taxId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + socialSecurityNumber?: string; + + // Address + @IsOptional() + @IsString() + @MaxLength(255) + street?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + country?: string; + + // Employment + @IsDateString() + hireDate: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave', 'terminated']) + status?: EmployeeStatus; + + // Emergency contact + @IsOptional() + @IsString() + @MaxLength(255) + emergencyContactName?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + emergencyContactPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + emergencyContactRelationship?: string; + + // Metadata + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; +} diff --git a/src/modules/hr/dto/create-leave.dto.ts b/src/modules/hr/dto/create-leave.dto.ts new file mode 100644 index 0000000..c76ca7e --- /dev/null +++ b/src/modules/hr/dto/create-leave.dto.ts @@ -0,0 +1,267 @@ +import { + IsString, + IsUUID, + IsOptional, + IsDateString, + IsEnum, + IsNumber, + IsBoolean, + IsPositive, + MaxLength, + MinLength, +} from 'class-validator'; +import { LeaveStatus, HalfDayType } from '../entities/leave.entity'; + +/** + * DTO for creating a new leave request + */ +export class CreateLeaveDto { + @IsUUID() + companyId: string; + + @IsUUID() + employeeId: string; + + @IsUUID() + leaveTypeId: string; + + @IsOptional() + @IsUUID() + allocationId?: string; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsNumber() + @IsPositive() + daysRequested: number; + + @IsOptional() + @IsBoolean() + isHalfDay?: boolean; + + @IsOptional() + @IsEnum(['morning', 'afternoon']) + halfDayType?: HalfDayType; + + @IsOptional() + @IsEnum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']) + status?: LeaveStatus; + + @IsOptional() + @IsString() + requestReason?: string; + + @IsOptional() + @IsString() + documentUrl?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO for updating an existing leave request + */ +export class UpdateLeaveDto { + @IsOptional() + @IsUUID() + leaveTypeId?: string; + + @IsOptional() + @IsUUID() + allocationId?: string | null; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @IsPositive() + daysRequested?: number; + + @IsOptional() + @IsBoolean() + isHalfDay?: boolean; + + @IsOptional() + @IsEnum(['morning', 'afternoon']) + halfDayType?: HalfDayType | null; + + @IsOptional() + @IsEnum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']) + status?: LeaveStatus; + + @IsOptional() + @IsString() + requestReason?: string | null; + + @IsOptional() + @IsString() + documentUrl?: string | null; + + @IsOptional() + @IsString() + notes?: string | null; +} + +/** + * DTO for submitting a leave request for approval + */ +export class SubmitLeaveDto { + @IsOptional() + @IsString() + requestReason?: string; +} + +/** + * DTO for approving a leave request + */ +export class ApproveLeaveDto { + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO for rejecting a leave request + */ +export class RejectLeaveDto { + @IsString() + @MinLength(1) + @MaxLength(1000) + rejectionReason: string; +} + +/** + * DTO for cancelling a leave request + */ +export class CancelLeaveDto { + @IsOptional() + @IsString() + @MaxLength(1000) + reason?: string; +} + +/** + * DTO for creating a leave type + */ +export class CreateLeaveTypeDto { + @IsUUID() + companyId: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + name: string; + + @IsString() + @MinLength(1) + @MaxLength(50) + code: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + color?: string; + + @IsOptional() + @IsEnum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']) + leaveCategory?: string; + + @IsOptional() + @IsEnum(['fixed', 'accrual', 'unlimited']) + allocationType?: string; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsBoolean() + requiresDocument?: boolean; + + @IsOptional() + @IsNumber() + maxDaysPerRequest?: number; + + @IsOptional() + @IsNumber() + maxDaysPerYear?: number; + + @IsOptional() + @IsNumber() + minDaysNotice?: number; + + @IsOptional() + @IsBoolean() + isPaid?: boolean; + + @IsOptional() + @IsNumber() + payPercentage?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** + * DTO for creating a leave allocation + */ +export class CreateLeaveAllocationDto { + @IsUUID() + employeeId: string; + + @IsUUID() + leaveTypeId: string; + + @IsNumber() + @IsPositive() + daysAllocated: number; + + @IsDateString() + dateFrom: string; + + @IsDateString() + dateTo: string; + + @IsOptional() + @IsString() + notes?: string; +} + +/** + * DTO for updating a leave allocation + */ +export class UpdateLeaveAllocationDto { + @IsOptional() + @IsNumber() + @IsPositive() + daysAllocated?: number; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsString() + notes?: string | null; +} diff --git a/src/modules/hr/dto/hr-filters.dto.ts b/src/modules/hr/dto/hr-filters.dto.ts new file mode 100644 index 0000000..11d2e40 --- /dev/null +++ b/src/modules/hr/dto/hr-filters.dto.ts @@ -0,0 +1,260 @@ +import { + IsString, + IsUUID, + IsOptional, + IsDateString, + IsEnum, + IsBoolean, + IsNumber, + Min, +} from 'class-validator'; +import { EmployeeStatus } from '../entities/employee.entity'; +import { ContractStatus, ContractType } from '../entities/contract.entity'; +import { LeaveStatus } from '../entities/leave.entity'; +import { LeaveTypeCategory } from '../entities/leave-type.entity'; + +/** + * Filters for querying employees + */ +export class EmployeeFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsUUID() + jobPositionId?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave', 'terminated']) + status?: EmployeeStatus; + + @IsOptional() + @IsBoolean() + hasUser?: boolean; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsDateString() + hireDateFrom?: string; + + @IsOptional() + @IsDateString() + hireDateTo?: string; +} + +/** + * Filters for querying departments + */ +export class DepartmentFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isRoot?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +/** + * Filters for querying job positions + */ +export class JobPositionFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsUUID() + departmentId?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +/** + * Filters for querying contracts + */ +export class ContractFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsUUID() + employeeId?: string; + + @IsOptional() + @IsEnum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']) + contractType?: ContractType; + + @IsOptional() + @IsEnum(['draft', 'active', 'expired', 'terminated', 'cancelled']) + status?: ContractStatus; + + @IsOptional() + @IsDateString() + dateStartFrom?: string; + + @IsOptional() + @IsDateString() + dateStartTo?: string; + + @IsOptional() + @IsDateString() + dateEndFrom?: string; + + @IsOptional() + @IsDateString() + dateEndTo?: string; + + @IsOptional() + @IsBoolean() + expiringWithinDays?: number; + + @IsOptional() + @IsString() + search?: string; +} + +/** + * Filters for querying leave types + */ +export class LeaveTypeFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsEnum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']) + leaveCategory?: LeaveTypeCategory; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isPaid?: boolean; + + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +/** + * Filters for querying leaves + */ +export class LeaveFiltersDto { + @IsOptional() + @IsUUID() + companyId?: string; + + @IsOptional() + @IsUUID() + employeeId?: string; + + @IsOptional() + @IsUUID() + leaveTypeId?: string; + + @IsOptional() + @IsUUID() + approverId?: string; + + @IsOptional() + @IsEnum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']) + status?: LeaveStatus; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsBoolean() + pendingApproval?: boolean; + + @IsOptional() + @IsString() + search?: string; +} + +/** + * Filters for querying leave allocations + */ +export class LeaveAllocationFiltersDto { + @IsOptional() + @IsUUID() + employeeId?: string; + + @IsOptional() + @IsUUID() + leaveTypeId?: string; + + @IsOptional() + @IsDateString() + asOfDate?: string; + + @IsOptional() + @IsBoolean() + hasRemainingDays?: boolean; +} + +/** + * Pagination parameters for HR queries + */ +export class HrPaginationDto { + @IsOptional() + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @IsNumber() + @Min(1) + limit?: number; + + @IsOptional() + @IsString() + sortBy?: string; + + @IsOptional() + @IsEnum(['asc', 'desc', 'ASC', 'DESC']) + sortOrder?: 'asc' | 'desc' | 'ASC' | 'DESC'; +} diff --git a/src/modules/hr/dto/index.ts b/src/modules/hr/dto/index.ts new file mode 100644 index 0000000..5018ff7 --- /dev/null +++ b/src/modules/hr/dto/index.ts @@ -0,0 +1,29 @@ +export { CreateEmployeeDto } from './create-employee.dto'; +export { UpdateEmployeeDto } from './update-employee.dto'; +export { + CreateContractDto, + UpdateContractDto, + ActivateContractDto, + TerminateContractDto, +} from './create-contract.dto'; +export { + CreateLeaveDto, + UpdateLeaveDto, + SubmitLeaveDto, + ApproveLeaveDto, + RejectLeaveDto, + CancelLeaveDto, + CreateLeaveTypeDto, + CreateLeaveAllocationDto, + UpdateLeaveAllocationDto, +} from './create-leave.dto'; +export { + EmployeeFiltersDto, + DepartmentFiltersDto, + JobPositionFiltersDto, + ContractFiltersDto, + LeaveTypeFiltersDto, + LeaveFiltersDto, + LeaveAllocationFiltersDto, + HrPaginationDto, +} from './hr-filters.dto'; diff --git a/src/modules/hr/dto/update-employee.dto.ts b/src/modules/hr/dto/update-employee.dto.ts new file mode 100644 index 0000000..9ace755 --- /dev/null +++ b/src/modules/hr/dto/update-employee.dto.ts @@ -0,0 +1,180 @@ +import { + IsString, + IsUUID, + IsOptional, + IsDateString, + IsEmail, + IsEnum, + MaxLength, + MinLength, +} from 'class-validator'; +import { EmployeeStatus } from '../entities/employee.entity'; + +/** + * DTO for updating an existing employee + * All fields are optional since it's a partial update + */ +export class UpdateEmployeeDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsUUID() + userId?: string | null; + + @IsOptional() + @IsUUID() + partnerId?: string | null; + + @IsOptional() + @IsUUID() + departmentId?: string | null; + + @IsOptional() + @IsUUID() + jobPositionId?: string | null; + + @IsOptional() + @IsUUID() + managerId?: string | null; + + // Work contact + @IsOptional() + @IsEmail() + @MaxLength(255) + workEmail?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + workPhone?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + workMobile?: string | null; + + @IsOptional() + @IsString() + @MaxLength(255) + workLocation?: string | null; + + // Personal data + @IsOptional() + @IsEmail() + @MaxLength(255) + personalEmail?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + personalPhone?: string | null; + + @IsOptional() + @IsDateString() + birthDate?: string | null; + + @IsOptional() + @IsString() + @MaxLength(20) + gender?: string | null; + + @IsOptional() + @IsString() + @MaxLength(20) + maritalStatus?: string | null; + + // Identification + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + identificationNumber?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + taxId?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + socialSecurityNumber?: string | null; + + // Address + @IsOptional() + @IsString() + @MaxLength(255) + street?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string | null; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string | null; + + @IsOptional() + @IsString() + @MaxLength(100) + country?: string | null; + + // Employment + @IsOptional() + @IsDateString() + hireDate?: string; + + @IsOptional() + @IsDateString() + terminationDate?: string | null; + + @IsOptional() + @IsEnum(['active', 'inactive', 'on_leave', 'terminated']) + status?: EmployeeStatus; + + // Emergency contact + @IsOptional() + @IsString() + @MaxLength(255) + emergencyContactName?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + emergencyContactPhone?: string | null; + + @IsOptional() + @IsString() + @MaxLength(50) + emergencyContactRelationship?: string | null; + + // Metadata + @IsOptional() + @IsString() + notes?: string | null; + + @IsOptional() + @IsString() + avatarUrl?: string | null; +} diff --git a/src/modules/hr/entities/contract.entity.ts b/src/modules/hr/entities/contract.entity.ts new file mode 100644 index 0000000..0ca2118 --- /dev/null +++ b/src/modules/hr/entities/contract.entity.ts @@ -0,0 +1,168 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Employee } from './employee.entity'; +import { Department } from './department.entity'; +import { JobPosition } from './job-position.entity'; + +/** + * Contract Type Enum + */ +export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time'; + +/** + * Contract Status Enum + */ +export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled'; + +/** + * Wage Type for payment frequency + */ +export type WageType = 'hourly' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'annual'; + +/** + * Contract Entity (schema: hr.contracts) + * + * Employment contracts with details about compensation, duration, + * and terms of employment. Tracks contract lifecycle from draft to termination. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'contracts', schema: 'hr' }) +export class Contract { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @ManyToOne(() => Employee, (employee) => employee.contracts, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'reference', type: 'varchar', length: 50, nullable: true }) + reference: string | null; + + @Index() + @Column({ + name: 'contract_type', + type: 'enum', + enum: ['permanent', 'temporary', 'contractor', 'internship', 'part_time'], + enumName: 'hr_contract_type', + default: 'permanent', + }) + contractType: ContractType; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'active', 'expired', 'terminated', 'cancelled'], + enumName: 'hr_contract_status', + default: 'draft', + }) + status: ContractStatus; + + @Index() + @Column({ name: 'date_start', type: 'date' }) + dateStart: Date; + + @Column({ name: 'date_end', type: 'date', nullable: true }) + dateEnd: Date | null; + + @Column({ name: 'job_position_id', type: 'uuid', nullable: true }) + jobPositionId: string | null; + + @ManyToOne(() => JobPosition, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'job_position_id' }) + jobPosition: JobPosition | null; + + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId: string | null; + + @ManyToOne(() => Department, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'department_id' }) + department: Department | null; + + // Compensation + @Column({ name: 'wage', type: 'decimal', precision: 15, scale: 2 }) + wage: number; + + @Column({ + name: 'wage_type', + type: 'varchar', + length: 20, + default: 'monthly', + }) + wageType: WageType; + + @Column({ name: 'currency', type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Schedule + @Column({ + name: 'hours_per_week', + type: 'decimal', + precision: 5, + scale: 2, + nullable: true, + default: 48, + }) + hoursPerWeek: number | null; + + @Column({ name: 'schedule_type', type: 'varchar', length: 50, nullable: true }) + scheduleType: string | null; + + // Trial period + @Column({ name: 'trial_period_months', type: 'integer', nullable: true, default: 0 }) + trialPeriodMonths: number | null; + + @Column({ name: 'trial_date_end', type: 'date', nullable: true }) + trialDateEnd: Date | null; + + // Metadata + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'activated_at', type: 'timestamptz', nullable: true }) + activatedAt: Date | null; + + @Column({ name: 'terminated_at', type: 'timestamptz', nullable: true }) + terminatedAt: Date | null; + + @Column({ name: 'terminated_by', type: 'uuid', nullable: true }) + terminatedBy: string | null; + + @Column({ name: 'termination_reason', type: 'text', nullable: true }) + terminationReason: string | null; +} diff --git a/src/modules/hr/entities/department.entity.ts b/src/modules/hr/entities/department.entity.ts new file mode 100644 index 0000000..b8c4e56 --- /dev/null +++ b/src/modules/hr/entities/department.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Employee } from './employee.entity'; + +/** + * Department Entity (schema: hr.departments) + * + * Organizational departments with self-referential hierarchy. + * Supports parent/child relationships for department structure. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'departments', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @ManyToOne(() => Department, (department) => department.children, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'parent_id' }) + parent: Department | null; + + @OneToMany(() => Department, (department) => department.parent) + children: Department[]; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string | null; + + @ManyToOne(() => Employee, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'manager_id' }) + manager: Employee | null; + + @OneToMany(() => Employee, (employee) => employee.department) + employees: Employee[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/employee.entity.ts b/src/modules/hr/entities/employee.entity.ts new file mode 100644 index 0000000..32d286c --- /dev/null +++ b/src/modules/hr/entities/employee.entity.ts @@ -0,0 +1,223 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Department } from './department.entity'; +import { JobPosition } from './job-position.entity'; +import { Contract } from './contract.entity'; +import { Leave } from './leave.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Employee Status Enum + */ +export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated'; + +/** + * Employee Entity (schema: hr.employees) + * + * Core employee information including personal data, organization assignment, + * and employment details. Supports self-referential manager relationship. + * + * Relations: + * - user_id: References auth.users (optional - employee may not have system access) + * - partner_id: References partners.partners (optional - employee as business contact) + * - department_id: Current department assignment + * - job_position_id: Current job position + * - manager_id: Direct manager (self-reference) + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'employees', schema: 'hr' }) +@Index(['tenantId', 'employeeNumber'], { unique: true }) +export class Employee { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'employee_number', type: 'varchar', length: 50 }) + employeeNumber: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName: string; + + /** + * Generated column in PostgreSQL: first_name || ' ' || last_name + * Mark as insert: false, update: false since it's computed by DB + */ + @Index() + @Column({ + name: 'full_name', + type: 'varchar', + length: 255, + insert: false, + update: false, + nullable: true, + }) + fullName: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string | null; + + @Column({ name: 'partner_id', type: 'uuid', nullable: true }) + partnerId: string | null; + + @Index() + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId: string | null; + + @ManyToOne(() => Department, (department) => department.employees, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'department_id' }) + department: Department | null; + + @Index() + @Column({ name: 'job_position_id', type: 'uuid', nullable: true }) + jobPositionId: string | null; + + @ManyToOne(() => JobPosition, (position) => position.employees, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'job_position_id' }) + jobPosition: JobPosition | null; + + @Index() + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string | null; + + @ManyToOne(() => Employee, (employee) => employee.subordinates, { + onDelete: 'SET NULL', + }) + @JoinColumn({ name: 'manager_id' }) + manager: Employee | null; + + @OneToMany(() => Employee, (employee) => employee.manager) + subordinates: Employee[]; + + // Work contact + @Column({ name: 'work_email', type: 'varchar', length: 255, nullable: true }) + workEmail: string | null; + + @Column({ name: 'work_phone', type: 'varchar', length: 50, nullable: true }) + workPhone: string | null; + + @Column({ name: 'work_mobile', type: 'varchar', length: 50, nullable: true }) + workMobile: string | null; + + @Column({ name: 'work_location', type: 'varchar', length: 255, nullable: true }) + workLocation: string | null; + + // Personal data + @Column({ name: 'personal_email', type: 'varchar', length: 255, nullable: true }) + personalEmail: string | null; + + @Column({ name: 'personal_phone', type: 'varchar', length: 50, nullable: true }) + personalPhone: string | null; + + @Column({ name: 'birth_date', type: 'date', nullable: true }) + birthDate: Date | null; + + @Column({ name: 'gender', type: 'varchar', length: 20, nullable: true }) + gender: string | null; + + @Column({ name: 'marital_status', type: 'varchar', length: 20, nullable: true }) + maritalStatus: string | null; + + // Official identification + @Column({ name: 'identification_type', type: 'varchar', length: 50, nullable: true }) + identificationType: string | null; + + @Column({ name: 'identification_number', type: 'varchar', length: 100, nullable: true }) + identificationNumber: string | null; + + @Column({ name: 'tax_id', type: 'varchar', length: 50, nullable: true }) + taxId: string | null; + + @Column({ name: 'social_security_number', type: 'varchar', length: 50, nullable: true }) + socialSecurityNumber: string | null; + + // Address + @Column({ name: 'street', type: 'varchar', length: 255, nullable: true }) + street: string | null; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city: string | null; + + @Column({ name: 'state', type: 'varchar', length: 100, nullable: true }) + state: string | null; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string | null; + + @Column({ name: 'country', type: 'varchar', length: 100, nullable: true }) + country: string | null; + + // Employment + @Column({ name: 'hire_date', type: 'date' }) + hireDate: Date; + + @Column({ name: 'termination_date', type: 'date', nullable: true }) + terminationDate: Date | null; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['active', 'inactive', 'on_leave', 'terminated'], + enumName: 'hr_employee_status', + default: 'active', + }) + status: EmployeeStatus; + + // Emergency contact + @Column({ name: 'emergency_contact_name', type: 'varchar', length: 255, nullable: true }) + emergencyContactName: string | null; + + @Column({ name: 'emergency_contact_phone', type: 'varchar', length: 50, nullable: true }) + emergencyContactPhone: string | null; + + @Column({ name: 'emergency_contact_relationship', type: 'varchar', length: 50, nullable: true }) + emergencyContactRelationship: string | null; + + // Metadata + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'avatar_url', type: 'text', nullable: true }) + avatarUrl: string | null; + + // Relations + @OneToMany(() => Contract, (contract) => contract.employee) + contracts: Contract[]; + + @OneToMany(() => Leave, (leave) => leave.employee) + leaves: Leave[]; + + @OneToMany(() => LeaveAllocation, (allocation) => allocation.employee) + leaveAllocations: LeaveAllocation[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/index.ts b/src/modules/hr/entities/index.ts new file mode 100644 index 0000000..c489c4d --- /dev/null +++ b/src/modules/hr/entities/index.ts @@ -0,0 +1,7 @@ +export { Department } from './department.entity'; +export { JobPosition } from './job-position.entity'; +export { Employee, EmployeeStatus } from './employee.entity'; +export { Contract, ContractType, ContractStatus, WageType } from './contract.entity'; +export { LeaveType, LeaveTypeCategory, AllocationType } from './leave-type.entity'; +export { LeaveAllocation } from './leave-allocation.entity'; +export { Leave, LeaveStatus, HalfDayType } from './leave.entity'; diff --git a/src/modules/hr/entities/job-position.entity.ts b/src/modules/hr/entities/job-position.entity.ts new file mode 100644 index 0000000..413185e --- /dev/null +++ b/src/modules/hr/entities/job-position.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Department } from './department.entity'; +import { Employee } from './employee.entity'; + +/** + * Job Position Entity (schema: hr.job_positions) + * + * Defines job titles/positions within the organization. + * Can be associated with a specific department. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'job_positions', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class JobPosition { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Index() + @Column({ name: 'department_id', type: 'uuid', nullable: true }) + departmentId: string | null; + + @ManyToOne(() => Department, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'department_id' }) + department: Department | null; + + @OneToMany(() => Employee, (employee) => employee.jobPosition) + employees: Employee[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave-allocation.entity.ts b/src/modules/hr/entities/leave-allocation.entity.ts new file mode 100644 index 0000000..ac84b19 --- /dev/null +++ b/src/modules/hr/entities/leave-allocation.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Employee } from './employee.entity'; +import { LeaveType } from './leave-type.entity'; + +/** + * Leave Allocation Entity (schema: hr.leave_allocations) + * + * Tracks allocated leave days per employee and leave type. + * Supports period-based allocations with used/remaining tracking. + * + * Note: days_remaining is a computed column in PostgreSQL + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_allocations', schema: 'hr' }) +export class LeaveAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @ManyToOne(() => Employee, (employee) => employee.leaveAllocations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.allocations, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'days_allocated', type: 'decimal', precision: 5, scale: 2 }) + daysAllocated: number; + + @Column({ name: 'days_used', type: 'decimal', precision: 5, scale: 2, default: 0 }) + daysUsed: number; + + /** + * Generated column in PostgreSQL: days_allocated - days_used + * Mark as insert: false, update: false since it's computed by DB + */ + @Column({ + name: 'days_remaining', + type: 'decimal', + precision: 5, + scale: 2, + insert: false, + update: false, + nullable: true, + }) + daysRemaining: number; + + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave-type.entity.ts b/src/modules/hr/entities/leave-type.entity.ts new file mode 100644 index 0000000..f182b35 --- /dev/null +++ b/src/modules/hr/entities/leave-type.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Leave } from './leave.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Type Category Enum + */ +export type LeaveTypeCategory = + | 'vacation' + | 'sick' + | 'personal' + | 'maternity' + | 'paternity' + | 'bereavement' + | 'unpaid' + | 'other'; + +/** + * Allocation Type Enum + */ +export type AllocationType = 'fixed' | 'accrual' | 'unlimited'; + +/** + * Leave Type Entity (schema: hr.leave_types) + * + * Configurable leave/absence types for the organization. + * Defines rules for approval, allocation, and payment. + * + * Examples: Vacation, Sick Leave, Maternity, etc. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leave_types', schema: 'hr' }) +@Index(['tenantId', 'companyId', 'code'], { unique: true }) +export class LeaveType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'color', type: 'varchar', length: 7, default: '#3B82F6' }) + color: string; + + @Index() + @Column({ + name: 'leave_category', + type: 'enum', + enum: ['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other'], + enumName: 'hr_leave_type_category', + default: 'other', + }) + leaveCategory: LeaveTypeCategory; + + @Column({ + name: 'allocation_type', + type: 'enum', + enum: ['fixed', 'accrual', 'unlimited'], + enumName: 'hr_allocation_type', + default: 'fixed', + }) + allocationType: AllocationType; + + @Column({ name: 'requires_approval', type: 'boolean', default: true }) + requiresApproval: boolean; + + @Column({ name: 'requires_document', type: 'boolean', default: false }) + requiresDocument: boolean; + + // Limits + @Column({ name: 'max_days_per_request', type: 'integer', nullable: true }) + maxDaysPerRequest: number | null; + + @Column({ name: 'max_days_per_year', type: 'integer', nullable: true }) + maxDaysPerYear: number | null; + + @Column({ name: 'min_days_notice', type: 'integer', default: 0 }) + minDaysNotice: number; + + // Payment + @Column({ name: 'is_paid', type: 'boolean', default: true }) + isPaid: boolean; + + @Column({ + name: 'pay_percentage', + type: 'decimal', + precision: 5, + scale: 2, + default: 100, + }) + payPercentage: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Relations + @OneToMany(() => Leave, (leave) => leave.leaveType) + leaves: Leave[]; + + @OneToMany(() => LeaveAllocation, (allocation) => allocation.leaveType) + allocations: LeaveAllocation[]; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/hr/entities/leave.entity.ts b/src/modules/hr/entities/leave.entity.ts new file mode 100644 index 0000000..fbdcbf9 --- /dev/null +++ b/src/modules/hr/entities/leave.entity.ts @@ -0,0 +1,142 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Employee } from './employee.entity'; +import { LeaveType } from './leave-type.entity'; +import { LeaveAllocation } from './leave-allocation.entity'; + +/** + * Leave Status Enum + */ +export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled'; + +/** + * Half Day Type + */ +export type HalfDayType = 'morning' | 'afternoon'; + +/** + * Leave Entity (schema: hr.leaves) + * + * Leave/absence requests from employees. + * Tracks the full lifecycle from draft to approval/rejection. + * + * RLS Policy: tenant_id = current_setting('app.current_tenant_id') + */ +@Entity({ name: 'leaves', schema: 'hr' }) +export class Leave { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'company_id', type: 'uuid' }) + companyId: string; + + @Index() + @Column({ name: 'employee_id', type: 'uuid' }) + employeeId: string; + + @ManyToOne(() => Employee, (employee) => employee.leaves, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'employee_id' }) + employee: Employee; + + @Index() + @Column({ name: 'leave_type_id', type: 'uuid' }) + leaveTypeId: string; + + @ManyToOne(() => LeaveType, (leaveType) => leaveType.leaves, { + onDelete: 'RESTRICT', + }) + @JoinColumn({ name: 'leave_type_id' }) + leaveType: LeaveType; + + @Column({ name: 'allocation_id', type: 'uuid', nullable: true }) + allocationId: string | null; + + @ManyToOne(() => LeaveAllocation, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'allocation_id' }) + allocation: LeaveAllocation | null; + + // Period + @Index() + @Column({ name: 'date_from', type: 'date' }) + dateFrom: Date; + + @Column({ name: 'date_to', type: 'date' }) + dateTo: Date; + + @Column({ name: 'days_requested', type: 'decimal', precision: 5, scale: 2 }) + daysRequested: number; + + // Half day support + @Column({ name: 'is_half_day', type: 'boolean', default: false }) + isHalfDay: boolean; + + @Column({ + name: 'half_day_type', + type: 'varchar', + length: 20, + nullable: true, + }) + halfDayType: HalfDayType | null; + + @Index() + @Column({ + name: 'status', + type: 'enum', + enum: ['draft', 'submitted', 'approved', 'rejected', 'cancelled'], + enumName: 'hr_leave_status', + default: 'draft', + }) + status: LeaveStatus; + + // Approval + @Index() + @Column({ name: 'approver_id', type: 'uuid', nullable: true }) + approverId: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string | null; + + // Metadata + @Column({ name: 'request_reason', type: 'text', nullable: true }) + requestReason: string | null; + + @Column({ name: 'document_url', type: 'text', nullable: true }) + documentUrl: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string | null; + + // Audit + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string | null; +}