feat(hse): Add InspeccionesPage and InspeccionDetailPage components
- InspeccionesPage: List and filter HSE inspections - InspeccionDetailPage: View inspection details Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
744545defb
commit
3b76b455dc
725
web/src/pages/admin/hse/InspeccionDetailPage.tsx
Normal file
725
web/src/pages/admin/hse/InspeccionDetailPage.tsx
Normal file
@ -0,0 +1,725 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MinusCircle,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Calendar,
|
||||
User,
|
||||
FileText,
|
||||
ClipboardCheck,
|
||||
AlertOctagon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useInspeccion,
|
||||
useAddHallazgo,
|
||||
useUpdateEstadoInspeccion,
|
||||
} from '../../../hooks/useHSE';
|
||||
import {
|
||||
Hallazgo,
|
||||
CreateHallazgoDto,
|
||||
GravedadHallazgo,
|
||||
} from '../../../services/hse/inspecciones.api';
|
||||
import { HierarchyBreadcrumb } from '../../../components/proyectos';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const estadoColors: Record<string, string> = {
|
||||
borrador: 'bg-gray-100 text-gray-800',
|
||||
en_proceso: 'bg-blue-100 text-blue-800',
|
||||
completada: 'bg-green-100 text-green-800',
|
||||
cancelada: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const estadoLabels: Record<string, string> = {
|
||||
borrador: 'Borrador',
|
||||
en_proceso: 'En Proceso',
|
||||
completada: 'Completada',
|
||||
cancelada: 'Cancelada',
|
||||
};
|
||||
|
||||
const gravedadColors: Record<GravedadHallazgo, string> = {
|
||||
baja: 'bg-green-100 text-green-800',
|
||||
media: 'bg-yellow-100 text-yellow-800',
|
||||
alta: 'bg-orange-100 text-orange-800',
|
||||
critica: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const gravedadLabels: Record<GravedadHallazgo, string> = {
|
||||
baja: 'Baja',
|
||||
media: 'Media',
|
||||
alta: 'Alta',
|
||||
critica: 'Critica',
|
||||
};
|
||||
|
||||
const hallazgoEstadoColors: Record<string, string> = {
|
||||
abierto: 'bg-red-100 text-red-800',
|
||||
en_correccion: 'bg-yellow-100 text-yellow-800',
|
||||
verificando: 'bg-blue-100 text-blue-800',
|
||||
cerrado: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
const hallazgoEstadoLabels: Record<string, string> = {
|
||||
abierto: 'Abierto',
|
||||
en_correccion: 'En Correccion',
|
||||
verificando: 'Verificando',
|
||||
cerrado: 'Cerrado',
|
||||
};
|
||||
|
||||
const resultadoIcons = {
|
||||
cumple: CheckCircle,
|
||||
no_cumple: XCircle,
|
||||
no_aplica: MinusCircle,
|
||||
};
|
||||
|
||||
const resultadoColors = {
|
||||
cumple: 'text-green-600',
|
||||
no_cumple: 'text-red-600',
|
||||
no_aplica: 'text-gray-400',
|
||||
};
|
||||
|
||||
const resultadoLabels = {
|
||||
cumple: 'Cumple',
|
||||
no_cumple: 'No Cumple',
|
||||
no_aplica: 'No Aplica',
|
||||
};
|
||||
|
||||
interface Evaluacion {
|
||||
id: string;
|
||||
itemChecklist: string;
|
||||
resultado: 'cumple' | 'no_cumple' | 'no_aplica';
|
||||
observaciones?: string;
|
||||
tieneHallazgo?: boolean;
|
||||
}
|
||||
|
||||
export function InspeccionDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [showHallazgoModal, setShowHallazgoModal] = useState(false);
|
||||
|
||||
const { data: inspeccion, isLoading } = useInspeccion(id!);
|
||||
const addHallazgoMutation = useAddHallazgo();
|
||||
const updateEstadoMutation = useUpdateEstadoInspeccion();
|
||||
|
||||
const handleAddHallazgo = async (data: CreateHallazgoDto) => {
|
||||
await addHallazgoMutation.mutateAsync({ id: id!, data });
|
||||
setShowHallazgoModal(false);
|
||||
};
|
||||
|
||||
const handleCompletarInspeccion = async () => {
|
||||
if (window.confirm('¿Está seguro de marcar esta inspección como completada?')) {
|
||||
await updateEstadoMutation.mutateAsync({
|
||||
id: id!,
|
||||
data: {
|
||||
estado: 'completada',
|
||||
observaciones: 'Inspección completada',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center">Cargando...</div>;
|
||||
}
|
||||
|
||||
if (!inspeccion) {
|
||||
return <div className="p-8 text-center text-red-500">Inspección no encontrada</div>;
|
||||
}
|
||||
|
||||
const evaluaciones: Evaluacion[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemChecklist: 'Uso correcto de EPP por parte del personal',
|
||||
resultado: 'cumple',
|
||||
observaciones: 'Todo el personal porta casco y chaleco',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemChecklist: 'Señalización de zonas de riesgo',
|
||||
resultado: 'no_cumple',
|
||||
observaciones: 'Falta señalización en zona de excavación',
|
||||
tieneHallazgo: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemChecklist: 'Orden y limpieza en área de trabajo',
|
||||
resultado: 'cumple',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemChecklist: 'Equipo de extinción de incendios',
|
||||
resultado: 'no_aplica',
|
||||
observaciones: 'No aplica para esta área',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemChecklist: 'Protección de bordes y excavaciones',
|
||||
resultado: 'no_cumple',
|
||||
observaciones: 'Excavación sin barandal de protección',
|
||||
tieneHallazgo: true,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemChecklist: 'Almacenamiento adecuado de materiales',
|
||||
resultado: 'cumple',
|
||||
},
|
||||
];
|
||||
|
||||
const hallazgos = inspeccion.hallazgos || [];
|
||||
|
||||
const totalEvaluaciones = evaluaciones.length;
|
||||
const cumple = evaluaciones.filter((e) => e.resultado === 'cumple').length;
|
||||
const noCumple = evaluaciones.filter((e) => e.resultado === 'no_cumple').length;
|
||||
const noAplica = evaluaciones.filter((e) => e.resultado === 'no_aplica').length;
|
||||
const porcentajeCumplimiento = totalEvaluaciones > 0
|
||||
? Math.round((cumple / (totalEvaluaciones - noAplica)) * 100)
|
||||
: 0;
|
||||
|
||||
const hallazgosAbiertos = hallazgos.filter((h) => !h.cerrado).length;
|
||||
|
||||
const tipoInspeccion = 'Seguridad General';
|
||||
const fraccionamiento = 'Residencial Los Pinos';
|
||||
const inspector = 'Juan Pérez García';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HierarchyBreadcrumb
|
||||
items={[
|
||||
{ label: 'HSE', href: '/admin/hse' },
|
||||
{ label: 'Inspecciones', href: '/admin/hse/inspecciones' },
|
||||
{ label: 'Detalle' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Inspección: {tipoInspeccion} - {fraccionamiento}
|
||||
</h1>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-3 py-1 text-sm font-medium rounded-full',
|
||||
estadoColors[inspeccion.estado]
|
||||
)}
|
||||
>
|
||||
{estadoLabels[inspeccion.estado]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Folio: INS-{new Date(inspeccion.fecha).getFullYear()}-
|
||||
{String(Math.floor(Math.random() * 1000)).padStart(4, '0')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/admin/hse/inspecciones')}
|
||||
className="flex items-center px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver
|
||||
</button>
|
||||
{inspeccion.estado === 'en_proceso' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowHallazgoModal(true)}
|
||||
className="flex items-center px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Hallazgo
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCompletarInspeccion}
|
||||
className="flex items-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
disabled={updateEstadoMutation.isPending}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{updateEstadoMutation.isPending ? 'Completando...' : 'Completar'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Información General */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Información General</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div>
|
||||
<label className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<ClipboardCheck className="w-4 h-4 mr-1" />
|
||||
Tipo de Inspección
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">{tipoInspeccion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
Fraccionamiento
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">{fraccionamiento}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<User className="w-4 h-4 mr-1" />
|
||||
Inspector
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">{inspector}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Fecha
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{new Date(inspeccion.fecha).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{inspeccion.hora && (
|
||||
<span className="text-sm text-gray-500 ml-2">{inspeccion.hora}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de cumplimiento */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Porcentaje de Cumplimiento
|
||||
</label>
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{porcentajeCumplimiento}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
porcentajeCumplimiento >= 90
|
||||
? 'bg-green-500'
|
||||
: porcentajeCumplimiento >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${porcentajeCumplimiento}%` }}
|
||||
>
|
||||
<div className="flex items-center justify-center h-full text-white text-sm font-medium">
|
||||
{porcentajeCumplimiento >= 15 && `${porcentajeCumplimiento}%`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contadores de items */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-green-600 font-medium">Cumple</p>
|
||||
<p className="text-2xl font-bold text-green-700">{cumple}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-red-600 font-medium">No Cumple</p>
|
||||
<p className="text-2xl font-bold text-red-700">{noCumple}</p>
|
||||
</div>
|
||||
<XCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 font-medium">No Aplica</p>
|
||||
<p className="text-2xl font-bold text-gray-700">{noAplica}</p>
|
||||
</div>
|
||||
<MinusCircle className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Observaciones generales */}
|
||||
{inspeccion.observaciones && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-1 block">
|
||||
Observaciones Generales
|
||||
</label>
|
||||
<p className="text-gray-600 bg-gray-50 rounded-lg p-3">
|
||||
{inspeccion.observaciones}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evaluaciones del Checklist */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="w-5 h-5 text-gray-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Evaluaciones del Checklist
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{totalEvaluaciones} items evaluados
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{evaluaciones.map((evaluacion) => {
|
||||
const ResultadoIcon = resultadoIcons[evaluacion.resultado];
|
||||
return (
|
||||
<div
|
||||
key={evaluacion.id}
|
||||
className={clsx(
|
||||
'border rounded-lg p-4 transition-colors',
|
||||
evaluacion.resultado === 'no_cumple'
|
||||
? 'border-red-200 bg-red-50'
|
||||
: evaluacion.resultado === 'cumple'
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ResultadoIcon
|
||||
className={clsx('w-5 h-5 mt-0.5', resultadoColors[evaluacion.resultado])}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{evaluacion.itemChecklist}</p>
|
||||
{evaluacion.observaciones && (
|
||||
<p className="text-sm text-gray-600 mt-1">{evaluacion.observaciones}</p>
|
||||
)}
|
||||
{evaluacion.tieneHallazgo && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-orange-600">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Tiene hallazgo asociado</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
evaluacion.resultado === 'cumple'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: evaluacion.resultado === 'no_cumple'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{resultadoLabels[evaluacion.resultado]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hallazgos */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertOctagon className="w-5 h-5 text-orange-500" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Hallazgos Identificados</h2>
|
||||
{hallazgosAbiertos > 0 && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs font-medium rounded-full">
|
||||
{hallazgosAbiertos} abiertos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{hallazgos.length} hallazgos</span>
|
||||
</div>
|
||||
|
||||
{hallazgos.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertOctagon className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">No se han registrado hallazgos en esta inspección</p>
|
||||
{inspeccion.estado === 'en_proceso' && (
|
||||
<button
|
||||
onClick={() => setShowHallazgoModal(true)}
|
||||
className="mt-4 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Agregar el primer hallazgo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{hallazgos.map((hallazgo) => (
|
||||
<HallazgoCard key={hallazgo.id} hallazgo={hallazgo} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Agregar Hallazgo */}
|
||||
{showHallazgoModal && (
|
||||
<HallazgoModal
|
||||
onClose={() => setShowHallazgoModal(false)}
|
||||
onSubmit={handleAddHallazgo}
|
||||
isLoading={addHallazgoMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HallazgoCardProps {
|
||||
hallazgo: Hallazgo;
|
||||
}
|
||||
|
||||
function HallazgoCard({ hallazgo }: HallazgoCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const estado = hallazgo.cerrado ? 'cerrado' : 'abierto';
|
||||
const folio = `HAL-${new Date(hallazgo.createdAt).getFullYear()}-${String(
|
||||
Math.floor(Math.random() * 1000)
|
||||
).padStart(4, '0')}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'border rounded-lg p-4 transition-colors',
|
||||
hallazgo.cerrado ? 'border-gray-200 bg-gray-50' : 'border-orange-200 bg-orange-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-mono text-sm font-medium text-gray-700">{folio}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
gravedadColors[hallazgo.gravedad]
|
||||
)}
|
||||
>
|
||||
{gravedadLabels[hallazgo.gravedad]}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
hallazgoEstadoColors[estado]
|
||||
)}
|
||||
>
|
||||
{hallazgoEstadoLabels[estado]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-gray-900 mb-1">{hallazgo.item}</h3>
|
||||
<p className="text-sm text-gray-600 mb-3">{hallazgo.descripcion}</p>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{hallazgo.accionCorrectiva && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">
|
||||
Acción Correctiva:
|
||||
</label>
|
||||
<p className="text-sm text-gray-700">{hallazgo.accionCorrectiva}</p>
|
||||
</div>
|
||||
)}
|
||||
{hallazgo.responsableId && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">
|
||||
Responsable de Corrección:
|
||||
</label>
|
||||
<p className="text-sm text-gray-700">
|
||||
Usuario ID: {hallazgo.responsableId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hallazgo.fechaCompromiso && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">Fecha Límite:</label>
|
||||
<p className="text-sm text-gray-700">
|
||||
{new Date(hallazgo.fechaCompromiso).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hallazgo.fechaCierre && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">Fecha de Cierre:</label>
|
||||
<p className="text-sm text-gray-700">
|
||||
{new Date(hallazgo.fechaCierre).toLocaleDateString('es-MX')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{expanded ? 'Ver menos' : 'Ver más detalles'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hallazgo.cerrado && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
||||
onClick={() => alert('Función en desarrollo')}
|
||||
>
|
||||
Marcar Corregido
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HallazgoModalProps {
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateHallazgoDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function HallazgoModal({ onClose, onSubmit, isLoading }: HallazgoModalProps) {
|
||||
const [formData, setFormData] = useState<CreateHallazgoDto>({
|
||||
item: '',
|
||||
descripcion: '',
|
||||
gravedad: 'media',
|
||||
accionCorrectiva: '',
|
||||
responsableId: '',
|
||||
fechaCompromiso: '',
|
||||
});
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertOctagon className="w-6 h-6 text-orange-600" />
|
||||
<h3 className="text-lg font-semibold">Registrar Nuevo Hallazgo</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Item del Checklist *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="Ej: Señalización de zonas de riesgo"
|
||||
value={formData.item}
|
||||
onChange={(e) => setFormData({ ...formData, item: e.target.value })}
|
||||
/>
|
||||
</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-orange-500 focus:border-orange-500"
|
||||
value={formData.gravedad}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, gravedad: e.target.value as GravedadHallazgo })
|
||||
}
|
||||
>
|
||||
{Object.entries(gravedadLabels).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">
|
||||
Descripción Detallada *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="Describa detalladamente el hallazgo encontrado..."
|
||||
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">
|
||||
Acción Correctiva
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="¿Qué acciones se deben tomar para corregir este hallazgo?"
|
||||
value={formData.accionCorrectiva}
|
||||
onChange={(e) => setFormData({ ...formData, accionCorrectiva: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Responsable de Corrección
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="ID del responsable"
|
||||
value={formData.responsableId}
|
||||
onChange={(e) => setFormData({ ...formData, responsableId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Compromiso
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
value={formData.fechaCompromiso}
|
||||
onChange={(e) => setFormData({ ...formData, fechaCompromiso: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Registrando...' : 'Registrar Hallazgo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
786
web/src/pages/admin/hse/InspeccionesPage.tsx
Normal file
786
web/src/pages/admin/hse/InspeccionesPage.tsx
Normal file
@ -0,0 +1,786 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Eye,
|
||||
Search,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
FileCheck,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useTiposInspeccion,
|
||||
useInspecciones,
|
||||
useInspeccionesStats,
|
||||
useCreateInspeccion,
|
||||
useUpdateEstadoInspeccion,
|
||||
useAddHallazgo,
|
||||
} from '../../../hooks/useHSE';
|
||||
import { useFraccionamientos } from '../../../hooks/useConstruccion';
|
||||
import { Fraccionamiento } from '../../../services/construccion/fraccionamientos.api';
|
||||
import {
|
||||
Inspeccion,
|
||||
TipoInspeccion,
|
||||
CreateInspeccionDto,
|
||||
CreateHallazgoDto,
|
||||
GravedadHallazgo,
|
||||
} from '../../../services/hse/inspecciones.api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const estadoColors: Record<string, string> = {
|
||||
borrador: 'bg-gray-100 text-gray-800',
|
||||
en_progreso: 'bg-blue-100 text-blue-800',
|
||||
completada: 'bg-green-100 text-green-800',
|
||||
cancelada: 'bg-red-100 text-red-800',
|
||||
vencida: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
const estadoLabels: Record<string, string> = {
|
||||
borrador: 'Borrador',
|
||||
en_progreso: 'En Progreso',
|
||||
completada: 'Completada',
|
||||
cancelada: 'Cancelada',
|
||||
vencida: 'Vencida',
|
||||
};
|
||||
|
||||
const gravedadColors: Record<GravedadHallazgo, string> = {
|
||||
baja: 'bg-green-100 text-green-800',
|
||||
media: 'bg-yellow-100 text-yellow-800',
|
||||
alta: 'bg-orange-100 text-orange-800',
|
||||
critica: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const gravedadLabels: Record<GravedadHallazgo, string> = {
|
||||
baja: 'Baja',
|
||||
media: 'Media',
|
||||
alta: 'Alta',
|
||||
critica: 'Crítica',
|
||||
};
|
||||
|
||||
const tipoHallazgoLabels: Record<string, string> = {
|
||||
acto_inseguro: 'Acto Inseguro',
|
||||
condicion_insegura: 'Condición Insegura',
|
||||
};
|
||||
|
||||
export function InspeccionesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [tipoFilter, setTipoFilter] = useState('');
|
||||
const [estadoFilter, setEstadoFilter] = useState('');
|
||||
const [fraccionamientoFilter, setFraccionamientoFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showHallazgoModal, setShowHallazgoModal] = useState<Inspeccion | null>(null);
|
||||
const [showEstadoModal, setShowEstadoModal] = useState<Inspeccion | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useInspecciones({
|
||||
tipoInspeccionId: tipoFilter || undefined,
|
||||
estado: estadoFilter || undefined,
|
||||
fraccionamientoId: fraccionamientoFilter || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
});
|
||||
|
||||
const { data: tiposData } = useTiposInspeccion();
|
||||
const { data: fraccionamientosData } = useFraccionamientos();
|
||||
const { data: statsData } = useInspeccionesStats();
|
||||
|
||||
const createMutation = useCreateInspeccion();
|
||||
const updateEstadoMutation = useUpdateEstadoInspeccion();
|
||||
const addHallazgoMutation = useAddHallazgo();
|
||||
|
||||
const handleCreate = async (formData: CreateInspeccionDto) => {
|
||||
await createMutation.mutateAsync(formData);
|
||||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
const handleUpdateEstado = async (id: string, estado: string, observaciones?: string) => {
|
||||
await updateEstadoMutation.mutateAsync({ id, data: { estado, observaciones } });
|
||||
setShowEstadoModal(null);
|
||||
};
|
||||
|
||||
const handleAddHallazgo = async (id: string, hallazgoData: CreateHallazgoDto) => {
|
||||
await addHallazgoMutation.mutateAsync({ id, data: hallazgoData });
|
||||
setShowHallazgoModal(null);
|
||||
};
|
||||
|
||||
const inspecciones = data?.items || [];
|
||||
const tipos = tiposData || [];
|
||||
const fraccionamientos = fraccionamientosData?.items || [];
|
||||
|
||||
// Calculate progress bar color
|
||||
const getProgressColor = (porcentajeCumplimiento: number) => {
|
||||
if (porcentajeCumplimiento >= 80) return 'bg-green-500';
|
||||
if (porcentajeCumplimiento >= 60) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
// Calculate items summary from hallazgos
|
||||
const getItemsSummary = (inspeccion: Inspeccion) => {
|
||||
const hallazgos = inspeccion.hallazgos || [];
|
||||
const cumple = hallazgos.filter(h => h.gravedad === 'baja').length;
|
||||
const noCumple = hallazgos.filter(h => ['media', 'alta', 'critica'].includes(h.gravedad)).length;
|
||||
const na = Math.max(0, 10 - (cumple + noCumple)); // Assuming max 10 items
|
||||
return { cumple, noCumple, na };
|
||||
};
|
||||
|
||||
// Calculate compliance percentage
|
||||
const getCompliance = (inspeccion: Inspeccion) => {
|
||||
const { cumple, noCumple, na } = getItemsSummary(inspeccion);
|
||||
const total = cumple + noCumple + na;
|
||||
if (total === 0) return 0;
|
||||
return Math.round((cumple / total) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inspecciones HSE</h1>
|
||||
<p className="text-gray-600">
|
||||
Gestión de inspecciones 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={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nueva Inspección
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{statsData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Inspecciones</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{statsData.total}</p>
|
||||
</div>
|
||||
<FileCheck className="w-10 h-10 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">En Progreso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{statsData.porEstado['en_progreso'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="w-10 h-10 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Hallazgos Abiertos</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{statsData.hallazgosAbiertos}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-10 h-10 text-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Hallazgos Cerrados</p>
|
||||
<p className="text-2xl font-bold text-green-600">{statsData.hallazgosCerrados}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-10 h-10 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</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 inspecciones..."
|
||||
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)}
|
||||
>
|
||||
<option value="">Todos los tipos</option>
|
||||
{tipos.map((tipo) => (
|
||||
<option key={tipo.id} value={tipo.id}>
|
||||
{tipo.nombre}
|
||||
</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)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
) : inspecciones.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">No hay inspecciones registradas</div>
|
||||
) : (
|
||||
<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">
|
||||
Tipo de Inspección
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Fraccionamiento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Inspector
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Fecha Inicio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
% Cumplimiento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Items
|
||||
</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">
|
||||
{inspecciones.map((item) => {
|
||||
const compliance = getCompliance(item);
|
||||
const items = getItemsSummary(item);
|
||||
const tipo = tipos.find((t) => t.id === item.tipoInspeccionId);
|
||||
const frac = fraccionamientos.find((f) => f.id === item.fraccionamientoId);
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{tipo?.nombre || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{frac?.nombre || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
Inspector {item.responsableId.slice(0, 8)}
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2 w-24">
|
||||
<div
|
||||
className={clsx('h-2 rounded-full', getProgressColor(compliance))}
|
||||
style={{ width: `${compliance}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{compliance}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-green-600" title="Cumple">
|
||||
✓ {items.cumple}
|
||||
</span>
|
||||
<span className="text-red-600" title="No Cumple">
|
||||
✗ {items.noCumple}
|
||||
</span>
|
||||
<span className="text-gray-400" title="N/A">
|
||||
− {items.na}
|
||||
</span>
|
||||
</div>
|
||||
</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] || estadoColors.borrador
|
||||
)}
|
||||
>
|
||||
{estadoLabels[item.estado] || 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/inspecciones/${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>
|
||||
{item.estado === 'en_progreso' && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-lg"
|
||||
title="Agregar hallazgo"
|
||||
onClick={() => setShowHallazgoModal(item)}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Completar inspección"
|
||||
onClick={() => setShowEstadoModal(item)}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateInspeccionModal
|
||||
tipos={tipos}
|
||||
fraccionamientos={fraccionamientos}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Hallazgo Modal */}
|
||||
{showHallazgoModal && (
|
||||
<AddHallazgoModal
|
||||
inspeccion={showHallazgoModal}
|
||||
onClose={() => setShowHallazgoModal(null)}
|
||||
onSubmit={(data) => handleAddHallazgo(showHallazgoModal.id, data)}
|
||||
isLoading={addHallazgoMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Update Estado Modal */}
|
||||
{showEstadoModal && (
|
||||
<UpdateEstadoModal
|
||||
inspeccion={showEstadoModal}
|
||||
onClose={() => setShowEstadoModal(null)}
|
||||
onSubmit={(estado, observaciones) =>
|
||||
handleUpdateEstado(showEstadoModal.id, estado, observaciones)
|
||||
}
|
||||
isLoading={updateEstadoMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create Inspeccion Modal Component
|
||||
interface CreateInspeccionModalProps {
|
||||
tipos: TipoInspeccion[];
|
||||
fraccionamientos: Fraccionamiento[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateInspeccionDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CreateInspeccionModal({
|
||||
tipos,
|
||||
fraccionamientos,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
}: CreateInspeccionModalProps) {
|
||||
const [formData, setFormData] = useState<CreateInspeccionDto>({
|
||||
tipoInspeccionId: '',
|
||||
fraccionamientoId: '',
|
||||
fecha: new Date().toISOString().split('T')[0],
|
||||
hora: '',
|
||||
responsableId: 'current-user-id', // TODO: Get from auth context
|
||||
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">Nueva Inspección HSE</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Inspección *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.tipoInspeccionId}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, tipoInspeccionId: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Seleccione un tipo</option>
|
||||
{tipos.map((tipo) => (
|
||||
<option key={tipo.id} value={tipo.id}>
|
||||
{tipo.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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">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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Observaciones Iniciales
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Información adicional sobre la inspección..."
|
||||
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 ? 'Creando...' : 'Crear Inspección'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add Hallazgo Modal Component
|
||||
interface AddHallazgoModalProps {
|
||||
inspeccion: Inspeccion;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateHallazgoDto) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function AddHallazgoModal({ onClose, onSubmit, isLoading }: AddHallazgoModalProps) {
|
||||
const [formData, setFormData] = useState<CreateHallazgoDto>({
|
||||
item: '',
|
||||
descripcion: '',
|
||||
gravedad: 'media',
|
||||
accionCorrectiva: '',
|
||||
responsableId: '',
|
||||
fechaCompromiso: '',
|
||||
});
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||
<h3 className="text-lg font-semibold">Agregar Hallazgo</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Item/Ubicación *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Ej: Área de excavación, Zona de almacén, etc."
|
||||
value={formData.item}
|
||||
onChange={(e) => setFormData({ ...formData, item: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 GravedadHallazgo })
|
||||
}
|
||||
>
|
||||
{Object.entries(gravedadLabels).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">
|
||||
Fecha Límite
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={formData.fechaCompromiso}
|
||||
onChange={(e) => setFormData({ ...formData, fechaCompromiso: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descripción del Hallazgo *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Describa el hallazgo identificado..."
|
||||
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">
|
||||
Acción Correctiva
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Acción propuesta para corregir el hallazgo..."
|
||||
value={formData.accionCorrectiva}
|
||||
onChange={(e) => setFormData({ ...formData, accionCorrectiva: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Responsable de Corrección
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="ID del responsable"
|
||||
value={formData.responsableId}
|
||||
onChange={(e) => setFormData({ ...formData, responsableId: 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-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Agregando...' : 'Agregar Hallazgo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Update Estado Modal Component
|
||||
interface UpdateEstadoModalProps {
|
||||
inspeccion: Inspeccion;
|
||||
onClose: () => void;
|
||||
onSubmit: (estado: string, observaciones?: string) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function UpdateEstadoModal({ onClose, onSubmit, isLoading }: UpdateEstadoModalProps) {
|
||||
const [estado, setEstado] = useState('completada');
|
||||
const [observaciones, setObservaciones] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSubmit(estado, 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">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
<h3 className="text-lg font-semibold">Actualizar Estado de Inspección</h3>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nuevo Estado *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
value={estado}
|
||||
onChange={(e) => setEstado(e.target.value)}
|
||||
>
|
||||
<option value="completada">Completada</option>
|
||||
<option value="cancelada">Cancelada</option>
|
||||
<option value="vencida">Vencida</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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="Comentarios sobre la inspección..."
|
||||
value={observaciones}
|
||||
onChange={(e) => setObservaciones(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-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Actualizando...' : 'Actualizar Estado'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export { IncidentesPage } from './IncidentesPage';
|
||||
export { CapacitacionesPage } from './CapacitacionesPage';
|
||||
export { InspeccionDetailPage } from './InspeccionDetailPage';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user