feat(hse): Add IncidentesPage component for HSE incidents management

- Complete CRUD for HSE incidents (accidente, incidente, casi_accidente)
- Filters by tipo, gravedad, estado, fraccionamiento, date range
- Investigation workflow (abierto -> en_investigacion -> cerrado)
- Modal forms for create/edit, investigate, and close
- Color-coded badges for tipo, gravedad, and estado

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-02 22:20:21 -06:00
parent dac9ae6f19
commit 744545defb

View File

@ -1,45 +1,739 @@
/**
* IncidentesPage Component
* Gestion de incidentes HSE
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Pencil,
Eye,
Search,
FileText,
XCircle,
AlertTriangle,
Activity,
} from 'lucide-react';
import {
useIncidentes,
useCreateIncidente,
useUpdateIncidente,
useInvestigateIncidente,
useCloseIncidente,
} from '../../../hooks/useHSE';
import { useFraccionamientos } from '../../../hooks/useConstruccion';
import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api';
import {
Incidente,
TipoIncidente,
GravedadIncidente,
EstadoIncidente,
CreateIncidenteDto,
} from '../../../services/hse/incidentes.api';
import clsx from 'clsx';
import { AlertTriangle } from 'lucide-react';
const tipoColors: Record<TipoIncidente, string> = {
accidente: 'bg-red-100 text-red-800',
incidente: 'bg-yellow-100 text-yellow-800',
casi_accidente: 'bg-blue-100 text-blue-800',
};
const tipoLabels: Record<TipoIncidente, string> = {
accidente: 'Accidente',
incidente: 'Incidente',
casi_accidente: 'Casi Accidente',
};
const gravedadColors: Record<GravedadIncidente, string> = {
leve: 'bg-green-100 text-green-800',
moderado: 'bg-yellow-100 text-yellow-800',
grave: 'bg-orange-100 text-orange-800',
fatal: 'bg-red-100 text-red-800',
};
const gravedadLabels: Record<GravedadIncidente, string> = {
leve: 'Leve',
moderado: 'Moderado',
grave: 'Grave',
fatal: 'Fatal',
};
const estadoColors: Record<EstadoIncidente, string> = {
abierto: 'bg-yellow-100 text-yellow-800',
en_investigacion: 'bg-blue-100 text-blue-800',
cerrado: 'bg-green-100 text-green-800',
};
const estadoLabels: Record<EstadoIncidente, string> = {
abierto: 'Abierto',
en_investigacion: 'En Investigación',
cerrado: 'Cerrado',
};
export function IncidentesPage() {
const [search, setSearch] = useState('');
const [tipoFilter, setTipoFilter] = useState<TipoIncidente | ''>('');
const [gravedadFilter, setGravedadFilter] = useState<GravedadIncidente | ''>('');
const [estadoFilter, setEstadoFilter] = useState<EstadoIncidente | ''>('');
const [fraccionamientoFilter, setFraccionamientoFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Incidente | null>(null);
const [investigateModal, setInvestigateModal] = useState<Incidente | null>(null);
const [closeModal, setCloseModal] = useState<Incidente | null>(null);
const { data, isLoading, error } = useIncidentes({
search: search || undefined,
tipo: tipoFilter || undefined,
gravedad: gravedadFilter || undefined,
estado: estadoFilter || undefined,
fraccionamientoId: fraccionamientoFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
const { data: fraccionamientosData } = useFraccionamientos();
const createMutation = useCreateIncidente();
const updateMutation = useUpdateIncidente();
const investigateMutation = useInvestigateIncidente();
const closeMutation = useCloseIncidente();
const handleSubmit = async (formData: CreateIncidenteDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const handleInvestigate = async (id: string, investigadorId: string) => {
await investigateMutation.mutateAsync({
id,
investigadorId,
fechaInvestigacion: new Date().toISOString(),
});
setInvestigateModal(null);
};
const handleClose = async (id: string, observaciones?: string) => {
await closeMutation.mutateAsync({
id,
fechaCierre: new Date().toISOString(),
observaciones,
});
setCloseModal(null);
};
const incidentes = data?.items || [];
const fraccionamientos = fraccionamientosData?.items || [];
// Generate folio for display (assuming incremental numbering)
const generateFolio = (index: number) => {
const year = new Date().getFullYear();
return `INC-${year}-${String(index + 1).padStart(4, '0')}`;
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Incidentes HSE</h1>
<p className="text-gray-600">
Gestion de incidentes de seguridad, salud y medio ambiente
</p>
<p className="text-gray-600">Gestión de incidentes de seguridad, salud y medio ambiente</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => {
setEditingItem(null);
setShowModal(true);
}}
>
<Plus className="w-5 h-5 mr-2" />
Registrar Incidente
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por folio o descripción..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={fraccionamientoFilter}
onChange={(e) => setFraccionamientoFilter(e.target.value)}
>
<option value="">Todos los fraccionamientos</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={tipoFilter}
onChange={(e) => setTipoFilter(e.target.value as TipoIncidente | '')}
>
<option value="">Todos los tipos</option>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={gravedadFilter}
onChange={(e) => setGravedadFilter(e.target.value as GravedadIncidente | '')}
>
<option value="">Todas las gravedades</option>
{Object.entries(gravedadLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={estadoFilter}
onChange={(e) => setEstadoFilter(e.target.value as EstadoIncidente | '')}
>
<option value="">Todos los estados</option>
{Object.entries(estadoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
placeholder="Desde"
/>
<input
type="date"
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
placeholder="Hasta"
/>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-8">
<div className="flex flex-col items-center justify-center text-center py-12">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-yellow-600" />
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-gray-500">Cargando...</div>
) : error ? (
<div className="p-8 text-center text-red-500">Error al cargar los datos</div>
) : incidentes.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No hay incidentes registrados
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Modulo de Incidentes en Desarrollo
</h3>
<p className="text-gray-600 max-w-md">
Este modulo permitira registrar, clasificar y dar seguimiento a incidentes
de seguridad, salud ocupacional y medio ambiente.
</p>
<div className="mt-6 text-sm text-gray-500">
<p>Funcionalidades proximas:</p>
<ul className="mt-2 space-y-1">
<li>- Registro de incidentes y accidentes</li>
<li>- Clasificacion por severidad y tipo</li>
<li>- Seguimiento de investigaciones</li>
<li>- Planes de accion correctiva</li>
<li>- Reportes y estadisticas</li>
</ul>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Folio
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Fecha/Hora
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Gravedad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Descripción
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Estado
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Acciones
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{incidentes.map((item, index) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{generateFolio(index)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>
{new Date(item.fecha).toLocaleDateString()}
{item.hora && (
<div className="text-xs text-gray-500">{item.hora}</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
tipoColors[item.tipo]
)}
>
{tipoLabels[item.tipo]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
gravedadColors[item.gravedad]
)}
>
{gravedadLabels[item.gravedad]}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{item.descripcion}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
estadoColors[item.estado]
)}
>
{estadoLabels[item.estado]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Link
to={`/admin/hse/incidentes/${item.id}`}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Ver detalle"
>
<Eye className="w-4 h-4" />
</Link>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
{item.estado === 'abierto' && (
<button
className="p-2 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg"
title="Investigar"
onClick={() => setInvestigateModal(item)}
>
<FileText className="w-4 h-4" />
</button>
)}
{item.estado === 'en_investigacion' && (
<button
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
title="Cerrar"
onClick={() => setCloseModal(item)}
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Create/Edit Modal */}
{showModal && (
<IncidenteModal
item={editingItem}
fraccionamientos={fraccionamientos}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Investigate Modal */}
{investigateModal && (
<InvestigateModal
incidente={investigateModal}
onClose={() => setInvestigateModal(null)}
onSubmit={(investigadorId) => handleInvestigate(investigateModal.id, investigadorId)}
isLoading={investigateMutation.isPending}
/>
)}
{/* Close Modal */}
{closeModal && (
<CloseModal
incidente={closeModal}
onClose={() => setCloseModal(null)}
onSubmit={(observaciones) => handleClose(closeModal.id, observaciones)}
isLoading={closeMutation.isPending}
/>
)}
</div>
);
}
// Modal Component
interface IncidenteModalProps {
item: Incidente | null;
fraccionamientos: Fraccionamiento[];
onClose: () => void;
onSubmit: (data: CreateIncidenteDto) => Promise<void>;
isLoading: boolean;
}
function IncidenteModal({
item,
fraccionamientos,
onClose,
onSubmit,
isLoading,
}: IncidenteModalProps) {
const [formData, setFormData] = useState<CreateIncidenteDto>({
fraccionamientoId: item?.fraccionamientoId || '',
tipo: item?.tipo || 'incidente',
gravedad: item?.gravedad || 'leve',
fecha: item?.fecha?.split('T')[0] || new Date().toISOString().split('T')[0],
hora: item?.hora || '',
ubicacion: item?.ubicacion || '',
descripcion: item?.descripcion || '',
causas: item?.causas || '',
acciones: item?.acciones || '',
observaciones: item?.observaciones || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Incidente' : 'Registrar Nuevo Incidente'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha *
</label>
<input
type="date"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fecha}
onChange={(e) => setFormData({ ...formData, fecha: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hora</label>
<input
type="time"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.hora}
onChange={(e) => setFormData({ ...formData, hora: e.target.value })}
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fraccionamiento *
</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.fraccionamientoId}
onChange={(e) =>
setFormData({ ...formData, fraccionamientoId: e.target.value })
}
>
<option value="">Seleccione un fraccionamiento</option>
{fraccionamientos.map((frac) => (
<option key={frac.id} value={frac.id}>
{frac.nombre}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo *</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.tipo}
onChange={(e) =>
setFormData({ ...formData, tipo: e.target.value as TipoIncidente })
}
>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Gravedad *
</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.gravedad}
onChange={(e) =>
setFormData({ ...formData, gravedad: e.target.value as GravedadIncidente })
}
>
{Object.entries(gravedadLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ubicación (descripción)
</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ej: Zona de excavación, Área de cimbra, etc."
value={formData.ubicacion}
onChange={(e) => setFormData({ ...formData, ubicacion: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descripción Detallada *
</label>
<textarea
required
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Describa detalladamente lo sucedido..."
value={formData.descripcion}
onChange={(e) => setFormData({ ...formData, descripcion: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Causa Inmediata
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="¿Qué causó directamente el incidente?"
value={formData.causas}
onChange={(e) => setFormData({ ...formData, causas: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Acciones Inmediatas Tomadas
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="¿Qué acciones se tomaron de inmediato?"
value={formData.acciones}
onChange={(e) => setFormData({ ...formData, acciones: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Observaciones
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Información adicional..."
value={formData.observaciones}
onChange={(e) => setFormData({ ...formData, observaciones: e.target.value })}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Registrar'}
</button>
</div>
</form>
</div>
</div>
);
}
// Investigate Modal
interface InvestigateModalProps {
incidente: Incidente;
onClose: () => void;
onSubmit: (investigadorId: string) => Promise<void>;
isLoading: boolean;
}
function InvestigateModal({ onClose, onSubmit, isLoading }: InvestigateModalProps) {
const [investigadorId, setInvestigadorId] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(investigadorId);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-purple-600" />
<h3 className="text-lg font-semibold">Iniciar Investigación</h3>
</div>
<p className="text-gray-600 mb-4">
Asigne un investigador responsable para iniciar el proceso de investigación del
incidente.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Investigador Responsable *
</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="ID del investigador"
value={investigadorId}
onChange={(e) => setInvestigadorId(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Procesando...' : 'Iniciar Investigación'}
</button>
</div>
</form>
</div>
</div>
);
}
// Close Modal
interface CloseModalProps {
incidente: Incidente;
onClose: () => void;
onSubmit: (observaciones?: string) => Promise<void>;
isLoading: boolean;
}
function CloseModal({ onClose, onSubmit, isLoading }: CloseModalProps) {
const [observaciones, setObservaciones] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(observaciones || undefined);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<Activity className="w-6 h-6 text-green-600" />
<h3 className="text-lg font-semibold">Cerrar Incidente</h3>
</div>
<p className="text-gray-600 mb-4">
Complete la investigación y cierre el incidente. Agregue observaciones finales si es
necesario.
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Observaciones Finales
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Resumen de la investigación y conclusiones..."
value={observaciones}
onChange={(e) => setObservaciones(e.target.value)}
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Cerrando...' : 'Cerrar Incidente'}
</button>
</div>
</form>
</div>
</div>
);