1975 lines
59 KiB
Markdown
1975 lines
59 KiB
Markdown
# 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
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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
|
|
|
|
1. Implementar autenticación y autorización
|
|
2. Agregar soporte offline con PWA
|
|
3. Implementar notificaciones push para alertas críticas
|
|
4. Agregar exportación de reportes a PDF/Excel
|
|
5. Implementar sistema de firma digital para documentos
|
|
6. Agregar soporte multiidioma (i18n)
|