ET-SEG-001: Especificaciones Técnicas - Frontend Seguridad Industrial
1. Información General
| Campo |
Valor |
| Módulo |
MAI-007 - Seguridad Industrial |
| Vertical |
Construcción |
| Documento |
ET-SEG-001-frontend |
| Versión |
1.0.0 |
| Fecha |
2025-12-06 |
| Responsable |
Equipo Frontend |
2. Stack Tecnológico
2.1 Core Technologies
{
"runtime": "React 18",
"buildTool": "Vite 5.x",
"language": "TypeScript 5.x",
"stateManagement": "Zustand 4.x",
"routing": "React Router 6.x"
}
2.2 Librerías Principales
| Librería |
Versión |
Propósito |
react |
^18.2.0 |
Framework UI |
react-dom |
^18.2.0 |
DOM rendering |
vite |
^5.0.0 |
Build tool |
zustand |
^4.5.0 |
State management |
react-router-dom |
^6.22.0 |
Routing |
@fullcalendar/react |
^6.1.0 |
Calendario capacitaciones |
@fullcalendar/daygrid |
^6.1.0 |
Vista mensual |
@fullcalendar/timegrid |
^6.1.0 |
Vista semanal |
@fullcalendar/interaction |
^6.1.0 |
Interacciones |
react-hook-form |
^7.51.0 |
Gestión de formularios |
zod |
^3.22.0 |
Validación schemas |
date-fns |
^3.3.0 |
Manipulación fechas |
recharts |
^2.12.0 |
Gráficos KPIs |
lucide-react |
^0.344.0 |
Iconos |
3. Arquitectura Frontend
3.1 Estructura de Directorios
apps/verticales/construccion/frontend/src/modules/safety/
├── features/
│ ├── epp/
│ │ ├── components/
│ │ │ ├── EPPAssignmentForm.tsx
│ │ │ ├── EPPInventoryList.tsx
│ │ │ ├── EPPAssignmentHistory.tsx
│ │ │ └── EPPExpirationAlerts.tsx
│ │ ├── hooks/
│ │ │ ├── useEPPAssignment.ts
│ │ │ ├── useEPPInventory.ts
│ │ │ └── useEPPValidations.ts
│ │ ├── stores/
│ │ │ └── eppStore.ts
│ │ ├── types/
│ │ │ └── epp.types.ts
│ │ └── utils/
│ │ └── eppValidations.ts
│ │
│ ├── safety-inspections/
│ │ ├── components/
│ │ │ ├── SafetyChecklist.tsx
│ │ │ ├── ChecklistTemplateBuilder.tsx
│ │ │ ├── InspectionForm.tsx
│ │ │ ├── InspectionResults.tsx
│ │ │ └── InspectionHistory.tsx
│ │ ├── hooks/
│ │ │ ├── useSafetyInspection.ts
│ │ │ ├── useChecklist.ts
│ │ │ └── useInspectionValidations.ts
│ │ ├── stores/
│ │ │ └── inspectionStore.ts
│ │ ├── types/
│ │ │ └── inspection.types.ts
│ │ └── utils/
│ │ └── checklistCalculations.ts
│ │
│ ├── incidents/
│ │ ├── components/
│ │ │ ├── IncidentReportForm.tsx
│ │ │ ├── IncidentTimeline.tsx
│ │ │ ├── IncidentDetails.tsx
│ │ │ ├── IncidentPhotos.tsx
│ │ │ ├── IncidentWitnesses.tsx
│ │ │ └── IncidentCorrectiveActions.tsx
│ │ ├── hooks/
│ │ │ ├── useIncidentReport.ts
│ │ │ ├── useIncidentTimeline.ts
│ │ │ └── useIncidentAnalysis.ts
│ │ ├── stores/
│ │ │ └── incidentStore.ts
│ │ ├── types/
│ │ │ └── incident.types.ts
│ │ └── utils/
│ │ └── incidentSeverityCalculator.ts
│ │
│ └── trainings/
│ ├── components/
│ │ ├── TrainingCalendar.tsx
│ │ ├── TrainingMatrix.tsx
│ │ ├── TrainingScheduleForm.tsx
│ │ ├── TrainingAttendance.tsx
│ │ ├── TrainingCertificates.tsx
│ │ └── TrainingReminders.tsx
│ ├── hooks/
│ │ ├── useTrainingCalendar.ts
│ │ ├── useTrainingMatrix.ts
│ │ └── useTrainingCertifications.ts
│ ├── stores/
│ │ └── trainingStore.ts
│ ├── types/
│ │ └── training.types.ts
│ └── utils/
│ └── certificationValidator.ts
│
├── dashboard/
│ ├── components/
│ │ ├── SafetyDashboard.tsx
│ │ ├── IncidentRateKPI.tsx
│ │ ├── ComplianceKPI.tsx
│ │ ├── EPPComplianceChart.tsx
│ │ ├── TrainingCompletionChart.tsx
│ │ ├── IncidentTrendChart.tsx
│ │ └── SafetyAlerts.tsx
│ ├── hooks/
│ │ └── useSafetyMetrics.ts
│ └── stores/
│ └── dashboardStore.ts
│
├── shared/
│ ├── components/
│ │ ├── SeverityBadge.tsx
│ │ ├── StatusIndicator.tsx
│ │ └── SafetyIcon.tsx
│ ├── constants/
│ │ └── safetyConstants.ts
│ └── utils/
│ ├── dateHelpers.ts
│ └── formatters.ts
│
└── routes/
└── safetyRoutes.tsx
4. Feature Modules
4.1 EPP (Equipo de Protección Personal)
4.1.1 EPPAssignmentForm Component
// apps/verticales/construccion/frontend/src/modules/safety/features/epp/components/EPPAssignmentForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEPPAssignment } from '../hooks/useEPPAssignment';
const eppAssignmentSchema = z.object({
employeeId: z.string().min(1, 'Empleado requerido'),
projectId: z.string().min(1, 'Proyecto requerido'),
eppItems: z.array(z.object({
eppId: z.string(),
quantity: z.number().min(1),
size: z.string().optional(),
expirationDate: z.date().optional(),
serialNumber: z.string().optional()
})).min(1, 'Debe asignar al menos un EPP'),
assignmentDate: z.date(),
notes: z.string().optional()
});
type EPPAssignmentFormData = z.infer<typeof eppAssignmentSchema>;
export const EPPAssignmentForm: React.FC = () => {
const { assignEPP, isLoading } = useEPPAssignment();
const { register, handleSubmit, formState: { errors } } = useForm<EPPAssignmentFormData>({
resolver: zodResolver(eppAssignmentSchema),
defaultValues: {
assignmentDate: new Date(),
eppItems: []
}
});
const onSubmit = async (data: EPPAssignmentFormData) => {
await assignEPP(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form implementation */}
</form>
);
};
4.1.2 EPP Store
// apps/verticales/construccion/frontend/src/modules/safety/features/epp/stores/eppStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface EPPItem {
id: string;
name: string;
category: string;
stock: number;
minStock: number;
cost: number;
}
interface EPPAssignment {
id: string;
employeeId: string;
employeeName: string;
projectId: string;
items: Array<{
eppId: string;
eppName: string;
quantity: number;
assignmentDate: string;
expirationDate?: string;
}>;
status: 'active' | 'returned' | 'expired';
}
interface EPPStore {
// State
inventory: EPPItem[];
assignments: EPPAssignment[];
selectedAssignment: EPPAssignment | null;
isLoading: boolean;
error: string | null;
// Actions
fetchInventory: () => Promise<void>;
fetchAssignments: (filters?: Record<string, any>) => Promise<void>;
createAssignment: (data: any) => Promise<void>;
updateAssignment: (id: string, data: any) => Promise<void>;
returnEPP: (assignmentId: string, items: string[]) => Promise<void>;
setSelectedAssignment: (assignment: EPPAssignment | null) => void;
checkExpiredEPP: () => EPPAssignment[];
checkLowStock: () => EPPItem[];
}
export const useEPPStore = create<EPPStore>()(
devtools(
persist(
(set, get) => ({
// Initial state
inventory: [],
assignments: [],
selectedAssignment: null,
isLoading: false,
error: null,
// Actions
fetchInventory: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/epp/inventory');
const data = await response.json();
set({ inventory: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
fetchAssignments: async (filters = {}) => {
set({ isLoading: true, error: null });
try {
const queryParams = new URLSearchParams(filters);
const response = await fetch(`/api/safety/epp/assignments?${queryParams}`);
const data = await response.json();
set({ assignments: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
createAssignment: async (data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/epp/assignments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const newAssignment = await response.json();
set(state => ({
assignments: [...state.assignments, newAssignment],
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
updateAssignment: async (id, data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/epp/assignments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const updated = await response.json();
set(state => ({
assignments: state.assignments.map(a => a.id === id ? updated : a),
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
returnEPP: async (assignmentId, items) => {
set({ isLoading: true, error: null });
try {
await fetch(`/api/safety/epp/assignments/${assignmentId}/return`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
await get().fetchAssignments();
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
setSelectedAssignment: (assignment) => set({ selectedAssignment: assignment }),
checkExpiredEPP: () => {
const now = new Date();
return get().assignments.filter(assignment =>
assignment.items.some(item =>
item.expirationDate && new Date(item.expirationDate) <= now
)
);
},
checkLowStock: () => {
return get().inventory.filter(item => item.stock <= item.minStock);
}
}),
{
name: 'epp-storage',
partialize: (state) => ({ selectedAssignment: state.selectedAssignment })
}
)
)
);
4.2 Safety Inspections
4.2.1 SafetyChecklist Component
// apps/verticales/construccion/frontend/src/modules/safety/features/safety-inspections/components/SafetyChecklist.tsx
import React, { useState } from 'react';
import { Check, X, AlertTriangle } from 'lucide-react';
import { useChecklist } from '../hooks/useChecklist';
interface ChecklistItem {
id: string;
category: string;
description: string;
required: boolean;
status?: 'compliant' | 'non-compliant' | 'not-applicable';
observations?: string;
photos?: string[];
}
interface SafetyChecklistProps {
templateId: string;
projectId: string;
inspectionDate: Date;
onSubmit: (data: any) => void;
}
export const SafetyChecklist: React.FC<SafetyChecklistProps> = ({
templateId,
projectId,
inspectionDate,
onSubmit
}) => {
const { template, isLoading } = useChecklist(templateId);
const [items, setItems] = useState<ChecklistItem[]>([]);
const [observations, setObservations] = useState<Record<string, string>>({});
const handleItemChange = (itemId: string, status: ChecklistItem['status']) => {
setItems(prev =>
prev.map(item =>
item.id === itemId ? { ...item, status } : item
)
);
};
const handleObservationChange = (itemId: string, observation: string) => {
setObservations(prev => ({
...prev,
[itemId]: observation
}));
};
const calculateCompliance = () => {
const total = items.filter(item => item.required).length;
const compliant = items.filter(
item => item.required && item.status === 'compliant'
).length;
return total > 0 ? (compliant / total) * 100 : 0;
};
const handleSubmit = () => {
const inspectionData = {
templateId,
projectId,
inspectionDate,
items: items.map(item => ({
...item,
observations: observations[item.id]
})),
compliancePercentage: calculateCompliance(),
completedBy: 'current-user-id', // From auth context
completedAt: new Date()
};
onSubmit(inspectionData);
};
if (isLoading) return <div>Cargando checklist...</div>;
return (
<div className="safety-checklist">
<div className="checklist-header">
<h2>{template?.name}</h2>
<div className="compliance-score">
Cumplimiento: {calculateCompliance().toFixed(1)}%
</div>
</div>
{template?.categories.map(category => (
<div key={category.id} className="checklist-category">
<h3>{category.name}</h3>
{category.items.map(item => (
<div key={item.id} className="checklist-item">
<div className="item-description">
{item.description}
{item.required && <span className="required">*</span>}
</div>
<div className="item-controls">
<button
className={`status-btn ${items.find(i => i.id === item.id)?.status === 'compliant' ? 'active' : ''}`}
onClick={() => handleItemChange(item.id, 'compliant')}
>
<Check size={20} /> Conforme
</button>
<button
className={`status-btn ${items.find(i => i.id === item.id)?.status === 'non-compliant' ? 'active' : ''}`}
onClick={() => handleItemChange(item.id, 'non-compliant')}
>
<X size={20} /> No Conforme
</button>
<button
className={`status-btn ${items.find(i => i.id === item.id)?.status === 'not-applicable' ? 'active' : ''}`}
onClick={() => handleItemChange(item.id, 'not-applicable')}
>
N/A
</button>
</div>
{items.find(i => i.id === item.id)?.status === 'non-compliant' && (
<textarea
placeholder="Observaciones sobre el incumplimiento..."
value={observations[item.id] || ''}
onChange={(e) => handleObservationChange(item.id, e.target.value)}
className="observations-input"
/>
)}
</div>
))}
</div>
))}
<button onClick={handleSubmit} className="submit-btn">
Finalizar Inspección
</button>
</div>
);
};
4.2.2 Inspection Store
// apps/verticales/construccion/frontend/src/modules/safety/features/safety-inspections/stores/inspectionStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface ChecklistTemplate {
id: string;
name: string;
description: string;
categories: Array<{
id: string;
name: string;
items: Array<{
id: string;
description: string;
required: boolean;
}>;
}>;
}
interface Inspection {
id: string;
projectId: string;
templateId: string;
inspectionDate: string;
compliancePercentage: number;
status: 'draft' | 'completed' | 'requires-followup';
completedBy: string;
items: any[];
}
interface InspectionStore {
templates: ChecklistTemplate[];
inspections: Inspection[];
selectedInspection: Inspection | null;
isLoading: boolean;
error: string | null;
fetchTemplates: () => Promise<void>;
fetchInspections: (projectId?: string) => Promise<void>;
createInspection: (data: any) => Promise<void>;
updateInspection: (id: string, data: any) => Promise<void>;
setSelectedInspection: (inspection: Inspection | null) => void;
getComplianceTrend: (projectId: string) => Promise<any[]>;
}
export const useInspectionStore = create<InspectionStore>()(
devtools((set, get) => ({
templates: [],
inspections: [],
selectedInspection: null,
isLoading: false,
error: null,
fetchTemplates: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/inspections/templates');
const data = await response.json();
set({ templates: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
fetchInspections: async (projectId) => {
set({ isLoading: true, error: null });
try {
const url = projectId
? `/api/safety/inspections?projectId=${projectId}`
: '/api/safety/inspections';
const response = await fetch(url);
const data = await response.json();
set({ inspections: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
createInspection: async (data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/inspections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const newInspection = await response.json();
set(state => ({
inspections: [...state.inspections, newInspection],
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
updateInspection: async (id, data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/inspections/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const updated = await response.json();
set(state => ({
inspections: state.inspections.map(i => i.id === id ? updated : i),
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
setSelectedInspection: (inspection) => set({ selectedInspection: inspection }),
getComplianceTrend: async (projectId) => {
try {
const response = await fetch(`/api/safety/inspections/compliance-trend?projectId=${projectId}`);
return await response.json();
} catch (error) {
console.error('Error fetching compliance trend:', error);
return [];
}
}
}))
);
4.3 Incidents
4.3.1 IncidentReportForm Component
// apps/verticales/construccion/frontend/src/modules/safety/features/incidents/components/IncidentReportForm.tsx
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useIncidentReport } from '../hooks/useIncidentReport';
const incidentSchema = z.object({
projectId: z.string().min(1, 'Proyecto requerido'),
incidentDate: z.date(),
incidentTime: z.string(),
location: z.string().min(1, 'Ubicación requerida'),
type: z.enum(['accident', 'near-miss', 'property-damage', 'environmental']),
severity: z.enum(['minor', 'moderate', 'serious', 'critical', 'fatal']),
description: z.string().min(20, 'Descripción debe tener al menos 20 caracteres'),
affectedPersons: z.array(z.object({
employeeId: z.string().optional(),
name: z.string().min(1),
role: z.string(),
injuryType: z.string().optional(),
medicalAttention: z.boolean()
})),
witnesses: z.array(z.object({
employeeId: z.string().optional(),
name: z.string().min(1),
contact: z.string(),
statement: z.string()
})),
immediateActions: z.string(),
rootCause: z.string().optional(),
correctiveActions: z.array(z.object({
description: z.string().min(1),
responsible: z.string(),
dueDate: z.date(),
status: z.enum(['pending', 'in-progress', 'completed'])
})),
photos: z.array(z.string()).optional(),
reportedBy: z.string()
});
type IncidentFormData = z.infer<typeof incidentSchema>;
export const IncidentReportForm: React.FC = () => {
const { createIncident, isLoading } = useIncidentReport();
const {
register,
control,
handleSubmit,
formState: { errors }
} = useForm<IncidentFormData>({
resolver: zodResolver(incidentSchema),
defaultValues: {
incidentDate: new Date(),
affectedPersons: [],
witnesses: [],
correctiveActions: []
}
});
const { fields: affectedFields, append: addAffected, remove: removeAffected } =
useFieldArray({ control, name: 'affectedPersons' });
const { fields: witnessFields, append: addWitness, remove: removeWitness } =
useFieldArray({ control, name: 'witnesses' });
const { fields: actionFields, append: addAction, remove: removeAction } =
useFieldArray({ control, name: 'correctiveActions' });
const onSubmit = async (data: IncidentFormData) => {
await createIncident(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="incident-form">
<section className="form-section">
<h3>Información General</h3>
{/* General info fields */}
</section>
<section className="form-section">
<h3>Personas Afectadas</h3>
{affectedFields.map((field, index) => (
<div key={field.id} className="array-item">
{/* Affected person fields */}
</div>
))}
<button type="button" onClick={() => addAffected({})}>
Agregar Persona Afectada
</button>
</section>
<section className="form-section">
<h3>Testigos</h3>
{witnessFields.map((field, index) => (
<div key={field.id} className="array-item">
{/* Witness fields */}
</div>
))}
<button type="button" onClick={() => addWitness({})}>
Agregar Testigo
</button>
</section>
<section className="form-section">
<h3>Acciones Correctivas</h3>
{actionFields.map((field, index) => (
<div key={field.id} className="array-item">
{/* Corrective action fields */}
</div>
))}
<button type="button" onClick={() => addAction({})}>
Agregar Acción Correctiva
</button>
</section>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Guardando...' : 'Registrar Incidente'}
</button>
</form>
);
};
4.3.2 IncidentTimeline Component
// apps/verticales/construccion/frontend/src/modules/safety/features/incidents/components/IncidentTimeline.tsx
import React from 'react';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
import { Clock, CheckCircle, AlertCircle, User } from 'lucide-react';
interface TimelineEvent {
id: string;
type: 'incident' | 'action' | 'update' | 'closure';
timestamp: string;
title: string;
description: string;
user: string;
status?: string;
}
interface IncidentTimelineProps {
incidentId: string;
events: TimelineEvent[];
}
export const IncidentTimeline: React.FC<IncidentTimelineProps> = ({
incidentId,
events
}) => {
const getIcon = (type: TimelineEvent['type']) => {
switch (type) {
case 'incident':
return <AlertCircle className="text-red-500" />;
case 'action':
return <Clock className="text-blue-500" />;
case 'update':
return <User className="text-yellow-500" />;
case 'closure':
return <CheckCircle className="text-green-500" />;
default:
return <Clock />;
}
};
return (
<div className="incident-timeline">
<h3>Línea de Tiempo del Incidente</h3>
<div className="timeline-container">
{events.map((event, index) => (
<div key={event.id} className="timeline-item">
<div className="timeline-marker">
{getIcon(event.type)}
</div>
<div className="timeline-content">
<div className="timeline-header">
<h4>{event.title}</h4>
<span className="timeline-date">
{format(new Date(event.timestamp), "dd MMM yyyy HH:mm", { locale: es })}
</span>
</div>
<p className="timeline-description">{event.description}</p>
<div className="timeline-meta">
<span className="timeline-user">
<User size={14} /> {event.user}
</span>
{event.status && (
<span className={`timeline-status status-${event.status}`}>
{event.status}
</span>
)}
</div>
</div>
{index < events.length - 1 && <div className="timeline-connector" />}
</div>
))}
</div>
</div>
);
};
4.3.3 Incident Store
// apps/verticales/construccion/frontend/src/modules/safety/features/incidents/stores/incidentStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface Incident {
id: string;
projectId: string;
projectName: string;
incidentDate: string;
type: string;
severity: string;
description: string;
status: 'reported' | 'investigating' | 'actions-pending' | 'closed';
affectedPersons: any[];
witnesses: any[];
correctiveActions: any[];
photos: string[];
timeline: any[];
}
interface IncidentStore {
incidents: Incident[];
selectedIncident: Incident | null;
filters: {
projectId?: string;
severity?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
};
isLoading: boolean;
error: string | null;
fetchIncidents: (filters?: any) => Promise<void>;
getIncidentById: (id: string) => Promise<void>;
createIncident: (data: any) => Promise<void>;
updateIncident: (id: string, data: any) => Promise<void>;
addCorrectiveAction: (incidentId: string, action: any) => Promise<void>;
updateActionStatus: (incidentId: string, actionId: string, status: string) => Promise<void>;
closeIncident: (incidentId: string) => Promise<void>;
setFilters: (filters: any) => void;
setSelectedIncident: (incident: Incident | null) => void;
getIncidentRate: (projectId: string, period: string) => Promise<number>;
}
export const useIncidentStore = create<IncidentStore>()(
devtools((set, get) => ({
incidents: [],
selectedIncident: null,
filters: {},
isLoading: false,
error: null,
fetchIncidents: async (filters = {}) => {
set({ isLoading: true, error: null, filters });
try {
const queryParams = new URLSearchParams(filters);
const response = await fetch(`/api/safety/incidents?${queryParams}`);
const data = await response.json();
set({ incidents: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
getIncidentById: async (id) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/incidents/${id}`);
const data = await response.json();
set({ selectedIncident: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
createIncident: async (data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/incidents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const newIncident = await response.json();
set(state => ({
incidents: [...state.incidents, newIncident],
isLoading: false
}));
return newIncident;
} catch (error) {
set({ error: error.message, isLoading: false });
throw error;
}
},
updateIncident: async (id, data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/incidents/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const updated = await response.json();
set(state => ({
incidents: state.incidents.map(i => i.id === id ? updated : i),
selectedIncident: state.selectedIncident?.id === id ? updated : state.selectedIncident,
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
addCorrectiveAction: async (incidentId, action) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/incidents/${incidentId}/actions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action)
});
await response.json();
await get().getIncidentById(incidentId);
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
updateActionStatus: async (incidentId, actionId, status) => {
try {
await fetch(`/api/safety/incidents/${incidentId}/actions/${actionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
await get().getIncidentById(incidentId);
} catch (error) {
console.error('Error updating action status:', error);
}
},
closeIncident: async (incidentId) => {
set({ isLoading: true, error: null });
try {
await fetch(`/api/safety/incidents/${incidentId}/close`, {
method: 'POST'
});
await get().getIncidentById(incidentId);
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
setFilters: (filters) => set({ filters }),
setSelectedIncident: (incident) => set({ selectedIncident: incident }),
getIncidentRate: async (projectId, period) => {
try {
const response = await fetch(
`/api/safety/incidents/rate?projectId=${projectId}&period=${period}`
);
const data = await response.json();
return data.rate;
} catch (error) {
console.error('Error fetching incident rate:', error);
return 0;
}
}
}))
);
4.4 Trainings
4.4.1 TrainingCalendar Component
// apps/verticales/construccion/frontend/src/modules/safety/features/trainings/components/TrainingCalendar.tsx
import React, { useEffect, useState } from 'react';
import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import esLocale from '@fullcalendar/core/locales/es';
import { useTrainingCalendar } from '../hooks/useTrainingCalendar';
interface TrainingCalendarProps {
projectId?: string;
onEventClick?: (event: any) => void;
onDateClick?: (date: Date) => void;
}
export const TrainingCalendar: React.FC<TrainingCalendarProps> = ({
projectId,
onEventClick,
onDateClick
}) => {
const { trainings, fetchTrainings, isLoading } = useTrainingCalendar();
const [events, setEvents] = useState([]);
useEffect(() => {
fetchTrainings(projectId);
}, [projectId]);
useEffect(() => {
const calendarEvents = trainings.map(training => ({
id: training.id,
title: training.title,
start: training.startDate,
end: training.endDate,
backgroundColor: getEventColor(training.status),
borderColor: getEventColor(training.status),
extendedProps: {
instructor: training.instructor,
capacity: training.capacity,
enrolled: training.enrolled,
status: training.status,
location: training.location
}
}));
setEvents(calendarEvents);
}, [trainings]);
const getEventColor = (status: string) => {
const colors = {
'scheduled': '#3b82f6',
'in-progress': '#f59e0b',
'completed': '#10b981',
'cancelled': '#ef4444'
};
return colors[status] || '#6b7280';
};
const handleEventClick = (info: any) => {
if (onEventClick) {
onEventClick(info.event.extendedProps);
}
};
const handleDateClick = (info: any) => {
if (onDateClick) {
onDateClick(info.date);
}
};
return (
<div className="training-calendar">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
locale={esLocale}
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
}}
events={events}
eventClick={handleEventClick}
dateClick={handleDateClick}
height="auto"
eventContent={(eventInfo) => (
<div className="custom-event">
<div className="event-title">{eventInfo.event.title}</div>
<div className="event-details">
{eventInfo.event.extendedProps.enrolled}/{eventInfo.event.extendedProps.capacity}
</div>
</div>
)}
eventDidMount={(info) => {
info.el.setAttribute('title',
`${info.event.title}\nInstructor: ${info.event.extendedProps.instructor}\nLugar: ${info.event.extendedProps.location}`
);
}}
/>
</div>
);
};
4.4.2 TrainingMatrix Component
// apps/verticales/construccion/frontend/src/modules/safety/features/trainings/components/TrainingMatrix.tsx
import React, { useEffect } from 'react';
import { Check, X, Clock, AlertCircle } from 'lucide-react';
import { useTrainingMatrix } from '../hooks/useTrainingMatrix';
interface TrainingMatrixProps {
projectId: string;
}
export const TrainingMatrix: React.FC<TrainingMatrixProps> = ({ projectId }) => {
const { matrix, fetchMatrix, isLoading } = useTrainingMatrix();
useEffect(() => {
fetchMatrix(projectId);
}, [projectId]);
const getCertificationStatus = (certification: any) => {
if (!certification) return { icon: X, color: 'text-red-500', text: 'No cursado' };
if (certification.expired) {
return { icon: AlertCircle, color: 'text-orange-500', text: 'Vencido' };
}
if (certification.expiresSoon) {
return { icon: Clock, color: 'text-yellow-500', text: 'Por vencer' };
}
return { icon: Check, color: 'text-green-500', text: 'Vigente' };
};
if (isLoading) return <div>Cargando matriz de capacitaciones...</div>;
return (
<div className="training-matrix">
<div className="matrix-header">
<h3>Matriz de Capacitaciones - {matrix?.projectName}</h3>
<div className="matrix-stats">
<span>Vigentes: {matrix?.stats.valid}</span>
<span>Por vencer: {matrix?.stats.expiringSoon}</span>
<span>Vencidos: {matrix?.stats.expired}</span>
<span>Faltantes: {matrix?.stats.missing}</span>
</div>
</div>
<div className="matrix-table-container">
<table className="matrix-table">
<thead>
<tr>
<th className="sticky-col">Empleado</th>
<th className="sticky-col">Puesto</th>
{matrix?.requiredTrainings.map(training => (
<th key={training.id} className="training-header">
{training.name}
</th>
))}
</tr>
</thead>
<tbody>
{matrix?.employees.map(employee => (
<tr key={employee.id}>
<td className="sticky-col employee-name">{employee.name}</td>
<td className="sticky-col">{employee.role}</td>
{matrix.requiredTrainings.map(training => {
const certification = employee.certifications.find(
c => c.trainingId === training.id
);
const status = getCertificationStatus(certification);
const Icon = status.icon;
return (
<td key={training.id} className="cert-cell">
<div className={`cert-status ${status.color}`}>
<Icon size={18} />
{certification && (
<span className="cert-date">
{new Date(certification.expirationDate).toLocaleDateString('es-ES')}
</span>
)}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="matrix-legend">
<h4>Leyenda:</h4>
<div className="legend-items">
<span><Check className="text-green-500" /> Vigente</span>
<span><Clock className="text-yellow-500" /> Por vencer (30 días)</span>
<span><AlertCircle className="text-orange-500" /> Vencido</span>
<span><X className="text-red-500" /> No cursado</span>
</div>
</div>
</div>
);
};
4.4.3 Training Store
// apps/verticales/construccion/frontend/src/modules/safety/features/trainings/stores/trainingStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface Training {
id: string;
title: string;
description: string;
category: string;
duration: number;
instructor: string;
capacity: number;
enrolled: number;
startDate: string;
endDate: string;
location: string;
status: 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
participants: string[];
validityPeriod: number; // months
}
interface Certification {
id: string;
employeeId: string;
trainingId: string;
completionDate: string;
expirationDate: string;
score?: number;
certificateUrl?: string;
}
interface TrainingStore {
trainings: Training[];
certifications: Certification[];
selectedTraining: Training | null;
isLoading: boolean;
error: string | null;
fetchTrainings: (filters?: any) => Promise<void>;
getTrainingById: (id: string) => Promise<void>;
createTraining: (data: any) => Promise<void>;
updateTraining: (id: string, data: any) => Promise<void>;
cancelTraining: (id: string, reason: string) => Promise<void>;
enrollEmployee: (trainingId: string, employeeId: string) => Promise<void>;
unenrollEmployee: (trainingId: string, employeeId: string) => Promise<void>;
recordAttendance: (trainingId: string, attendees: string[]) => Promise<void>;
issueCertification: (trainingId: string, employeeId: string, score?: number) => Promise<void>;
fetchCertifications: (employeeId?: string) => Promise<void>;
getExpiringCertifications: (days: number) => Certification[];
setSelectedTraining: (training: Training | null) => void;
}
export const useTrainingStore = create<TrainingStore>()(
devtools((set, get) => ({
trainings: [],
certifications: [],
selectedTraining: null,
isLoading: false,
error: null,
fetchTrainings: async (filters = {}) => {
set({ isLoading: true, error: null });
try {
const queryParams = new URLSearchParams(filters);
const response = await fetch(`/api/safety/trainings?${queryParams}`);
const data = await response.json();
set({ trainings: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
getTrainingById: async (id) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/trainings/${id}`);
const data = await response.json();
set({ selectedTraining: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
createTraining: async (data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/safety/trainings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const newTraining = await response.json();
set(state => ({
trainings: [...state.trainings, newTraining],
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
updateTraining: async (id, data) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/safety/trainings/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const updated = await response.json();
set(state => ({
trainings: state.trainings.map(t => t.id === id ? updated : t),
isLoading: false
}));
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
cancelTraining: async (id, reason) => {
set({ isLoading: true, error: null });
try {
await fetch(`/api/safety/trainings/${id}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
await get().getTrainingById(id);
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
enrollEmployee: async (trainingId, employeeId) => {
try {
await fetch(`/api/safety/trainings/${trainingId}/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employeeId })
});
await get().getTrainingById(trainingId);
} catch (error) {
console.error('Error enrolling employee:', error);
}
},
unenrollEmployee: async (trainingId, employeeId) => {
try {
await fetch(`/api/safety/trainings/${trainingId}/unenroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employeeId })
});
await get().getTrainingById(trainingId);
} catch (error) {
console.error('Error unenrolling employee:', error);
}
},
recordAttendance: async (trainingId, attendees) => {
try {
await fetch(`/api/safety/trainings/${trainingId}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attendees })
});
await get().getTrainingById(trainingId);
} catch (error) {
console.error('Error recording attendance:', error);
}
},
issueCertification: async (trainingId, employeeId, score) => {
try {
const response = await fetch(`/api/safety/trainings/${trainingId}/certify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employeeId, score })
});
const certification = await response.json();
set(state => ({
certifications: [...state.certifications, certification]
}));
} catch (error) {
console.error('Error issuing certification:', error);
}
},
fetchCertifications: async (employeeId) => {
set({ isLoading: true, error: null });
try {
const url = employeeId
? `/api/safety/certifications?employeeId=${employeeId}`
: '/api/safety/certifications';
const response = await fetch(url);
const data = await response.json();
set({ certifications: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
getExpiringCertifications: (days) => {
const threshold = new Date();
threshold.setDate(threshold.getDate() + days);
return get().certifications.filter(cert => {
const expirationDate = new Date(cert.expirationDate);
return expirationDate <= threshold && expirationDate > new Date();
});
},
setSelectedTraining: (training) => set({ selectedTraining: training })
}))
);
5. Safety Dashboard
5.1 SafetyDashboard Component
// apps/verticales/construccion/frontend/src/modules/safety/dashboard/components/SafetyDashboard.tsx
import React, { useEffect } from 'react';
import { IncidentRateKPI } from './IncidentRateKPI';
import { ComplianceKPI } from './ComplianceKPI';
import { EPPComplianceChart } from './EPPComplianceChart';
import { TrainingCompletionChart } from './TrainingCompletionChart';
import { IncidentTrendChart } from './IncidentTrendChart';
import { SafetyAlerts } from './SafetyAlerts';
import { useSafetyMetrics } from '../hooks/useSafetyMetrics';
interface SafetyDashboardProps {
projectId?: string;
dateRange?: { start: Date; end: Date };
}
export const SafetyDashboard: React.FC<SafetyDashboardProps> = ({
projectId,
dateRange
}) => {
const { metrics, fetchMetrics, isLoading } = useSafetyMetrics();
useEffect(() => {
fetchMetrics({ projectId, dateRange });
}, [projectId, dateRange]);
if (isLoading) {
return <div className="dashboard-loading">Cargando métricas de seguridad...</div>;
}
return (
<div className="safety-dashboard">
<div className="dashboard-header">
<h1>Dashboard de Seguridad Industrial</h1>
<div className="dashboard-filters">
{/* Filters for project and date range */}
</div>
</div>
<div className="kpi-grid">
<IncidentRateKPI
rate={metrics?.incidentRate || 0}
trend={metrics?.incidentTrend || 0}
previousPeriod={metrics?.previousIncidentRate || 0}
/>
<ComplianceKPI
percentage={metrics?.compliancePercentage || 0}
trend={metrics?.complianceTrend || 0}
inspections={metrics?.totalInspections || 0}
/>
<div className="kpi-card">
<h3>EPP Asignados</h3>
<div className="kpi-value">{metrics?.eppAssigned || 0}</div>
<div className="kpi-subtitle">
{metrics?.eppExpiringSoon || 0} por vencer
</div>
</div>
<div className="kpi-card">
<h3>Capacitaciones</h3>
<div className="kpi-value">{metrics?.trainingsCompleted || 0}</div>
<div className="kpi-subtitle">
{metrics?.trainingsPending || 0} pendientes
</div>
</div>
</div>
<div className="dashboard-charts">
<div className="chart-row">
<div className="chart-container">
<IncidentTrendChart
data={metrics?.incidentTrendData || []}
/>
</div>
<div className="chart-container">
<EPPComplianceChart
data={metrics?.eppComplianceData || []}
/>
</div>
</div>
<div className="chart-row">
<div className="chart-container">
<TrainingCompletionChart
data={metrics?.trainingCompletionData || []}
/>
</div>
<div className="chart-container">
<SafetyAlerts
alerts={metrics?.alerts || []}
/>
</div>
</div>
</div>
</div>
);
};
5.2 IncidentRateKPI Component
// apps/verticales/construccion/frontend/src/modules/safety/dashboard/components/IncidentRateKPI.tsx
import React from 'react';
import { TrendingDown, TrendingUp, Minus } from 'lucide-react';
interface IncidentRateKPIProps {
rate: number;
trend: number;
previousPeriod: number;
}
export const IncidentRateKPI: React.FC<IncidentRateKPIProps> = ({
rate,
trend,
previousPeriod
}) => {
const getTrendIcon = () => {
if (trend < 0) return <TrendingDown className="text-green-500" />;
if (trend > 0) return <TrendingUp className="text-red-500" />;
return <Minus className="text-gray-500" />;
};
const getTrendColor = () => {
if (trend < 0) return 'text-green-500';
if (trend > 0) return 'text-red-500';
return 'text-gray-500';
};
return (
<div className="kpi-card incident-rate">
<div className="kpi-header">
<h3>Tasa de Incidentes</h3>
<div className={`kpi-trend ${getTrendColor()}`}>
{getTrendIcon()}
<span>{Math.abs(trend).toFixed(1)}%</span>
</div>
</div>
<div className="kpi-value">
{rate.toFixed(2)}
</div>
<div className="kpi-subtitle">
por cada 100 trabajadores
</div>
<div className="kpi-comparison">
Período anterior: {previousPeriod.toFixed(2)}
</div>
</div>
);
};
5.3 ComplianceKPI Component
// apps/verticales/construccion/frontend/src/modules/safety/dashboard/components/ComplianceKPI.tsx
import React from 'react';
import { CheckCircle } from 'lucide-react';
interface ComplianceKPIProps {
percentage: number;
trend: number;
inspections: number;
}
export const ComplianceKPI: React.FC<ComplianceKPIProps> = ({
percentage,
trend,
inspections
}) => {
const getComplianceLevel = () => {
if (percentage >= 90) return { level: 'Excelente', color: 'text-green-500' };
if (percentage >= 75) return { level: 'Bueno', color: 'text-blue-500' };
if (percentage >= 60) return { level: 'Aceptable', color: 'text-yellow-500' };
return { level: 'Crítico', color: 'text-red-500' };
};
const complianceLevel = getComplianceLevel();
return (
<div className="kpi-card compliance">
<div className="kpi-header">
<h3>Cumplimiento de Seguridad</h3>
<CheckCircle className={complianceLevel.color} size={24} />
</div>
<div className={`kpi-value ${complianceLevel.color}`}>
{percentage.toFixed(1)}%
</div>
<div className="kpi-subtitle">
{complianceLevel.level}
</div>
<div className="kpi-comparison">
{inspections} inspecciones realizadas
</div>
<div className="compliance-bar">
<div
className="compliance-fill"
style={{
width: `${percentage}%`,
backgroundColor: complianceLevel.color.replace('text-', '')
}}
/>
</div>
</div>
);
};
5.4 Dashboard Store
// apps/verticales/construccion/frontend/src/modules/safety/dashboard/stores/dashboardStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface SafetyMetrics {
incidentRate: number;
incidentTrend: number;
previousIncidentRate: number;
compliancePercentage: number;
complianceTrend: number;
totalInspections: number;
eppAssigned: number;
eppExpiringSoon: number;
trainingsCompleted: number;
trainingsPending: number;
incidentTrendData: Array<{ date: string; count: number }>;
eppComplianceData: Array<{ category: string; compliance: number }>;
trainingCompletionData: Array<{ month: string; completed: number; total: number }>;
alerts: Array<{
id: string;
type: 'critical' | 'warning' | 'info';
message: string;
timestamp: string;
}>;
}
interface DashboardStore {
metrics: SafetyMetrics | null;
filters: {
projectId?: string;
dateRange?: { start: Date; end: Date };
};
isLoading: boolean;
error: string | null;
fetchMetrics: (filters?: any) => Promise<void>;
setFilters: (filters: any) => void;
refreshMetrics: () => Promise<void>;
}
export const useDashboardStore = create<DashboardStore>()(
devtools((set, get) => ({
metrics: null,
filters: {},
isLoading: false,
error: null,
fetchMetrics: async (filters = {}) => {
set({ isLoading: true, error: null, filters });
try {
const queryParams = new URLSearchParams({
projectId: filters.projectId || '',
startDate: filters.dateRange?.start?.toISOString() || '',
endDate: filters.dateRange?.end?.toISOString() || ''
});
const response = await fetch(`/api/safety/dashboard/metrics?${queryParams}`);
const data = await response.json();
set({ metrics: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
setFilters: (filters) => {
set({ filters });
get().fetchMetrics(filters);
},
refreshMetrics: async () => {
const currentFilters = get().filters;
await get().fetchMetrics(currentFilters);
}
}))
);
6. Routing
// apps/verticales/construccion/frontend/src/modules/safety/routes/safetyRoutes.tsx
import { Routes, Route } from 'react-router-dom';
import { SafetyDashboard } from '../dashboard/components/SafetyDashboard';
import { EPPAssignmentForm } from '../features/epp/components/EPPAssignmentForm';
import { EPPInventoryList } from '../features/epp/components/EPPInventoryList';
import { SafetyChecklist } from '../features/safety-inspections/components/SafetyChecklist';
import { InspectionHistory } from '../features/safety-inspections/components/InspectionHistory';
import { IncidentReportForm } from '../features/incidents/components/IncidentReportForm';
import { IncidentDetails } from '../features/incidents/components/IncidentDetails';
import { TrainingCalendar } from '../features/trainings/components/TrainingCalendar';
import { TrainingMatrix } from '../features/trainings/components/TrainingMatrix';
export const SafetyRoutes = () => {
return (
<Routes>
<Route path="/" element={<SafetyDashboard />} />
{/* EPP Routes */}
<Route path="/epp">
<Route index element={<EPPInventoryList />} />
<Route path="assign" element={<EPPAssignmentForm />} />
</Route>
{/* Inspection Routes */}
<Route path="/inspections">
<Route index element={<InspectionHistory />} />
<Route path="new" element={<SafetyChecklist />} />
</Route>
{/* Incident Routes */}
<Route path="/incidents">
<Route index element={<div>Incident List</div>} />
<Route path="new" element={<IncidentReportForm />} />
<Route path=":id" element={<IncidentDetails />} />
</Route>
{/* Training Routes */}
<Route path="/trainings">
<Route index element={<TrainingCalendar />} />
<Route path="matrix" element={<TrainingMatrix />} />
</Route>
</Routes>
);
};
7. Types & Interfaces
// apps/verticales/construccion/frontend/src/modules/safety/shared/types/index.ts
export interface Employee {
id: string;
name: string;
role: string;
projectId: string;
}
export interface Project {
id: string;
name: string;
code: string;
}
export type IncidentType = 'accident' | 'near-miss' | 'property-damage' | 'environmental';
export type IncidentSeverity = 'minor' | 'moderate' | 'serious' | 'critical' | 'fatal';
export type IncidentStatus = 'reported' | 'investigating' | 'actions-pending' | 'closed';
export type InspectionStatus = 'draft' | 'completed' | 'requires-followup';
export type ChecklistItemStatus = 'compliant' | 'non-compliant' | 'not-applicable';
export type TrainingStatus = 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
export type EPPStatus = 'active' | 'returned' | 'expired';
8. Configuración de Vite
// apps/verticales/construccion/frontend/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@safety': path.resolve(__dirname, './src/modules/safety')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
});
9. Package.json
{
"name": "@erp-suite/construccion-safety-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"zustand": "^4.5.0",
"react-hook-form": "^7.51.0",
"@hookform/resolvers": "^3.3.4",
"zod": "^3.22.0",
"@fullcalendar/react": "^6.1.0",
"@fullcalendar/daygrid": "^6.1.0",
"@fullcalendar/timegrid": "^6.1.0",
"@fullcalendar/interaction": "^6.1.0",
"date-fns": "^3.3.0",
"recharts": "^2.12.0",
"lucide-react": "^0.344.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}
10. Consideraciones de Implementación
10.1 Estado Global vs Local
- Zustand (Global): Para datos compartidos entre múltiples componentes (inventario EPP, lista de incidentes, capacitaciones)
- useState (Local): Para estado UI específico de componentes (formularios, modales, filtros temporales)
10.2 Performance
- Implementar virtualización para listas largas (matriz de capacitaciones)
- Memoización de cálculos complejos (tasas de incidentes, cumplimiento)
- Lazy loading de imágenes en reportes de incidentes
- Code splitting por feature module
10.3 Validaciones
- Validación en tiempo real con react-hook-form + zod
- Validaciones de negocio en el frontend antes de enviar al backend
- Mensajes de error descriptivos y contextuales
10.4 Accesibilidad
- Uso de semantic HTML
- Labels apropiados en formularios
- Soporte de navegación por teclado
- Contraste de colores adecuado en KPIs
10.5 Testing (Futuro)
- Unit tests con Vitest
- Component tests con React Testing Library
- E2E tests con Playwright
11. Próximos Pasos
- Implementar autenticación y autorización
- Agregar soporte offline con PWA
- Implementar notificaciones push para alertas críticas
- Agregar exportación de reportes a PDF/Excel
- Implementar sistema de firma digital para documentos
- Agregar soporte multiidioma (i18n)