feat(frontend): Add HSE module navigation and routes

- Add HSE section to AdminLayout sidebar with Incidentes and Capacitaciones
- Add HSE routes in App.tsx (/admin/hse/incidentes and /admin/hse/capacitaciones)
- Create IncidentesPage and CapacitacionesPage components
- Create barrel export index.ts for HSE pages
- Import AlertTriangle and GraduationCap icons for HSE navigation

Routes added:
- /admin/hse -> redirects to /admin/hse/incidentes
- /admin/hse/incidentes -> IncidentesPage
- /admin/hse/capacitaciones -> CapacitacionesPage

Navigation structure:
- HSE section in sidebar (collapsed by default)
- Links to Incidentes and Capacitaciones pages
- Active state highlighting for current route

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-02 21:23:13 -06:00
parent 676978146b
commit cbb4f265c5
5 changed files with 512 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import { ManzanasPage } from './pages/admin/proyectos/ManzanasPage';
import { DashboardPage } from './pages/admin/dashboard'; import { DashboardPage } from './pages/admin/dashboard';
import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos'; import { ConceptosPage, PresupuestosPage, EstimacionesPage } from './pages/admin/presupuestos';
import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding'; import { OpportunitiesPage, TendersPage, ProposalsPage, VendorsPage } from './pages/admin/bidding';
import { IncidentesPage, CapacitacionesPage } from './pages/admin/hse';
function App() { function App() {
return ( return (
@ -59,6 +60,13 @@ function App() {
<Route path="propuestas" element={<ProposalsPage />} /> <Route path="propuestas" element={<ProposalsPage />} />
<Route path="proveedores" element={<VendorsPage />} /> <Route path="proveedores" element={<VendorsPage />} />
</Route> </Route>
{/* HSE */}
<Route path="hse">
<Route index element={<Navigate to="incidentes" replace />} />
<Route path="incidentes" element={<IncidentesPage />} />
<Route path="capacitaciones" element={<CapacitacionesPage />} />
</Route>
</Route> </Route>
{/* Portal Supervisor */} {/* Portal Supervisor */}

View File

@ -20,6 +20,8 @@ import {
FileCheck, FileCheck,
Send, Send,
Users, Users,
AlertTriangle,
GraduationCap,
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@ -74,6 +76,14 @@ const navSections: NavSection[] = [
{ label: 'Proveedores', href: '/admin/licitaciones/proveedores', icon: Users }, { label: 'Proveedores', href: '/admin/licitaciones/proveedores', icon: Users },
], ],
}, },
{
title: 'HSE',
defaultOpen: false,
items: [
{ label: 'Incidentes', href: '/admin/hse/incidentes', icon: AlertTriangle },
{ label: 'Capacitaciones', href: '/admin/hse/capacitaciones', icon: GraduationCap },
],
},
]; ];
export function AdminLayout() { export function AdminLayout() {

View File

@ -0,0 +1,446 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, Power } from 'lucide-react';
import {
useCapacitaciones,
useCreateCapacitacion,
useUpdateCapacitacion,
useToggleCapacitacion,
useDeleteCapacitacion,
} from '../../../hooks/useHSE';
import {
Capacitacion,
TipoCapacitacion,
CreateCapacitacionDto,
} from '../../../services/hse/capacitaciones.api';
import clsx from 'clsx';
const tipoColors: Record<TipoCapacitacion, string> = {
induccion: 'bg-green-100 text-green-800',
especifica: 'bg-blue-100 text-blue-800',
certificacion: 'bg-purple-100 text-purple-800',
reentrenamiento: 'bg-orange-100 text-orange-800',
};
const tipoLabels: Record<TipoCapacitacion, string> = {
induccion: 'Induccion',
especifica: 'Especifica',
certificacion: 'Certificacion',
reentrenamiento: 'Reentrenamiento',
};
export function CapacitacionesPage() {
const [search, setSearch] = useState('');
const [tipoFilter, setTipoFilter] = useState<TipoCapacitacion | ''>('');
const [activoFilter, setActivoFilter] = useState<boolean | ''>('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<Capacitacion | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const { data, isLoading, error } = useCapacitaciones({
search: search || undefined,
tipo: tipoFilter || undefined,
activo: activoFilter === '' ? undefined : activoFilter,
});
const deleteMutation = useDeleteCapacitacion();
const createMutation = useCreateCapacitacion();
const updateMutation = useUpdateCapacitacion();
const toggleMutation = useToggleCapacitacion();
const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync(id);
setDeleteConfirm(null);
};
const handleSubmit = async (formData: CreateCapacitacionDto) => {
if (editingItem) {
await updateMutation.mutateAsync({ id: editingItem.id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setShowModal(false);
setEditingItem(null);
};
const handleToggleActive = async (id: string) => {
await toggleMutation.mutateAsync(id);
};
const capacitaciones = data?.items || [];
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Capacitaciones HSE</h1>
<p className="text-gray-600">Gestion de capacitaciones de seguridad y salud</p>
</div>
<button
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => {
setEditingItem(null);
setShowModal(true);
}}
>
<Plus className="w-5 h-5 mr-2" />
Nueva Capacitacion
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar por codigo o nombre..."
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={tipoFilter}
onChange={(e) => setTipoFilter(e.target.value as TipoCapacitacion | '')}
>
<option value="">Todos los tipos</option>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={activoFilter === '' ? '' : activoFilter ? 'true' : 'false'}
onChange={(e) =>
setActivoFilter(e.target.value === '' ? '' : e.target.value === 'true')
}
>
<option value="">Todos los estados</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
</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>
) : capacitaciones.length === 0 ? (
<div className="p-8 text-center text-gray-500">No hay capacitaciones</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">
Codigo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Nombre
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Tipo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Duracion
</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">
{capacitaciones.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.codigo}
</td>
<td className="px-6 py-4 text-sm text-gray-900">{item.nombre}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
tipoColors[item.tipo]
)}
>
{tipoLabels[item.tipo]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.duracionHoras} hrs
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
item.activo
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
)}
>
{item.activo ? 'Activo' : 'Inactivo'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<button
className={clsx(
'p-2 rounded-lg',
item.activo
? 'text-gray-500 hover:text-orange-600 hover:bg-orange-50'
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
)}
title={item.activo ? 'Desactivar' : 'Activar'}
onClick={() => handleToggleActive(item.id)}
disabled={toggleMutation.isPending}
>
<Power className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"
title="Editar"
onClick={() => {
setEditingItem(item);
setShowModal(true);
}}
>
<Pencil className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"
title="Eliminar"
onClick={() => setDeleteConfirm(item.id)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Modal */}
{showModal && (
<CapacitacionModal
item={editingItem}
onClose={() => {
setShowModal(false);
setEditingItem(null);
}}
onSubmit={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Delete Confirmation */}
{deleteConfirm && (
<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">
<h3 className="text-lg font-semibold mb-4">Confirmar eliminacion</h3>
<p className="text-gray-600 mb-6">
¿Esta seguro de eliminar esta capacitacion? Esta accion no se puede deshacer.
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={() => setDeleteConfirm(null)}
>
Cancelar
</button>
<button
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
onClick={() => handleDelete(deleteConfirm)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Modal Component
interface CapacitacionModalProps {
item: Capacitacion | null;
onClose: () => void;
onSubmit: (data: CreateCapacitacionDto) => Promise<void>;
isLoading: boolean;
}
function CapacitacionModal({ item, onClose, onSubmit, isLoading }: CapacitacionModalProps) {
const [formData, setFormData] = useState<CreateCapacitacionDto>({
codigo: item?.codigo || '',
nombre: item?.nombre || '',
descripcion: item?.descripcion || '',
tipo: item?.tipo || 'induccion',
duracionHoras: item?.duracionHoras || 0,
temario: item?.temario || '',
objetivos: item?.objetivos || '',
requisitos: item?.requisitos || '',
activo: item?.activo ?? true,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit(formData);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{item ? 'Editar Capacitacion' : 'Nueva Capacitacion'}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Codigo *</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.codigo}
onChange={(e) => setFormData({ ...formData, codigo: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo *</label>
<select
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.tipo}
onChange={(e) =>
setFormData({ ...formData, tipo: e.target.value as TipoCapacitacion })
}
>
{Object.entries(tipoLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
<input
type="text"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Descripcion</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.descripcion}
onChange={(e) => setFormData({ ...formData, descripcion: 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">
Duracion (horas) *
</label>
<input
type="number"
required
min="0"
step="0.5"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.duracionHoras}
onChange={(e) =>
setFormData({ ...formData, duracionHoras: parseFloat(e.target.value) })
}
/>
</div>
<div className="flex items-center">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={formData.activo}
onChange={(e) => setFormData({ ...formData, activo: e.target.checked })}
/>
<span className="ml-2 text-sm text-gray-700">Activo</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Objetivos</label>
<textarea
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.objetivos}
onChange={(e) => setFormData({ ...formData, objetivos: e.target.value })}
placeholder="Objetivos de aprendizaje de la capacitacion"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Temario</label>
<textarea
rows={4}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.temario}
onChange={(e) => setFormData({ ...formData, temario: e.target.value })}
placeholder="Contenido y temas a cubrir"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Requisitos previos
</label>
<textarea
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
value={formData.requisitos}
onChange={(e) => setFormData({ ...formData, requisitos: e.target.value })}
placeholder="Requisitos para tomar la capacitacion"
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50"
onClick={onClose}
>
Cancelar
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Guardando...' : item ? 'Guardar Cambios' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
/**
* IncidentesPage Component
* Gestion de incidentes HSE
*/
import { AlertTriangle } from 'lucide-react';
export function IncidentesPage() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Incidentes HSE</h1>
<p className="text-gray-600">
Gestion de incidentes de seguridad, salud y medio ambiente
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-8">
<div className="flex flex-col items-center justify-center text-center py-12">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-yellow-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Modulo de Incidentes en Desarrollo
</h3>
<p className="text-gray-600 max-w-md">
Este modulo permitira registrar, clasificar y dar seguimiento a incidentes
de seguridad, salud ocupacional y medio ambiente.
</p>
<div className="mt-6 text-sm text-gray-500">
<p>Funcionalidades proximas:</p>
<ul className="mt-2 space-y-1">
<li>- Registro de incidentes y accidentes</li>
<li>- Clasificacion por severidad y tipo</li>
<li>- Seguimiento de investigaciones</li>
<li>- Planes de accion correctiva</li>
<li>- Reportes y estadisticas</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

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