# ET-PROJ-004: Implementación de Equipo y Calendario **Épica:** MAI-002 - Proyectos y Estructura de Obra **Requerimiento base:** RF-PROJ-004 **Prioridad:** P0 (Crítica) **Estimación:** 6 SP **Versión:** 1.0 **Fecha:** 2025-11-17 --- ## 1. Resumen Ejecutivo Esta especificación técnica implementa: - **Asignación de Equipo**: Director, Residentes, Ingenieros, Supervisores por proyecto - **Gestión de Workload**: Validación de límites de carga de trabajo por rol - **Milestones**: Hitos del proyecto con dependencias y fechas - **Fechas Críticas**: Alertas automáticas para compromisos contractuales - **Dashboard de Equipo**: Vista consolidada de asignaciones y disponibilidad El sistema garantiza que ningún usuario exceda su capacidad de trabajo y emite alertas proactivas sobre fechas cercanas. --- ## 2. Arquitectura de Base de Datos ### 2.1 Schema SQL ```sql -- Schema: projects CREATE SCHEMA IF NOT EXISTS projects; -- ===================================================== -- TABLA: projects.project_team_assignments -- ===================================================== CREATE TABLE projects.project_team_assignments ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, -- Multi-tenant discriminator (inherited from project) -- tenant = constructora in this system (see GLOSARIO.md) constructora_id UUID NOT NULL, user_id UUID NOT NULL, -- Rol y especialidad role VARCHAR(50) NOT NULL, -- Valores: director | resident | engineer | supervisor | purchases_manager specialty VARCHAR(100), -- Valores: structural | installations | electrical | costs | quality | safety -- Asignación start_date DATE NOT NULL, end_date DATE, is_active BOOLEAN DEFAULT true, is_primary BOOLEAN DEFAULT false, -- Indica si es el responsable principal (ej: Residente principal vs suplente) -- Workload workload_percentage INTEGER NOT NULL DEFAULT 100, -- Ej: 100 = dedicación completa, 50 = medio tiempo, 25 = cuarto tiempo -- Responsabilidades específicas responsibilities TEXT[], -- Ej: ["Supervision de cimentacion", "Control de calidad", "Seguridad"] notes TEXT, -- Auditoría created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by UUID, updated_by UUID, CONSTRAINT check_workload_range CHECK (workload_percentage > 0 AND workload_percentage <= 100), CONSTRAINT check_valid_dates CHECK (end_date IS NULL OR end_date >= start_date) ); CREATE INDEX idx_team_assignments_project ON projects.project_team_assignments(project_id); CREATE INDEX idx_team_assignments_user ON projects.project_team_assignments(user_id); CREATE INDEX idx_team_assignments_constructora ON projects.project_team_assignments(constructora_id); CREATE INDEX idx_team_assignments_role ON projects.project_team_assignments(role); CREATE INDEX idx_team_assignments_active ON projects.project_team_assignments(is_active); -- ===================================================== -- TABLA: projects.project_milestones -- ===================================================== CREATE TABLE projects.project_milestones ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, -- Multi-tenant discriminator (inherited from project) -- tenant = constructora in this system (see GLOSARIO.md) constructora_id UUID NOT NULL, -- Información básica name VARCHAR(200) NOT NULL, description TEXT, -- Tipo de hito milestone_type VARCHAR(50) NOT NULL, -- Valores: -- project_kickoff | permits_obtained | construction_start -- foundation_complete | structure_complete | installations_complete -- finishes_complete | first_delivery | final_delivery -- project_closure | other -- Fechas planned_date DATE NOT NULL, actual_date DATE, -- Estado status VARCHAR(50) NOT NULL DEFAULT 'pending', -- Valores: pending | in_progress | completed | delayed | cancelled -- Responsable responsible_user_id UUID, -- Dependencias depends_on_milestone_ids UUID[], -- Array de IDs de milestones que deben completarse antes -- Entregables deliverables TEXT[], -- Ej: ["Licencia de construccion", "Planos aprobados", "Contrato firmado"] completion_notes TEXT, -- Alertas alert_days_before INTEGER DEFAULT 7, last_alert_sent_at TIMESTAMP, -- Auditoría created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by UUID, updated_by UUID, CONSTRAINT check_valid_milestone_dates CHECK (actual_date IS NULL OR actual_date >= planned_date - INTERVAL '90 days') ); CREATE INDEX idx_milestones_project ON projects.project_milestones(project_id); CREATE INDEX idx_milestones_constructora ON projects.project_milestones(constructora_id); CREATE INDEX idx_milestones_status ON projects.project_milestones(status); CREATE INDEX idx_milestones_type ON projects.project_milestones(milestone_type); CREATE INDEX idx_milestones_planned_date ON projects.project_milestones(planned_date); -- ===================================================== -- TABLA: projects.critical_dates -- ===================================================== CREATE TABLE projects.critical_dates ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, constructora_id UUID NOT NULL, -- Información básica name VARCHAR(200) NOT NULL, description TEXT, -- Fecha date DATE NOT NULL, is_hard_deadline BOOLEAN DEFAULT true, -- true = fecha inamovible, false = fecha sugerida -- Origen del compromiso commitment_type VARCHAR(50), -- Valores: contractual | regulatory | financial | client_requested | internal related_entity VARCHAR(100), -- Ej: "INFONAVIT", "Cliente: ABC Construction", "Autoridad Municipal" -- Consecuencias consequences_if_missed TEXT, -- Ej: "Penalización de $500,000 MXN + intereses del 2% mensual" -- Alertas alert_days_before INTEGER DEFAULT 30, last_alert_sent_at TIMESTAMP, -- Estado status VARCHAR(50) NOT NULL DEFAULT 'pending', -- Valores: pending | at_risk | met | missed met_date DATE, missed_notes TEXT, -- Auditoría created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by UUID, updated_by UUID ); CREATE INDEX idx_critical_dates_project ON projects.critical_dates(project_id); CREATE INDEX idx_critical_dates_constructora ON projects.critical_dates(constructora_id); CREATE INDEX idx_critical_dates_date ON projects.critical_dates(date); CREATE INDEX idx_critical_dates_status ON projects.critical_dates(status); -- ===================================================== -- TABLA: projects.construction_phases -- ===================================================== CREATE TABLE projects.construction_phases ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, constructora_id UUID NOT NULL, -- Información básica phase_name VARCHAR(100) NOT NULL, -- Valores: preliminaries | earthworks | foundation | structure | masonry -- installations | finishes | urbanization | cleanup phase_order INTEGER NOT NULL, description TEXT, -- Fechas planificadas planned_start_date DATE, planned_end_date DATE, planned_duration_days INTEGER, -- Fechas reales actual_start_date DATE, actual_end_date DATE, actual_duration_days INTEGER, -- Estado status VARCHAR(50) NOT NULL DEFAULT 'not_started', -- Valores: not_started | in_progress | completed | delayed -- Avance physical_progress DECIMAL(5, 2) DEFAULT 0.00, -- Responsable responsible_user_id UUID, -- Auditoría created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_by UUID, updated_by UUID, CONSTRAINT unique_project_phase_order UNIQUE (project_id, phase_order) ); CREATE INDEX idx_phases_project ON projects.construction_phases(project_id); CREATE INDEX idx_phases_status ON projects.construction_phases(status); CREATE INDEX idx_phases_order ON projects.construction_phases(phase_order); -- ===================================================== -- FUNCIONES para validación de workload -- ===================================================== -- Función: Calcular workload total de un usuario CREATE OR REPLACE FUNCTION get_user_total_workload( p_user_id UUID, p_constructora_id UUID ) RETURNS INTEGER AS $$ DECLARE total_workload INTEGER; BEGIN SELECT COALESCE(SUM(workload_percentage), 0) INTO total_workload FROM projects.project_team_assignments WHERE user_id = p_user_id AND constructora_id = p_constructora_id AND is_active = true AND (end_date IS NULL OR end_date >= CURRENT_DATE); RETURN total_workload; END; $$ LANGUAGE plpgsql; -- Función: Obtener límite de workload por rol CREATE OR REPLACE FUNCTION get_role_workload_limit(p_role VARCHAR) RETURNS INTEGER AS $$ BEGIN RETURN CASE p_role WHEN 'director' THEN 500 -- Max 5 proyectos a 100% WHEN 'resident' THEN 200 -- Max 2 proyectos a 100% WHEN 'engineer' THEN 800 -- Max 8 proyectos a 100% WHEN 'supervisor' THEN 100 -- Max 1 proyecto a 100% WHEN 'purchases_manager' THEN 1000 -- Centralizado, múltiples proyectos ELSE 100 END; END; $$ LANGUAGE plpgsql; -- ===================================================== -- RLS (Row Level Security) -- ===================================================== ALTER TABLE projects.project_team_assignments ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.project_milestones ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.critical_dates ENABLE ROW LEVEL SECURITY; ALTER TABLE projects.construction_phases ENABLE ROW LEVEL SECURITY; CREATE POLICY team_assignments_isolation ON projects.project_team_assignments USING (constructora_id = current_setting('app.current_constructora_id')::UUID); CREATE POLICY milestones_isolation ON projects.project_milestones USING (constructora_id = current_setting('app.current_constructora_id')::UUID); CREATE POLICY critical_dates_isolation ON projects.critical_dates USING (constructora_id = current_setting('app.current_constructora_id')::UUID); CREATE POLICY construction_phases_isolation ON projects.construction_phases USING (constructora_id = current_setting('app.current_constructora_id')::UUID); ``` --- ## 3. Implementación Backend (NestJS) ### 3.1 Entities #### ProjectTeamAssignment Entity ```typescript // apps/backend/src/modules/projects/entities/project-team-assignment.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from './project.entity'; export enum ProjectRole { DIRECTOR = 'director', RESIDENT = 'resident', ENGINEER = 'engineer', SUPERVISOR = 'supervisor', PURCHASES_MANAGER = 'purchases_manager', } export enum Specialty { STRUCTURAL = 'structural', INSTALLATIONS = 'installations', ELECTRICAL = 'electrical', COSTS = 'costs', QUALITY = 'quality', SAFETY = 'safety', } @Entity('project_team_assignments', { schema: 'projects' }) @Index(['projectId', 'userId']) @Index(['userId']) @Index(['constructoraId']) @Index(['role']) @Index(['isActive']) export class ProjectTeamAssignment { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid' }) projectId: string; @Column({ type: 'uuid' }) constructoraId: string; @Column({ type: 'uuid' }) userId: string; // Rol y especialidad @Column({ type: 'enum', enum: ProjectRole }) role: ProjectRole; @Column({ type: 'enum', enum: Specialty, nullable: true }) specialty: Specialty; // Asignación @Column({ type: 'date' }) startDate: Date; @Column({ type: 'date', nullable: true }) endDate: Date; @Column({ type: 'boolean', default: true }) isActive: boolean; @Column({ type: 'boolean', default: false }) isPrimary: boolean; // Workload @Column({ type: 'integer', default: 100 }) workloadPercentage: number; // Responsabilidades @Column({ type: 'text', array: true, nullable: true }) responsibilities: string[]; @Column({ type: 'text', nullable: true }) notes: string; // Auditoría @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @Column({ type: 'uuid', nullable: true }) createdBy: string; @Column({ type: 'uuid', nullable: true }) updatedBy: string; // Relaciones @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; } ``` #### Milestone Entity ```typescript // apps/backend/src/modules/projects/entities/milestone.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from './project.entity'; export enum MilestoneType { PROJECT_KICKOFF = 'project_kickoff', PERMITS_OBTAINED = 'permits_obtained', CONSTRUCTION_START = 'construction_start', FOUNDATION_COMPLETE = 'foundation_complete', STRUCTURE_COMPLETE = 'structure_complete', INSTALLATIONS_COMPLETE = 'installations_complete', FINISHES_COMPLETE = 'finishes_complete', FIRST_DELIVERY = 'first_delivery', FINAL_DELIVERY = 'final_delivery', PROJECT_CLOSURE = 'project_closure', OTHER = 'other', } export enum MilestoneStatus { PENDING = 'pending', IN_PROGRESS = 'in_progress', COMPLETED = 'completed', DELAYED = 'delayed', CANCELLED = 'cancelled', } @Entity('project_milestones', { schema: 'projects' }) @Index(['projectId']) @Index(['constructoraId']) @Index(['status']) @Index(['milestoneType']) @Index(['plannedDate']) export class Milestone { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid' }) projectId: string; @Column({ type: 'uuid' }) constructoraId: string; // Información básica @Column({ type: 'varchar', length: 200 }) name: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ type: 'enum', enum: MilestoneType }) milestoneType: MilestoneType; // Fechas @Column({ type: 'date' }) plannedDate: Date; @Column({ type: 'date', nullable: true }) actualDate: Date; // Estado @Column({ type: 'enum', enum: MilestoneStatus, default: MilestoneStatus.PENDING }) status: MilestoneStatus; // Responsable @Column({ type: 'uuid', nullable: true }) responsibleUserId: string; // Dependencias @Column({ type: 'uuid', array: true, nullable: true }) dependsOnMilestoneIds: string[]; // Entregables @Column({ type: 'text', array: true, nullable: true }) deliverables: string[]; @Column({ type: 'text', nullable: true }) completionNotes: string; // Alertas @Column({ type: 'integer', default: 7 }) alertDaysBefore: number; @Column({ type: 'timestamp', nullable: true }) lastAlertSentAt: Date; // Auditoría @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @Column({ type: 'uuid', nullable: true }) createdBy: string; @Column({ type: 'uuid', nullable: true }) updatedBy: string; // Relaciones @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; } ``` #### CriticalDate Entity ```typescript // apps/backend/src/modules/projects/entities/critical-date.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from './project.entity'; export enum CommitmentType { CONTRACTUAL = 'contractual', REGULATORY = 'regulatory', FINANCIAL = 'financial', CLIENT_REQUESTED = 'client_requested', INTERNAL = 'internal', } export enum CriticalDateStatus { PENDING = 'pending', AT_RISK = 'at_risk', MET = 'met', MISSED = 'missed', } @Entity('critical_dates', { schema: 'projects' }) @Index(['projectId']) @Index(['constructoraId']) @Index(['date']) @Index(['status']) export class CriticalDate { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid' }) projectId: string; @Column({ type: 'uuid' }) constructoraId: string; // Información básica @Column({ type: 'varchar', length: 200 }) name: string; @Column({ type: 'text', nullable: true }) description: string; // Fecha @Column({ type: 'date' }) date: Date; @Column({ type: 'boolean', default: true }) isHardDeadline: boolean; // Origen del compromiso @Column({ type: 'enum', enum: CommitmentType, nullable: true }) commitmentType: CommitmentType; @Column({ type: 'varchar', length: 100, nullable: true }) relatedEntity: string; // Consecuencias @Column({ type: 'text', nullable: true }) consequencesIfMissed: string; // Alertas @Column({ type: 'integer', default: 30 }) alertDaysBefore: number; @Column({ type: 'timestamp', nullable: true }) lastAlertSentAt: Date; // Estado @Column({ type: 'enum', enum: CriticalDateStatus, default: CriticalDateStatus.PENDING }) status: CriticalDateStatus; @Column({ type: 'date', nullable: true }) metDate: Date; @Column({ type: 'text', nullable: true }) missedNotes: string; // Auditoría @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @Column({ type: 'uuid', nullable: true }) createdBy: string; @Column({ type: 'uuid', nullable: true }) updatedBy: string; // Relaciones @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; } ``` ### 3.2 DTOs ```typescript // apps/backend/src/modules/projects/dto/create-team-assignment.dto.ts import { IsUUID, IsEnum, IsDateString, IsInt, IsBoolean, IsOptional, IsArray, Min, Max } from 'class-validator'; import { ProjectRole, Specialty } from '../entities/project-team-assignment.entity'; export class CreateTeamAssignmentDto { @IsUUID() userId: string; @IsEnum(ProjectRole) role: ProjectRole; @IsOptional() @IsEnum(Specialty) specialty?: Specialty; @IsDateString() startDate: string; @IsOptional() @IsDateString() endDate?: string; @IsOptional() @IsBoolean() isPrimary?: boolean; @IsInt() @Min(1) @Max(100) workloadPercentage: number; @IsOptional() @IsArray() responsibilities?: string[]; @IsOptional() notes?: string; } // apps/backend/src/modules/projects/dto/create-milestone.dto.ts import { IsString, IsEnum, IsDateString, IsUUID, IsInt, IsOptional, IsArray } from 'class-validator'; import { MilestoneType } from '../entities/milestone.entity'; export class CreateMilestoneDto { @IsString() name: string; @IsOptional() @IsString() description?: string; @IsEnum(MilestoneType) milestoneType: MilestoneType; @IsDateString() plannedDate: string; @IsOptional() @IsUUID() responsibleUserId?: string; @IsOptional() @IsArray() @IsUUID('4', { each: true }) dependsOnMilestoneIds?: string[]; @IsOptional() @IsArray() deliverables?: string[]; @IsOptional() @IsInt() alertDaysBefore?: number; } // apps/backend/src/modules/projects/dto/create-critical-date.dto.ts import { IsString, IsDateString, IsBoolean, IsEnum, IsInt, IsOptional } from 'class-validator'; import { CommitmentType } from '../entities/critical-date.entity'; export class CreateCriticalDateDto { @IsString() name: string; @IsOptional() @IsString() description?: string; @IsDateString() date: string; @IsOptional() @IsBoolean() isHardDeadline?: boolean; @IsOptional() @IsEnum(CommitmentType) commitmentType?: CommitmentType; @IsOptional() @IsString() relatedEntity?: string; @IsOptional() @IsString() consequencesIfMissed?: string; @IsOptional() @IsInt() alertDaysBefore?: number; } ``` ### 3.3 Services #### TeamAssignmentsService ```typescript // apps/backend/src/modules/projects/services/team-assignments.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { ProjectTeamAssignment, ProjectRole } from '../entities/project-team-assignment.entity'; import { CreateTeamAssignmentDto } from '../dto/create-team-assignment.dto'; import { UpdateTeamAssignmentDto } from '../dto/update-team-assignment.dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class TeamAssignmentsService { constructor( @InjectRepository(ProjectTeamAssignment) private assignmentRepo: Repository, private dataSource: DataSource, private eventEmitter: EventEmitter2, ) {} async create( projectId: string, dto: CreateTeamAssignmentDto, constructoraId: string, currentUserId: string, ): Promise { // Validate workload limit await this.validateWorkloadLimit(dto.userId, dto.role, dto.workloadPercentage, constructoraId); // Validate: only one primary director per project if (dto.role === ProjectRole.DIRECTOR && dto.isPrimary) { const existingDirector = await this.assignmentRepo.findOne({ where: { projectId, role: ProjectRole.DIRECTOR, isPrimary: true, isActive: true, }, }); if (existingDirector) { throw new BadRequestException('Ya existe un Director principal asignado a este proyecto'); } } const assignment = this.assignmentRepo.create({ ...dto, projectId, constructoraId, createdBy: currentUserId, }); const saved = await this.assignmentRepo.save(assignment); this.eventEmitter.emit('team_assignment.created', saved); return saved; } async findAll( projectId: string, constructoraId: string, filters?: { role?: ProjectRole; isActive?: boolean }, ): Promise { const query = this.assignmentRepo .createQueryBuilder('assignment') .where('assignment.projectId = :projectId', { projectId }) .andWhere('assignment.constructoraId = :constructoraId', { constructoraId }) .orderBy('assignment.isPrimary', 'DESC') .addOrderBy('assignment.startDate', 'ASC'); if (filters?.role) { query.andWhere('assignment.role = :role', { role: filters.role }); } if (filters?.isActive !== undefined) { query.andWhere('assignment.isActive = :isActive', { isActive: filters.isActive }); } return query.getMany(); } async findOne(id: string, constructoraId: string): Promise { const assignment = await this.assignmentRepo.findOne({ where: { id, constructoraId }, relations: ['project'], }); if (!assignment) { throw new NotFoundException(`Asignación con ID ${id} no encontrada`); } return assignment; } async update( id: string, dto: UpdateTeamAssignmentDto, constructoraId: string, currentUserId: string, ): Promise { const assignment = await this.findOne(id, constructoraId); // If changing workload, validate new limit if (dto.workloadPercentage && dto.workloadPercentage !== assignment.workloadPercentage) { const currentWorkload = await this.getUserTotalWorkload( assignment.userId, constructoraId, ); const newWorkload = currentWorkload - assignment.workloadPercentage + dto.workloadPercentage; const limit = this.getRoleWorkloadLimit(assignment.role); if (newWorkload > limit) { throw new BadRequestException( `El usuario ya tiene una carga de ${currentWorkload}%. ` + `El cambio a ${dto.workloadPercentage}% excedería el límite de ${limit}% para su rol.`, ); } } Object.assign(assignment, dto); assignment.updatedBy = currentUserId; return this.assignmentRepo.save(assignment); } async deactivate( id: string, endDate: Date, constructoraId: string, currentUserId: string, ): Promise { const assignment = await this.findOne(id, constructoraId); assignment.isActive = false; assignment.endDate = endDate; assignment.updatedBy = currentUserId; const updated = await this.assignmentRepo.save(assignment); this.eventEmitter.emit('team_assignment.deactivated', updated); return updated; } async getUserTotalWorkload(userId: string, constructoraId: string): Promise { const result = await this.dataSource.query( `SELECT get_user_total_workload($1, $2) as total`, [userId, constructoraId], ); return result[0]?.total || 0; } async getUserAssignments( userId: string, constructoraId: string, onlyActive = true, ): Promise { const query = this.assignmentRepo .createQueryBuilder('assignment') .where('assignment.userId = :userId', { userId }) .andWhere('assignment.constructoraId = :constructoraId', { constructoraId }) .leftJoinAndSelect('assignment.project', 'project') .orderBy('assignment.startDate', 'DESC'); if (onlyActive) { query.andWhere('assignment.isActive = true'); } return query.getMany(); } async getTeamDashboard(projectId: string, constructoraId: string): Promise { const assignments = await this.findAll(projectId, constructoraId, { isActive: true }); // Group by role const byRole = assignments.reduce((acc, assignment) => { if (!acc[assignment.role]) { acc[assignment.role] = []; } acc[assignment.role].push(assignment); return acc; }, {} as Record); // Calculate total workload by user const userWorkloads = new Map(); for (const assignment of assignments) { const current = userWorkloads.get(assignment.userId) || 0; userWorkloads.set(assignment.userId, current + assignment.workloadPercentage); } return { totalMembers: assignments.length, byRole: Object.entries(byRole).map(([role, members]) => ({ role, count: members.length, members, })), userWorkloads: Array.from(userWorkloads.entries()).map(([userId, workload]) => ({ userId, totalWorkload: workload, limit: this.getRoleWorkloadLimit( assignments.find((a) => a.userId === userId)?.role || ProjectRole.SUPERVISOR, ), available: this.getRoleWorkloadLimit( assignments.find((a) => a.userId === userId)?.role || ProjectRole.SUPERVISOR, ) - workload, })), }; } private async validateWorkloadLimit( userId: string, role: ProjectRole, newWorkload: number, constructoraId: string, ): Promise { const currentWorkload = await this.getUserTotalWorkload(userId, constructoraId); const totalWorkload = currentWorkload + newWorkload; const limit = this.getRoleWorkloadLimit(role); if (totalWorkload > limit) { throw new BadRequestException( `El usuario ya tiene una carga de ${currentWorkload}%. ` + `Asignar ${newWorkload}% adicional excedería el límite de ${limit}% para el rol ${role}.`, ); } } private getRoleWorkloadLimit(role: ProjectRole): number { const limits: Record = { [ProjectRole.DIRECTOR]: 500, [ProjectRole.RESIDENT]: 200, [ProjectRole.ENGINEER]: 800, [ProjectRole.SUPERVISOR]: 100, [ProjectRole.PURCHASES_MANAGER]: 1000, }; return limits[role] || 100; } async remove(id: string, constructoraId: string): Promise { const assignment = await this.findOne(id, constructoraId); await this.assignmentRepo.remove(assignment); this.eventEmitter.emit('team_assignment.deleted', { id }); } } ``` #### MilestonesService ```typescript // apps/backend/src/modules/projects/services/milestones.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Milestone, MilestoneStatus } from '../entities/milestone.entity'; import { CreateMilestoneDto } from '../dto/create-milestone.dto'; import { UpdateMilestoneDto } from '../dto/update-milestone.dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class MilestonesService { constructor( @InjectRepository(Milestone) private milestoneRepo: Repository, private eventEmitter: EventEmitter2, ) {} async create( projectId: string, dto: CreateMilestoneDto, constructoraId: string, userId: string, ): Promise { // Validate dependencies exist if (dto.dependsOnMilestoneIds && dto.dependsOnMilestoneIds.length > 0) { await this.validateDependencies(dto.dependsOnMilestoneIds, projectId, constructoraId); } const milestone = this.milestoneRepo.create({ ...dto, projectId, constructoraId, createdBy: userId, }); const saved = await this.milestoneRepo.save(milestone); this.eventEmitter.emit('milestone.created', saved); return saved; } async findAll( projectId: string, constructoraId: string, filters?: { status?: MilestoneStatus }, ): Promise { const query = this.milestoneRepo .createQueryBuilder('milestone') .where('milestone.projectId = :projectId', { projectId }) .andWhere('milestone.constructoraId = :constructoraId', { constructoraId }) .orderBy('milestone.plannedDate', 'ASC'); if (filters?.status) { query.andWhere('milestone.status = :status', { status: filters.status }); } return query.getMany(); } async findOne(id: string, constructoraId: string): Promise { const milestone = await this.milestoneRepo.findOne({ where: { id, constructoraId }, relations: ['project'], }); if (!milestone) { throw new NotFoundException(`Hito con ID ${id} no encontrado`); } return milestone; } async update( id: string, dto: UpdateMilestoneDto, constructoraId: string, userId: string, ): Promise { const milestone = await this.findOne(id, constructoraId); Object.assign(milestone, dto); milestone.updatedBy = userId; return this.milestoneRepo.save(milestone); } async markComplete( id: string, actualDate: Date, completionNotes: string, constructoraId: string, userId: string, ): Promise { const milestone = await this.findOne(id, constructoraId); // Validate dependencies are completed if (milestone.dependsOnMilestoneIds && milestone.dependsOnMilestoneIds.length > 0) { const dependencies = await this.milestoneRepo.find({ where: { constructoraId }, }); const incompleteDeps = dependencies.filter( (dep) => milestone.dependsOnMilestoneIds.includes(dep.id) && dep.status !== MilestoneStatus.COMPLETED, ); if (incompleteDeps.length > 0) { throw new BadRequestException( `No se puede completar este hito. ${incompleteDeps.length} dependencias aún están pendientes.`, ); } } milestone.status = MilestoneStatus.COMPLETED; milestone.actualDate = actualDate; milestone.completionNotes = completionNotes; milestone.updatedBy = userId; const updated = await this.milestoneRepo.save(milestone); this.eventEmitter.emit('milestone.completed', updated); return updated; } async getTimeline(projectId: string, constructoraId: string): Promise { const milestones = await this.findAll(projectId, constructoraId); // Sort by planned date const sorted = milestones.sort( (a, b) => new Date(a.plannedDate).getTime() - new Date(b.plannedDate).getTime(), ); return sorted.map((milestone, index) => ({ ...milestone, order: index + 1, isDelayed: this.isMilestoneDelayed(milestone), daysUntil: this.getDaysUntilPlanned(milestone), })); } private isMilestoneDelayed(milestone: Milestone): boolean { if (milestone.status === MilestoneStatus.COMPLETED) return false; const now = new Date(); const planned = new Date(milestone.plannedDate); return now > planned; } private getDaysUntilPlanned(milestone: Milestone): number { if (milestone.status === MilestoneStatus.COMPLETED) return 0; const now = new Date(); const planned = new Date(milestone.plannedDate); const diff = planned.getTime() - now.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } private async validateDependencies( dependencyIds: string[], projectId: string, constructoraId: string, ): Promise { const dependencies = await this.milestoneRepo.find({ where: { projectId, constructoraId }, }); const validIds = dependencies.map((d) => d.id); const invalidIds = dependencyIds.filter((id) => !validIds.includes(id)); if (invalidIds.length > 0) { throw new BadRequestException( `Las siguientes dependencias no existen: ${invalidIds.join(', ')}`, ); } } async remove(id: string, constructoraId: string): Promise { const milestone = await this.findOne(id, constructoraId); // Check if any other milestone depends on this one const dependents = await this.milestoneRepo.find({ where: { projectId: milestone.projectId, constructoraId }, }); const hasDependents = dependents.some( (m) => m.dependsOnMilestoneIds && m.dependsOnMilestoneIds.includes(id), ); if (hasDependents) { throw new BadRequestException( 'No se puede eliminar este hito porque otros hitos dependen de él', ); } await this.milestoneRepo.remove(milestone); this.eventEmitter.emit('milestone.deleted', { id }); } } ``` ### 3.4 Controllers ```typescript // apps/backend/src/modules/projects/controllers/team-assignments.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { TeamAssignmentsService } from '../services/team-assignments.service'; import { CreateTeamAssignmentDto } from '../dto/create-team-assignment.dto'; import { UpdateTeamAssignmentDto } from '../dto/update-team-assignment.dto'; import { ProjectRole } from '../entities/project-team-assignment.entity'; @Controller('projects/:projectId/team') @UseGuards(JwtAuthGuard, RolesGuard) export class TeamAssignmentsController { constructor(private teamService: TeamAssignmentsService) {} @Post() @Roles('director', 'admin') async create( @Param('projectId') projectId: string, @Body() dto: CreateTeamAssignmentDto, @Request() req, ) { return this.teamService.create(projectId, dto, req.user.constructoraId, req.user.sub); } @Get() @Roles('director', 'resident', 'engineer', 'supervisor', 'admin') async findAll( @Param('projectId') projectId: string, @Query('role') role: ProjectRole, @Query('isActive') isActive: boolean, @Request() req, ) { return this.teamService.findAll(projectId, req.user.constructoraId, { role, isActive }); } @Get('dashboard') @Roles('director', 'admin') async getDashboard(@Param('projectId') projectId: string, @Request() req) { return this.teamService.getTeamDashboard(projectId, req.user.constructoraId); } @Get(':id') @Roles('director', 'resident', 'engineer', 'supervisor', 'admin') async findOne(@Param('id') id: string, @Request() req) { return this.teamService.findOne(id, req.user.constructoraId); } @Put(':id') @Roles('director', 'admin') async update( @Param('id') id: string, @Body() dto: UpdateTeamAssignmentDto, @Request() req, ) { return this.teamService.update(id, dto, req.user.constructoraId, req.user.sub); } @Put(':id/deactivate') @Roles('director', 'admin') async deactivate( @Param('id') id: string, @Body('endDate') endDate: Date, @Request() req, ) { return this.teamService.deactivate(id, endDate, req.user.constructoraId, req.user.sub); } @Delete(':id') @Roles('director', 'admin') async remove(@Param('id') id: string, @Request() req) { await this.teamService.remove(id, req.user.constructoraId); return { message: 'Asignación eliminada exitosamente' }; } } // apps/backend/src/modules/projects/controllers/milestones.controller.ts import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { MilestonesService } from '../services/milestones.service'; import { CreateMilestoneDto } from '../dto/create-milestone.dto'; import { UpdateMilestoneDto } from '../dto/update-milestone.dto'; import { MilestoneStatus } from '../entities/milestone.entity'; @Controller('projects/:projectId/milestones') @UseGuards(JwtAuthGuard, RolesGuard) export class MilestonesController { constructor(private milestonesService: MilestonesService) {} @Post() @Roles('director', 'resident', 'admin') async create( @Param('projectId') projectId: string, @Body() dto: CreateMilestoneDto, @Request() req, ) { return this.milestonesService.create(projectId, dto, req.user.constructoraId, req.user.sub); } @Get() @Roles('director', 'resident', 'engineer', 'supervisor', 'admin') async findAll( @Param('projectId') projectId: string, @Query('status') status: MilestoneStatus, @Request() req, ) { return this.milestonesService.findAll(projectId, req.user.constructoraId, { status }); } @Get('timeline') @Roles('director', 'resident', 'engineer', 'supervisor', 'admin') async getTimeline(@Param('projectId') projectId: string, @Request() req) { return this.milestonesService.getTimeline(projectId, req.user.constructoraId); } @Get(':id') @Roles('director', 'resident', 'engineer', 'supervisor', 'admin') async findOne(@Param('id') id: string, @Request() req) { return this.milestonesService.findOne(id, req.user.constructoraId); } @Put(':id') @Roles('director', 'resident', 'admin') async update( @Param('id') id: string, @Body() dto: UpdateMilestoneDto, @Request() req, ) { return this.milestonesService.update(id, dto, req.user.constructoraId, req.user.sub); } @Put(':id/complete') @Roles('director', 'resident', 'admin') async markComplete( @Param('id') id: string, @Body('actualDate') actualDate: Date, @Body('completionNotes') completionNotes: string, @Request() req, ) { return this.milestonesService.markComplete( id, actualDate, completionNotes, req.user.constructoraId, req.user.sub, ); } @Delete(':id') @Roles('director', 'admin') async remove(@Param('id') id: string, @Request() req) { await this.milestonesService.remove(id, req.user.constructoraId); return { message: 'Hito eliminado exitosamente' }; } } ``` --- ## 4. Implementación Frontend (React + TypeScript) ### 4.1 Components #### TeamRoster Component ```typescript // apps/frontend/src/features/projects/components/TeamRoster.tsx import React from 'react'; import { useQuery } from '@tanstack/react-query'; import { Users, User, Award, Briefcase } from 'lucide-react'; import { projectsApi } from '../../../services/projects.api'; import type { ProjectTeamAssignment } from '../../../types/projects.types'; interface TeamRosterProps { projectId: string; } export function TeamRoster({ projectId }: TeamRosterProps) { const { data: dashboard, isLoading } = useQuery({ queryKey: ['team-dashboard', projectId], queryFn: () => projectsApi.getTeamDashboard(projectId), }); const getRoleIcon = (role: string) => { switch (role) { case 'director': return ; case 'resident': return ; default: return ; } }; const getRoleName = (role: string): string => { const names: Record = { director: 'Director', resident: 'Residente', engineer: 'Ingeniero', supervisor: 'Supervisor', purchases_manager: 'Gerente de Compras', }; return names[role] || role; }; if (isLoading) { return
Cargando equipo...
; } return (

Equipo del Proyecto

{dashboard?.totalMembers} miembros
{dashboard?.byRole.map((roleGroup: any) => (
{getRoleIcon(roleGroup.role)}

{getRoleName(roleGroup.role)}

({roleGroup.count})
{roleGroup.members.map((member: ProjectTeamAssignment) => (

{member.userId}

{member.specialty && (

Especialidad: {member.specialty}

)} {member.isPrimary && ( Principal )}

{member.workloadPercentage}% carga

Desde {new Date(member.startDate).toLocaleDateString('es-MX')}

))}
))}
{/* Workload Summary */}

Carga de Trabajo

{dashboard?.userWorkloads.map((userWorkload: any) => (
{userWorkload.userId}
userWorkload.limit ? 'bg-red-500' : userWorkload.totalWorkload > userWorkload.limit * 0.8 ? 'bg-yellow-500' : 'bg-green-500' }`} style={{ width: `${(userWorkload.totalWorkload / userWorkload.limit) * 100}%` }} />
{userWorkload.totalWorkload}% / {userWorkload.limit}%
))}
); } ``` #### MilestoneTimeline Component ```typescript // apps/frontend/src/features/projects/components/MilestoneTimeline.tsx import React from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Calendar, CheckCircle, Clock, AlertTriangle } from 'lucide-react'; import { toast } from 'sonner'; import { projectsApi } from '../../../services/projects.api'; import type { Milestone } from '../../../types/projects.types'; interface MilestoneTimelineProps { projectId: string; } export function MilestoneTimeline({ projectId }: MilestoneTimelineProps) { const queryClient = useQueryClient(); const { data: milestones, isLoading } = useQuery({ queryKey: ['milestones-timeline', projectId], queryFn: () => projectsApi.getMilestoneTimeline(projectId), }); const completeMutation = useMutation({ mutationFn: (data: { id: string; actualDate: Date; notes: string }) => projectsApi.completeMilestone(data.id, data.actualDate, data.notes), onSuccess: () => { toast.success('Hito marcado como completado'); queryClient.invalidateQueries({ queryKey: ['milestones-timeline', projectId] }); }, }); const getStatusIcon = (milestone: any) => { if (milestone.status === 'completed') { return ; } if (milestone.isDelayed) { return ; } return ; }; const getStatusColor = (milestone: any): string => { if (milestone.status === 'completed') return 'border-green-500 bg-green-50'; if (milestone.isDelayed) return 'border-red-500 bg-red-50'; if (milestone.daysUntil <= 7) return 'border-yellow-500 bg-yellow-50'; return 'border-blue-500 bg-blue-50'; }; if (isLoading) { return
Cargando hitos...
; } return (

Hitos del Proyecto

{/* Timeline Line */}
{/* Milestones */}
{milestones?.map((milestone: any, index: number) => (
{/* Icon */}
{getStatusIcon(milestone)}
{/* Content */}

{milestone.name}

{milestone.description}

{milestone.status}
Planificado:{' '} {new Date(milestone.plannedDate).toLocaleDateString('es-MX')}
{milestone.actualDate && (
Completado:{' '} {new Date(milestone.actualDate).toLocaleDateString('es-MX')}
)} {!milestone.actualDate && milestone.daysUntil !== undefined && (
{milestone.daysUntil > 0 ? ( En {milestone.daysUntil} días ) : ( Atrasado {Math.abs(milestone.daysUntil)} días )}
)}
{milestone.deliverables && milestone.deliverables.length > 0 && (
Entregables:
    {milestone.deliverables.map((deliverable: string, idx: number) => (
  • {deliverable}
  • ))}
)} {milestone.status !== 'completed' && ( )}
))}
); } ``` --- ## 5. Cron Jobs para Alertas ```typescript // apps/backend/src/modules/projects/services/alerts.service.ts import { Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThanOrEqual } from 'typeorm'; import { Milestone } from '../entities/milestone.entity'; import { CriticalDate } from '../entities/critical-date.entity'; import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class AlertsService { constructor( @InjectRepository(Milestone) private milestoneRepo: Repository, @InjectRepository(CriticalDate) private criticalDateRepo: Repository, private eventEmitter: EventEmitter2, ) {} // Run daily at 9:00 AM @Cron(CronExpression.EVERY_DAY_AT_9AM) async sendMilestoneAlerts(): Promise { const today = new Date(); // Get milestones approaching their planned date const milestones = await this.milestoneRepo .createQueryBuilder('milestone') .where('milestone.status = :status', { status: 'pending' }) .andWhere('milestone.plannedDate >= :today', { today }) .getMany(); for (const milestone of milestones) { const daysUntil = this.getDaysUntil(milestone.plannedDate); if (daysUntil <= milestone.alertDaysBefore) { // Check if alert was already sent recently (within last 24 hours) if (this.shouldSendAlert(milestone.lastAlertSentAt)) { this.eventEmitter.emit('alert.milestone_approaching', { milestone, daysUntil, }); milestone.lastAlertSentAt = new Date(); await this.milestoneRepo.save(milestone); } } } } // Run daily at 9:00 AM @Cron(CronExpression.EVERY_DAY_AT_9AM) async sendCriticalDateAlerts(): Promise { const today = new Date(); const criticalDates = await this.criticalDateRepo .createQueryBuilder('cd') .where('cd.status = :status', { status: 'pending' }) .andWhere('cd.date >= :today', { today }) .getMany(); for (const criticalDate of criticalDates) { const daysUntil = this.getDaysUntil(criticalDate.date); if (daysUntil <= criticalDate.alertDaysBefore) { if (this.shouldSendAlert(criticalDate.lastAlertSentAt)) { this.eventEmitter.emit('alert.critical_date_approaching', { criticalDate, daysUntil, }); criticalDate.lastAlertSentAt = new Date(); await this.criticalDateRepo.save(criticalDate); } } } } private getDaysUntil(date: Date): number { const now = new Date(); const targetDate = new Date(date); const diff = targetDate.getTime() - now.getTime(); return Math.ceil(diff / (1000 * 60 * 60 * 24)); } private shouldSendAlert(lastAlertSent: Date | null): boolean { if (!lastAlertSent) return true; const hoursSinceLastAlert = (new Date().getTime() - new Date(lastAlertSent).getTime()) / (1000 * 60 * 60); // Send alert if last one was more than 24 hours ago return hoursSinceLastAlert >= 24; } } ``` --- ## 6. Validaciones de Negocio 1. **Workload:** - Total workload de un usuario no debe exceder límite de su rol - Director: max 500%, Residente: max 200%, Ingeniero: max 800% - Validación en creación y actualización de asignaciones 2. **Equipo:** - Solo un Director principal (isPrimary = true) por proyecto - Al menos un Residente activo en proyectos en ejecución - Fechas: endDate >= startDate 3. **Milestones:** - Dependencias deben existir en el mismo proyecto - No se puede completar un hito si sus dependencias están pendientes - No se puede eliminar un hito si otros dependen de él 4. **Fechas Críticas:** - Alertas enviadas automáticamente según alertDaysBefore - No duplicar alertas (máximo 1 cada 24 horas) --- ## 7. Eventos Emitidos ```typescript 'team_assignment.created': { assignment: ProjectTeamAssignment } 'team_assignment.deactivated': { assignment: ProjectTeamAssignment } 'team_assignment.deleted': { id: string } 'milestone.created': { milestone: Milestone } 'milestone.completed': { milestone: Milestone } 'milestone.deleted': { id: string } 'critical_date.created': { criticalDate: CriticalDate } 'critical_date.met': { criticalDate: CriticalDate } 'critical_date.missed': { criticalDate: CriticalDate } 'alert.milestone_approaching': { milestone: Milestone, daysUntil: number } 'alert.critical_date_approaching': { criticalDate: CriticalDate, daysUntil: number } ``` --- **Fecha de generación:** 2025-11-17 **Autor:** Sistema de Documentación Técnica **Versión:** 1.0 **Estado:** ✅ Completo