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:
Adrian Flores Cortes 2026-02-02 22:22:24 -06:00
parent 744545defb
commit 3b76b455dc
3 changed files with 1512 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View File

@ -1,2 +1,3 @@
export { IncidentesPage } from './IncidentesPage';
export { CapacitacionesPage } from './CapacitacionesPage';
export { InspeccionDetailPage } from './InspeccionDetailPage';