Diseño de un AuditoriaController con un patrón para añadir endpoints de historial para diferentes entidades.
Implementación de la lógica de servicio y repositorio para obtener datos de las tablas _H para:
Usuarios (gral_Usuarios_H)
Pagos de Distribuidores (cue_PagosDistribuidor_H)
Notas de Crédito/Débito (cue_CreditosDebitos_H)
Entradas/Salidas de Distribuidores (dist_EntradasSalidas_H)
Entradas/Salidas de Canillitas (dist_EntradasSalidasCanillas_H)
Novedades de Canillitas (dist_dtNovedadesCanillas_H)
Ajustes Manuales de Saldo (cue_SaldoAjustesHistorial)
Tipos de Pago (cue_dtTipopago_H)
Canillitas (Maestro) (dist_dtCanillas_H)
Distribuidores (Maestro) (dist_dtDistribuidores_H)
Empresas (Maestro) (dist_dtEmpresas_H)
DTOs específicos para cada tipo de historial, incluyendo NombreUsuarioModifico.
Frontend:
Servicio auditoriaService.ts con métodos para llamar a cada endpoint de historial.
Página AuditoriaGeneralPage.tsx con:
Selector de "Tipo de Entidad a Auditar".
Filtros comunes (Fechas, Usuario Modificador, Tipo de Modificación, ID Entidad).
Un DataGrid que muestra las columnas dinámicamente según el tipo de entidad seleccionada.
Lógica para cargar los datos correspondientes.
DTOs de historial en TypeScript.
Actualizaciones en AppRoutes.tsx y MainLayout.tsx para la nueva sección de Auditoría (restringida a SuperAdmin).
This commit is contained in:
2025-06-09 19:37:07 -03:00
parent 35e24ab7d2
commit 437b1e8864
98 changed files with 3683 additions and 325 deletions

View File

@@ -0,0 +1,471 @@
// src/pages/Auditoria/AuditoriaGeneralPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Paper, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, Tooltip
} from '@mui/material';
import FilterListIcon from '@mui/icons-material/FilterList';
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
import auditoriaService from '../../services/Auditoria/auditoriaService';
import type { UsuarioDto } from '../../models/dtos/Usuarios/UsuarioDto';
import usuarioService from '../../services/Usuarios/usuarioService';
import { usePermissions } from '../../hooks/usePermissions';
//import axios from 'axios'; // Para el tipo de error de Axios
// Lista de tipos de entidad para el filtro
const TIPOS_ENTIDAD_AUDITABLES = [
{ value: "PagoDistribuidor", label: "Pagos de Distribuidores (cue_PagosDistribuidor_H)" },
{ value: "NotaCreditoDebito", label: "Notas C/D (cue_CreditosDebitos_H)" },
{ value: "EntradaSalidaDist", label: "E/S Distribuidores (dist_EntradasSalidas_H)" },
{ value: "EntradaSalidaCanilla", label: "E/S Canillitas (dist_EntradasSalidasCanillas_H)" },
{ value: "NovedadCanilla", label: "Novedades Canillitas (dist_dtNovedadesCanillas_H)" },
{ value: "SaldoAjuste", label: "Ajustes Manuales de Saldo (cue_SaldoAjustesHistorial)" },
{ value: "TipoPago", label: "Tipos de Pago (cue_dtTipopago_H)" },
{ value: "Canillita", label: "Canillitas (Maestro) (dist_dtCanillas_H)" },
{ value: "Distribuidor", label: "Distribuidores (Maestro) (dist_dtDistribuidores_H)" },
{ value: "Empresa", label: "Empresas (Maestro) (dist_dtEmpresas_H)" },
{ value: "Zona", label: "Zonas (Maestro) (dist_dtZonas_H)" },
{ value: "OtroDestino", label: "Otros Destinos (Maestro) (dist_dtOtrosDestinos_H)" },
{ value: "Publicacion", label: "Publicaciones (Maestro) (dist_dtPublicaciones_H)" },
{ value: "PubliSeccion", label: "Secciones de Publicación (dist_dtPubliSecciones_H)" },
{ value: "PrecioPublicacion", label: "Precios de Publicación (dist_Precios_H)" },
{ value: "RecargoZona", label: "Recargos por Zona (dist_RecargoZona_H)" },
{ value: "PorcPagoDistribuidor", label: "Porcentajes Pago Dist. (dist_PorcPago_H)" },
{ value: "PorcMonCanilla", label: "Porcentajes/Montos Canillita (dist_PorcMonPagoCanilla_H)" },
{ value: "ControlDevoluciones", label: "Control Devoluciones (dist_dtCtrlDevoluciones_H)" },
{ value: "TipoBobina", label: "Tipos de Bobina (bob_dtBobinas_H)" },
{ value: "EstadoBobina", label: "Estados de Bobina (bob_dtEstadosBobinas_H)" },
{ value: "PlantaImpresion", label: "Plantas de Impresión (bob_dtPlantas_H)" },
{ value: "StockBobina", label: "Stock de Bobinas (bob_StockBobinas_H)" },
{ value: "RegPublicacionesTirada", label: "Secciones de Tirada (bob_RegPublicaciones_H)" },
{ value: "RegTirada", label: "Registro de Tirada (bob_RegTiradas_H)" },
].sort((a, b) => a.label.localeCompare(b.label));
const TIPOS_MODIFICACION = [
"Creado", "Creada", "Actualizado", "Actualizada", "Modificado", "Modificada",
"Eliminado", "Eliminada", "Insertada", "Baja", "Alta", "Liquidada", "AjusteManualSaldo"
// Añadir más si es necesario (ej. "AjusteManualSaldo")
].sort();
const AuditoriaGeneralPage: React.FC = () => {
const [datosAuditoria, setDatosAuditoria] = useState<any[]>([]); // Tipo genérico para los datos de la tabla
const [columnasActuales, setColumnasActuales] = useState<GridColDef[]>([]);
const [loading, setLoading] = useState(false); // Un solo loading para la búsqueda
const [error, setError] = useState<string | null>(null); // Error general de la página o de la búsqueda
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroIdUsuarioMod, setFiltroIdUsuarioMod] = useState<number | string>('');
const [filtroTipoEntidad, setFiltroTipoEntidad] = useState<string>('');
const [filtroIdEntidad, setFiltroIdEntidad] = useState<string>('');
const [filtroTipoMod, setFiltroTipoMod] = useState<string>('');
const [usuariosDropdown, setUsuariosDropdown] = useState<UsuarioDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerAuditoria = isSuperAdmin || tienePermiso("AU_GENERAL_VIEW"); // Define este permiso
const formatDate = (dateString?: string | null): string => {
if (!dateString) return '-';
const datePart = dateString.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) { return `${parts[2]}/${parts[1]}/${parts[0]}`; }
return datePart;
};
const currencyFormatter = (value?: number | null) => value != null ? value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }) : '-';
const numberFormatter = (value?: number | null) => value != null ? value.toLocaleString('es-AR') : '-';
useEffect(() => {
const fetchDropdowns = async () => {
if (!puedeVerAuditoria) return;
setLoadingDropdowns(true);
try {
const users = await usuarioService.getAllUsuarios();
setUsuariosDropdown(users);
} catch (e) {
console.error("Error cargando usuarios para filtro auditoría", e);
setError("Error al cargar usuarios para filtro.");
} finally {
setLoadingDropdowns(false);
}
};
fetchDropdowns();
}, [puedeVerAuditoria]);
const getCommonAuditColumns = (): GridColDef[] => [
{ field: 'fechaMod', headerName: 'Fecha Mod.', width: 170, type: 'dateTime', valueFormatter: (value) => formatDate(value as string) },
{ field: 'nombreUsuarioModifico', headerName: 'Modificado Por', width: 180, flex: 0.8 },
{ field: 'tipoMod', headerName: 'Acción', width: 120, flex: 0.5 },
// Se pueden añadir más columnas comunes aquí si aplican a TODOS los historiales
];
const cargarHistorial = useCallback(async () => {
if (!puedeVerAuditoria) {
setError("No tiene permiso para ver la auditoría."); setLoading(false); return;
}
if (!filtroTipoEntidad) {
setError("Debe seleccionar un 'Tipo de Entidad a Auditar' para buscar.");
setDatosAuditoria([]); setColumnasActuales(getCommonAuditColumns()); setLoading(false); return;
}
setLoading(true); setError(null);
try {
const commonParams = {
fechaDesde: filtroFechaDesde || undefined,
fechaHasta: filtroFechaHasta || undefined,
idUsuarioModificador: filtroIdUsuarioMod ? Number(filtroIdUsuarioMod) : undefined,
tipoModificacion: filtroTipoMod || undefined,
};
let rawData: any[] = [];
let cols: GridColDef[] = getCommonAuditColumns();
switch (filtroTipoEntidad) {
case "PagoDistribuidor":
const pagoHist = await auditoriaService.getHistorialPagosDistribuidor({
...commonParams,
idPagoAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = pagoHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Pago', headerName: 'ID Pago', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Distribuidor', headerName: 'ID Dist.', width: 100, align: 'center', headerAlign: 'center' }, // Podrías añadir Nombre si el DTO lo trae
{ field: 'id_Empresa', headerName: 'ID Emp.', width: 100, align: 'center', headerAlign: 'center' }, // Podrías añadir Nombre
{ field: 'fecha', headerName: 'Fecha Pago', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'recibo', headerName: 'Recibo', width: 100 },
{ field: 'tipoMovimiento', headerName: 'Tipo Mov.', width: 120 },
{ field: 'monto', headerName: 'Monto Pago', width: 130, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'id_TipoPago', headerName: 'ID Tipo Pago', width: 110, align: 'center', headerAlign: 'center' }, // Podrías añadir Nombre
{ field: 'detalle', headerName: 'Detalle Pago', flex: 1, minWidth: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
];
break;
case "NotaCreditoDebito":
const notaCDHist = await auditoriaService.getHistorialNotasCD({
...commonParams,
idNotaAfectada: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = notaCDHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Nota', headerName: 'ID Nota Orig.', width: 110, align: 'center', headerAlign: 'center' },
{ field: 'destino', headerName: 'Destino', width: 120 },
{ field: 'id_Destino', headerName: 'ID Dest.', width: 100, align: 'center', headerAlign: 'center' },
// NombreDestinatario y NombreEmpresa se podrían traer si el DTO de historial los incluyera
{ field: 'tipo', headerName: 'Tipo Nota', width: 100 },
{ field: 'fecha', headerName: 'Fecha Nota', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'monto', headerName: 'Monto Nota', width: 130, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'referencia', headerName: 'Referencia', width: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'observaciones', headerName: 'Obs. Nota', flex: 1, minWidth: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
];
break;
case "EntradaSalidaDist":
const esDistHist = await auditoriaService.getHistorialEntradasSalidasDist({
...commonParams,
idParteAfectada: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
// Si añades más filtros al servicio, pásalos aquí
});
rawData = esDistHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Parte', headerName: 'ID Mov.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Publicacion', headerName: 'ID Pub.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Distribuidor', headerName: 'ID Dist.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'fecha', headerName: 'Fecha Mov.', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'tipoMovimiento', headerName: 'Tipo', width: 100 },
{ field: 'cantidad', headerName: 'Cantidad', width: 100, type: 'number', valueFormatter: (v) => numberFormatter(v as number) },
{ field: 'remito', headerName: 'Remito', width: 100, type: 'number' },
{ field: 'observacion', headerName: 'Obs. Mov.', flex: 1, minWidth: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
// Podrías mostrar Id_Precio, Id_Recargo, Id_Porcentaje si es útil
];
break;
case "EntradaSalidaCanilla":
const esCanillaHist = await auditoriaService.getHistorialEntradasSalidasCanilla({
...commonParams,
idParteAfectada: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = esCanillaHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Parte', headerName: 'ID Mov.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Publicacion', headerName: 'ID Pub.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Canilla', headerName: 'ID Can.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'fecha', headerName: 'Fecha Mov.', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'cantSalida', headerName: 'Salida', width: 90, type: 'number', valueFormatter: (v) => numberFormatter(v as number) },
{ field: 'cantEntrada', headerName: 'Entrada', width: 90, type: 'number', valueFormatter: (v) => numberFormatter(v as number) },
{ field: 'observacion', headerName: 'Obs. Mov.', flex: 1, minWidth: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
// Considera mostrar Id_Precio, Id_Recargo, Id_PorcMon si es relevante
];
break;
case "NovedadCanilla":
const novedadHist = await auditoriaService.getHistorialNovedadesCanilla({
...commonParams,
idNovedadAfectada: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
// También podrías pasar un filtro de idCanilla si lo añades a HistorialNovedadesCanillaParams y al backend
});
rawData = novedadHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Novedad', headerName: 'ID Novedad', width: 110, align: 'center', headerAlign: 'center' },
{ field: 'id_Canilla', headerName: 'ID Canillita', width: 110, align: 'center', headerAlign: 'center' },
// Aquí podrías querer mostrar el NombreCanilla, necesitarías un JOIN o una llamada extra en el servicio
{ field: 'fecha', headerName: 'Fecha Novedad', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{
field: 'detalle',
headerName: 'Detalle Novedad',
flex: 1,
minWidth: 250,
renderCell: (params) => (
<Tooltip title={params.value || ''} arrow placement="top">
<Typography noWrap variant="body2" sx={{ width: '100%' }}>{params.value || '-'}</Typography>
</Tooltip>
)
},
];
break;
case "SaldoAjuste":
const ajusteHist = await auditoriaService.getHistorialAjustesSaldo({
// Pasar los filtros comunes
fechaDesde: commonParams.fechaDesde,
fechaHasta: commonParams.fechaHasta,
idUsuarioModificador: commonParams.idUsuarioModificador,
// Filtros específicos para este historial (si el backend los usa para este endpoint en particular)
// Si no, estos se ignorarán o podrías omitirlos si el endpoint no los toma.
// El endpoint actual que definimos sí los toma.
destino: filtroIdEntidad ? (TIPOS_ENTIDAD_AUDITABLES.find(t => t.value === filtroTipoEntidad)?.label.includes("Dist") ? "Distribuidores" : "Canillas") : undefined, // Lógica para determinar Destino
idDestino: filtroIdEntidad ? Number(filtroIdEntidad) : undefined, // ID Entidad Afectada aquí sería idDestino
// idEmpresa: si tienes un filtro de empresa general, pásalo
});
rawData = ajusteHist;
cols = [
// Reutilizar 'fechaMod' como 'Fecha Ajuste' y 'nombreUsuarioModifico'
{ field: 'fechaAjuste', headerName: 'Fecha Ajuste', width: 170, type: 'dateTime', valueFormatter: (value) => formatDate(value as string) },
{ field: 'nombreUsuarioModifico', headerName: 'Ajustado Por', width: 180, flex: 0.8 },
{ field: 'destino', headerName: 'Tipo Dest.', width: 120 },
{ field: 'id_Destino', headerName: 'ID Dest.', width: 100, align: 'center', headerAlign: 'center' },
// Aquí podrías querer mostrar NombreDestinatario y NombreEmpresa,
// requeriría que SaldoAjusteHistorialDto los traiga (JOINs en backend)
{ field: 'id_Empresa', headerName: 'ID Emp.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'montoAjuste', headerName: 'Monto Ajuste', width: 130, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'saldoAnterior', headerName: 'Saldo Ant.', width: 130, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'saldoNuevo', headerName: 'Saldo Nvo.', width: 130, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{
field: 'justificacion',
headerName: 'Justificación',
flex: 1,
minWidth: 200,
renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2" sx={{ width: '100%' }}>{params.value || '-'}</Typography></Tooltip>)
},
];
break;
case "TipoPago":
const tipoPagoHist = await auditoriaService.getHistorialTiposPago({
...commonParams,
idTipoPagoAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = tipoPagoHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_TipoPago', headerName: 'ID Tipo Pago', width: 110, align: 'center', headerAlign: 'center' },
{ field: 'nombre', headerName: 'Nombre Tipo Pago', width: 200 },
{
field: 'detalle',
headerName: 'Detalle',
flex: 1,
minWidth: 250,
renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2" sx={{ width: '100%' }}>{params.value || '-'}</Typography></Tooltip>)
},
];
break;
case "Canillita": // Historial del maestro de Canillitas
const canMaestroHist = await auditoriaService.getHistorialCanillitasMaestro({
...commonParams,
idCanillaAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = canMaestroHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Canilla', headerName: 'ID Canillita', width: 110, align: 'center', headerAlign: 'center' },
{ field: 'nomApe', headerName: 'Nombre y Apellido', width: 200 },
{ field: 'legajo', headerName: 'Legajo', width: 100, type: 'number' },
{ field: 'parada', headerName: 'Parada', width: 180, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'id_Zona', headerName: 'ID Zona', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'accionista', headerName: 'Accionista', width: 100, type: 'boolean' },
{ field: 'empresa', headerName: 'ID Empresa', width: 100, align: 'center', headerAlign: 'center' }, // ID de la empresa
{ field: 'baja', headerName: 'Baja', width: 80, type: 'boolean' },
{ field: 'fechaBaja', headerName: 'Fecha Baja', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'obs', headerName: 'Obs.', flex: 1, minWidth: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
];
break;
case "Distribuidor": // Historial del maestro de Distribuidores
const distMaestroHist = await auditoriaService.getHistorialDistribuidoresMaestro({
...commonParams,
idDistribuidorAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = distMaestroHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Distribuidor', headerName: 'ID Distribuidor', width: 110, align: 'center', headerAlign: 'center' },
{ field: 'nombre', headerName: 'Nombre', width: 200 },
{ field: 'nroDoc', headerName: 'Nro. Doc.', width: 120 },
{ field: 'contacto', headerName: 'Contacto', width: 150, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'id_Zona', headerName: 'ID Zona', width: 90, align: 'center', headerAlign: 'center' },
// Podrías añadir más campos como Calle, Localidad, etc. si son importantes para la auditoría visual
{ field: 'email', headerName: 'Email', width: 180, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'telefono', headerName: 'Teléfono', width: 130 },
];
break;
case "Empresa": // Historial del maestro de Empresas
const empMaestroHist = await auditoriaService.getHistorialEmpresasMaestro({
...commonParams,
idEmpresaAfectada: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = empMaestroHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Empresa', headerName: 'ID Empresa', width: 110, align:'center', headerAlign:'center' },
{ field: 'nombre', headerName: 'Nombre Empresa', width: 250 },
{
field: 'detalle',
headerName: 'Detalle',
flex:1,
minWidth:250,
renderCell: (params) => ( <Tooltip title={params.value || ''}><Typography noWrap variant="body2" sx={{width:'100%'}}>{params.value || '-'}</Typography></Tooltip>)},
];
break;
default:
setError(`La vista de auditoría para '${filtroTipoEntidad}' aún no está implementada.`);
setDatosAuditoria([]);
setColumnasActuales(getCommonAuditColumns());
setLoading(false);
return;
}
// Asignar un ID único a cada fila para el DataGrid si no viene del DTO
setDatosAuditoria(rawData.map((item, index) => ({ ...item, id: item.id_NotaOriginal || item.id_Nota || item.idHist || `hist-${filtroTipoEntidad}-${index}-${Math.random()}` }))); // Asegurar ID único
setColumnasActuales(cols);
} catch (err: any) {
console.error(err);
setError(err.response?.data?.message || 'Error al cargar el historial.');
setDatosAuditoria([]);
setColumnasActuales(getCommonAuditColumns());
} finally {
setLoading(false);
}
}, [
puedeVerAuditoria, filtroTipoEntidad, filtroFechaDesde, filtroFechaHasta,
filtroIdUsuarioMod, filtroIdEntidad, filtroTipoMod
]);
const handleBuscar = () => {
setPage(0);
cargarHistorial();
}
if (!puedeVerAuditoria && !loadingDropdowns) { // Si ya terminaron de cargar los dropdowns y no tiene permiso
return <Box sx={{ p: 2 }}><Alert severity="error">No tiene permiso para acceder a la Auditoría General.</Alert></Box>;
}
if (loadingDropdowns && !usuariosDropdown.length) { // Spinner inicial para dropdowns
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Auditoría General del Sistema</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}>
<TextField label="Fecha Mod. Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<TextField label="Fecha Mod. Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingDropdowns}>
<InputLabel>Modificado Por</InputLabel>
<Select value={filtroIdUsuarioMod} label="Modificado Por" onChange={(e) => setFiltroIdUsuarioMod(e.target.value)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{usuariosDropdown.map((u) => (<MenuItem key={u.id} value={u.id}>{u.nombre} {u.apellido} ({u.user})</MenuItem>))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} required error={!filtroTipoEntidad && !!error && error.includes("Debe seleccionar un 'Tipo de Entidad")}>
<InputLabel>Tipo de Entidad a Auditar</InputLabel>
<Select value={filtroTipoEntidad} label="Tipo de Entidad a Auditar"
onChange={(e) => {
setFiltroTipoEntidad(e.target.value);
setFiltroIdEntidad('');
setDatosAuditoria([]);
setColumnasActuales(getCommonAuditColumns());
if (error && error.includes("Debe seleccionar un 'Tipo de Entidad")) setError(null);
}}
>
<MenuItem value=""><em>Seleccione un tipo</em></MenuItem>
{TIPOS_ENTIDAD_AUDITABLES.map((t) => (<MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>))}
</Select>
{!filtroTipoEntidad && error && error.includes("Debe seleccionar un 'Tipo de Entidad") && <Typography color="error" variant="caption" sx={{ ml: 1.5, mt: 0.5 }}>{error}</Typography>}
</FormControl>
<TextField label="ID Entidad Afectada" type="number" size="small" value={filtroIdEntidad}
onChange={(e) => setFiltroIdEntidad(e.target.value)} sx={{ minWidth: 150 }}
InputProps={{ inputProps: { min: 1 } }}
disabled={!filtroTipoEntidad}
/>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}>
<InputLabel>Tipo Modificación</InputLabel>
<Select value={filtroTipoMod} label="Tipo Modificación" onChange={(e) => setFiltroTipoMod(e.target.value)}>
<MenuItem value=""><em>Todas</em></MenuItem>
{TIPOS_MODIFICACION.map((t) => (<MenuItem key={t} value={t}>{t}</MenuItem>))}
</Select>
</FormControl>
<Button variant="contained" onClick={handleBuscar} disabled={loading || loadingDropdowns || !filtroTipoEntidad}>
{loading ? <CircularProgress size={24} color="inherit" /> : "Buscar"}
</Button>
</Box>
</Paper>
{/* Mostrar error general si no es un error de "seleccione tipo de entidad" */}
{error && !error.includes("Debe seleccionar un 'Tipo de Entidad") && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{!loading && !error && filtroTipoEntidad && (
<Paper sx={{ height: 'calc(100vh - 360px)', width: '100%' }}> {/* Ajustar altura */}
<DataGrid
rows={datosAuditoria}
columns={columnasActuales}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
density="compact"
rowCount={datosAuditoria.length}
paginationModel={{ page, pageSize: rowsPerPage }}
onPaginationModelChange={(model) => {
setPage(model.page);
setRowsPerPage(model.pageSize);
}}
pageSizeOptions={[25, 50, 100]}
rowHeight={48}
sx={{
'& .MuiDataGrid-cell': { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiDataGrid-columnHeaderTitleContainer': { overflow: 'hidden' },
}}
getRowId={(row) => row.id} // Asegurar que getRowId use el 'id' que generamos
/>
</Paper>
)}
{!loading && !error && datosAuditoria.length === 0 && filtroTipoEntidad && (
<Typography sx={{ mt: 2, fontStyle: 'italic', textAlign: 'center' }}>
No se encontraron registros de auditoría para '{TIPOS_ENTIDAD_AUDITABLES.find(t => t.value === filtroTipoEntidad)?.label || filtroTipoEntidad}' con los filtros aplicados.
</Typography>
)}
{!loading && !error && !filtroTipoEntidad && (
<Typography sx={{ mt: 2, fontStyle: 'italic', textAlign: 'center' }}>
Seleccione un "Tipo de Entidad a Auditar" y presione "Buscar".
</Typography>
)}
</Box>
);
};
export default AuditoriaGeneralPage;

View File

@@ -7,6 +7,7 @@ import {
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import HistoryIcon from '@mui/icons-material/History';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
import EditIcon from '@mui/icons-material/Edit';
import EventNoteIcon from '@mui/icons-material/EventNote'; // << AÑADIR IMPORTACIÓN DEL ICONO
@@ -46,10 +47,8 @@ const GestionarCanillitasPage: React.FC = () => {
const puedeCrear = isSuperAdmin || tienePermiso("CG002");
const puedeModificar = isSuperAdmin || tienePermiso("CG003");
const puedeDarBaja = isSuperAdmin || tienePermiso("CG005");
// Permisos para Novedades
const puedeGestionarNovedades = isSuperAdmin || tienePermiso("CG006"); // << DEFINIR PERMISO
// Para la opción "Ver Novedades", podemos usar el permiso de ver canillitas (CG001)
// O si solo se quiere mostrar si puede gestionarlas, usar puedeGestionarNovedades
const puedeGestionarParadas = isSuperAdmin || tienePermiso("CG007");
const puedeGestionarNovedades = isSuperAdmin || tienePermiso("CG006");
const puedeVerNovedadesCanilla = puedeVer || puedeGestionarNovedades; // << LÓGICA PARA MOSTRAR LA OPCIÓN
@@ -131,6 +130,12 @@ const GestionarCanillitasPage: React.FC = () => {
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10)); setPage(0);
};
const handleOpenParadas = (idCan: number) => {
navigate(`/distribucion/canillas/${idCan}/paradas`);
handleMenuClose();
};
const displayData = canillitas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!loading && !puedeVer) {
@@ -181,7 +186,7 @@ const GestionarCanillitasPage: React.FC = () => {
{error && !apiErrorMessage && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true también aquí
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true también a
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
@@ -231,6 +236,12 @@ const GestionarCanillitasPage: React.FC = () => {
<ListItemText>Novedades</ListItemText>
</MenuItem>
)}
{puedeGestionarParadas && selectedCanillitaRow && (
<MenuItem onClick={() => handleOpenParadas(selectedCanillitaRow.idCanilla)}>
<ListItemIcon><HistoryIcon /></ListItemIcon> {/* Cambiar ícono si es necesario */}
<ListItemText>Gestionar Paradas</ListItemText>
</MenuItem>
)}
{puedeModificar && selectedCanillitaRow && ( // Asegurar que selectedCanillitaRow existe
<MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
@@ -243,7 +254,7 @@ const GestionarCanillitasPage: React.FC = () => {
<ListItemText>{selectedCanillitaRow.baja ? 'Reactivar' : 'Dar de Baja'}</ListItemText>
</MenuItem>
)}
{/* Mostrar "Sin acciones" si no hay ninguna acción permitida para la fila seleccionada */}
{selectedCanillitaRow && !puedeModificar && !puedeDarBaja && !puedeVerNovedadesCanilla && (
<MenuItem disabled>Sin acciones</MenuItem>

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, Paper, IconButton, Menu, MenuItem,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
CircularProgress, Alert, Chip
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit'; // Para "Cerrar Vigencia"
import DeleteIcon from '@mui/icons-material/Delete';
import cambioParadaService from '../../services/Distribucion/cambioParadaService';
import canillaService from '../../services/Distribucion/canillaService';
import type { CambioParadaDto } from '../../models/dtos/Distribucion/CambioParadaDto';
import type { CreateCambioParadaDto } from '../../models/dtos/Distribucion/CreateCambioParadaDto';
import type { UpdateCambioParadaDto } from '../../models/dtos/Distribucion/UpdateCambioParadaDto';
import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import CambioParadaFormModal from '../../components/Modals/Distribucion/CambioParadaFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
const GestionarParadasCanillaPage: React.FC = () => {
const { idCanilla: idCanillaStr } = useParams<{ idCanilla: string }>();
const navigate = useNavigate();
const idCanilla = Number(idCanillaStr);
const [canillita, setCanillita] = useState<CanillaDto | null>(null);
const [paradas, setParadas] = useState<CambioParadaDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [paradaParaCerrar, setParadaParaCerrar] = useState<CambioParadaDto | null>(null); // Para el modo "Cerrar" del modal
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedParadaRow, setSelectedParadaRow] = useState<CambioParadaDto | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionarParadas = isSuperAdmin || tienePermiso("CG007");
const puedeVerCanillitas = isSuperAdmin || tienePermiso("CG001");
const cargarDatos = useCallback(async () => {
if (isNaN(idCanilla)) {
setError("ID de Canillita inválido."); setLoading(false); return;
}
if (!puedeGestionarParadas && !puedeVerCanillitas) {
setError("No tiene permiso para acceder a esta sección."); setLoading(false); return;
}
setLoading(true); setError(null); setApiErrorMessage(null);
try {
const [canData, paradasData] = await Promise.all([
puedeVerCanillitas ? canillaService.getCanillaById(idCanilla) : Promise.resolve(null),
(puedeGestionarParadas || puedeVerCanillitas) ? cambioParadaService.getParadasPorCanilla(idCanilla) : Promise.resolve([])
]);
if (canData) setCanillita(canData);
else if (puedeGestionarParadas || puedeVerCanillitas) setCanillita({ idCanilla, nomApe: `ID ${idCanilla}` } as CanillaDto);
setParadas(paradasData.sort((a,b) => new Date(b.vigenciaD).getTime() - new Date(a.vigenciaD).getTime()));
} catch (err: any) {
console.error(err);
setError(axios.isAxiosError(err) && err.response?.status === 404 ? `Canillita ID ${idCanilla} no encontrado.` : 'Error al cargar datos.');
} finally { setLoading(false); }
}, [idCanilla, puedeGestionarParadas, puedeVerCanillitas]);
useEffect(() => { cargarDatos(); }, [cargarDatos]);
const handleOpenModalParaCrear = () => {
if (!puedeGestionarParadas) {
setApiErrorMessage("No tiene permiso para agregar paradas."); return;
}
setParadaParaCerrar(null); // Asegurar que es modo creación
setApiErrorMessage(null);
setModalOpen(true);
};
const handleOpenModalParaCerrar = (parada: CambioParadaDto) => {
if (!puedeGestionarParadas) {
setApiErrorMessage("No tiene permiso para modificar paradas."); return;
}
if (parada.vigenciaH) { // Ya está cerrada
setApiErrorMessage("Esta parada ya tiene una fecha de Vigencia Hasta."); return;
}
setParadaParaCerrar(parada);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false); setParadaParaCerrar(null);
};
const handleSubmitModal = async (data: CreateCambioParadaDto | UpdateCambioParadaDto, idRegistroParada?: number) => {
if (!puedeGestionarParadas || !idCanilla) return; // idCanilla es necesario para crear
setApiErrorMessage(null);
try {
if (isModoCerrar && idRegistroParada) { // Es UpdateCambioParadaDto (para cerrar)
await cambioParadaService.cerrarParada(idRegistroParada, data as UpdateCambioParadaDto);
} else { // Es CreateCambioParadaDto
await cambioParadaService.createParada(idCanilla, data as CreateCambioParadaDto);
}
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la parada.';
setApiErrorMessage(message); throw err;
}
};
const handleDelete = async (idRegistro: number) => {
if (!puedeGestionarParadas) return;
if (window.confirm(`¿Seguro de eliminar este registro de parada (ID: ${idRegistro})? Esta acción no se puede deshacer.`)) {
setApiErrorMessage(null);
try {
await cambioParadaService.deleteParada(idRegistro);
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar el registro de parada.';
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: CambioParadaDto) => {
setAnchorEl(event.currentTarget); setSelectedParadaRow(item);
};
const handleMenuClose = () => {
setAnchorEl(null); setSelectedParadaRow(null);
};
const formatDate = (dateString?: string | null) => dateString ? new Date(dateString + 'T00:00:00Z').toLocaleDateString('es-AR', {timeZone:'UTC'}) : '-';
const isModoCerrar = Boolean(paradaParaCerrar && paradaParaCerrar.idRegistro);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
if (!puedeGestionarParadas && !puedeVerCanillitas) return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>;
return (
<Box sx={{ p: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`/distribucion/canillas`)} sx={{ mb: 2 }}>
Volver a Canillitas
</Button>
<Typography variant="h5" gutterBottom>
Historial de Paradas de: {canillita?.nomApe || `Canillita ID ${idCanilla}`}
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
{puedeGestionarParadas && (
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenModalParaCrear} sx={{ mb: {xs: 2, sm:0} }}>
Registrar Nueva Parada
</Button>
)}
</Paper>
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell sx={{fontWeight: 'bold'}}>Dirección de Parada</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Vigencia Desde</TableCell>
<TableCell sx={{fontWeight: 'bold'}}>Vigencia Hasta</TableCell>
<TableCell align="center" sx={{fontWeight: 'bold'}}>Estado</TableCell>
{puedeGestionarParadas && <TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell>}
</TableRow></TableHead>
<TableBody>
{paradas.length === 0 ? (
<TableRow><TableCell colSpan={puedeGestionarParadas ? 5 : 4} align="center">No hay historial de paradas para este canillita.</TableCell></TableRow>
) : (
paradas.map((p) => (
<TableRow key={p.idRegistro} hover>
<TableCell>{p.parada}</TableCell>
<TableCell>{formatDate(p.vigenciaD)}</TableCell>
<TableCell>{formatDate(p.vigenciaH)}</TableCell>
<TableCell align="center">{p.esActual ? <Chip label="Activa" color="success" size="small" /> : <Chip label="Histórica" size="small" />}</TableCell>
{puedeGestionarParadas && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, p)} disabled={!puedeGestionarParadas}><MoreVertIcon /></IconButton>
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
</TableContainer>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeGestionarParadas && selectedParadaRow && selectedParadaRow.esActual && (
<MenuItem onClick={() => { handleOpenModalParaCerrar(selectedParadaRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{mr:1}}/> Cerrar Vigencia</MenuItem>)}
{/* La eliminación de paradas históricas puede ser delicada, considerar si es necesaria */}
{puedeGestionarParadas && selectedParadaRow && selectedParadaRow.vigenciaH && ( /* Solo eliminar si está cerrada */
<MenuItem onClick={() => handleDelete(selectedParadaRow.idRegistro)}><DeleteIcon fontSize="small" sx={{mr:1}}/> Eliminar Registro</MenuItem>)}
</Menu>
{idCanilla &&
<CambioParadaFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
idCanilla={idCanilla}
nombreCanilla={canillita?.nomApe}
paradaParaCerrar={paradaParaCerrar} // Para diferenciar modo crear vs cerrar
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
}
</Box>
);
};
export default GestionarParadasCanillaPage;

View File

@@ -3,32 +3,30 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
const radiosSubModules = [
{ label: 'Ritmos', path: 'ritmos' },
{ label: 'Canciones', path: 'canciones' },
{ label: 'Generar Listas', path: 'generar-listas' },
{ label: 'Ritmos', path: 'ritmos' },
{ label: 'Canciones', path: 'canciones' },
];
const RadiosIndexPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
const [selectedSubTab, setSelectedSubTab] = useState<number>(0); // Inicializa en 0
useEffect(() => {
const currentBasePath = '/radios';
const subPath = location.pathname.startsWith(currentBasePath + '/')
? location.pathname.substring(currentBasePath.length + 1).split('/')[0]
: (location.pathname === currentBasePath ? radiosSubModules[0]?.path : undefined);
? location.pathname.substring(currentBasePath.length + 1).split('/')[0]
: undefined;
const activeTabIndex = radiosSubModules.findIndex(sm => sm.path === subPath);
if (activeTabIndex !== -1) {
if (location.pathname === currentBasePath) {
// Si está en /radios, redirige a la primera subruta
navigate(radiosSubModules[0].path, { replace: true });
setSelectedSubTab(0);
} else if (activeTabIndex !== -1) {
setSelectedSubTab(activeTabIndex);
} else {
if (location.pathname === currentBasePath && radiosSubModules.length > 0) {
navigate(radiosSubModules[0].path, { replace: true });
setSelectedSubTab(0);
} else {
setSelectedSubTab(false);
}
}
}, [location.pathname, navigate]);