Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.
Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
agrupar las suscripciones por cliente y empresa, generando una
factura consolidada para cada combinación. Esto asegura la correcta
separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
de un período (ej. Septiembre) aplique únicamente los ajustes
pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
`GenerarFacturacionMensual` que impide generar la facturación de un
período si el anterior no ha sido cerrado, garantizando el orden
cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
generar el cierre ahora envía un único email consolidado por
suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
para que opere sobre una `idFactura` individual y adjunte el PDF
correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
`FacturaRepository` y `AjusteRepository` para soportar los nuevos
requisitos de filtrado y consulta de datos consolidados.
Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
- `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
débito, procesar respuesta).
- `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
filtrar y gestionar facturas individuales con una interfaz de doble
acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
filtros por nombre de suscriptor, estado de pago y estado de
facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
ahora filtra por el mes actual por defecto para mejorar el rendimiento
y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
registrar un monto superior al saldo pendiente de la factura.
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Box, Paper, Typography, List, ListItemButton, ListItemText, Collapse, CircularProgress } from '@mui/material';
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
import ExpandLess from '@mui/icons-material/ExpandLess';
|
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
|
|
|
// Definición de los módulos de reporte con sus categorías, etiquetas y rutas
|
|
const allReportModules: { category: string; label: string; path: string }[] = [
|
|
{ category: 'Existencia Papel', label: 'Existencia de Papel', path: 'existencia-papel' },
|
|
{ category: 'Movimientos Bobinas', label: 'Movimiento de Bobinas', path: 'movimiento-bobinas' },
|
|
{ category: 'Movimientos Bobinas', label: 'Mov. Bobinas por Estado', path: 'movimiento-bobinas-estado' },
|
|
{ category: 'Listados Distribución', label: 'Distribución Distribuidores', path: 'listado-distribucion-distribuidores' },
|
|
{ category: 'Listados Distribución', label: 'Distribución Canillas', path: 'listado-distribucion-canillas' },
|
|
{ category: 'Listados Distribución', label: 'Distribución General', path: 'listado-distribucion-general' },
|
|
{ category: 'Listados Distribución', label: 'Distrib. Canillas (Importe)', path: 'listado-distribucion-canillas-importe' },
|
|
{ category: 'Listados Distribución', label: 'Detalle Distribución Canillas', path: 'detalle-distribucion-canillas' },
|
|
{ category: 'Secretaría', label: 'Venta Mensual Secretaría', path: 'venta-mensual-secretaria' },
|
|
{ category: 'Tiradas por Publicación', label: 'Tiradas Publicación/Sección', path: 'tiradas-publicaciones-secciones' },
|
|
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/Sección', path: 'consumo-bobinas-seccion' },
|
|
{ category: 'Consumos Bobinas', label: 'Consumo Bobinas/PubPublicación', path: 'consumo-bobinas-publicacion' },
|
|
{ category: 'Consumos Bobinas', label: 'Comparativa Consumo Bobinas', path: 'comparativa-consumo-bobinas' },
|
|
{ category: 'Balance de Cuentas', label: 'Cuentas Distribuidores', path: 'cuentas-distribuidores' },
|
|
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' },
|
|
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' },
|
|
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' },
|
|
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
|
|
];
|
|
|
|
const predefinedCategoryOrder = [
|
|
'Balance de Cuentas',
|
|
'Listados Distribución',
|
|
'Ctrl. Devoluciones',
|
|
'Novedades de Canillitas',
|
|
'Suscripciones',
|
|
'Existencia Papel',
|
|
'Movimientos Bobinas',
|
|
'Consumos Bobinas',
|
|
'Tiradas por Publicación',
|
|
'Secretaría',
|
|
];
|
|
|
|
|
|
const ReportesIndexPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const [expandedCategory, setExpandedCategory] = useState<string | false>(false);
|
|
const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true);
|
|
|
|
const uniqueCategories = useMemo(() => predefinedCategoryOrder, []);
|
|
|
|
useEffect(() => {
|
|
const currentBasePath = '/reportes';
|
|
const pathParts = location.pathname.substring(currentBasePath.length + 1).split('/');
|
|
const subPathSegment = pathParts[0];
|
|
|
|
let activeReportFoundInEffect = false;
|
|
|
|
if (subPathSegment && subPathSegment !== "") { // Asegurarse que subPathSegment no esté vacío
|
|
const activeReport = allReportModules.find(module => module.path === subPathSegment);
|
|
if (activeReport) {
|
|
setExpandedCategory(activeReport.category);
|
|
activeReportFoundInEffect = true;
|
|
} else {
|
|
setExpandedCategory(false);
|
|
}
|
|
} else {
|
|
setExpandedCategory(false);
|
|
}
|
|
|
|
if (location.pathname === currentBasePath && allReportModules.length > 0 && isLoadingInitialNavigation) {
|
|
let firstReportToNavigate: { category: string; label: string; path: string } | null = null;
|
|
for (const category of uniqueCategories) {
|
|
const reportsInCat = allReportModules.filter(r => r.category === category);
|
|
if (reportsInCat.length > 0) {
|
|
firstReportToNavigate = reportsInCat[0];
|
|
break;
|
|
}
|
|
}
|
|
if (firstReportToNavigate) {
|
|
navigate(firstReportToNavigate.path, { replace: true });
|
|
activeReportFoundInEffect = true;
|
|
}
|
|
}
|
|
// Solo se establece a false si no estamos en el proceso de navegación inicial O si no se encontró reporte
|
|
if (!activeReportFoundInEffect || location.pathname !== currentBasePath) {
|
|
setIsLoadingInitialNavigation(false);
|
|
}
|
|
|
|
}, [location.pathname, navigate, uniqueCategories, isLoadingInitialNavigation]);
|
|
|
|
const handleCategoryClick = (categoryName: string) => {
|
|
setExpandedCategory(prev => (prev === categoryName ? false : categoryName));
|
|
};
|
|
|
|
const handleReportClick = (reportPath: string) => {
|
|
navigate(reportPath);
|
|
};
|
|
|
|
const isReportActive = (reportPath: string) => {
|
|
return location.pathname === `/reportes/${reportPath}` || location.pathname.startsWith(`/reportes/${reportPath}/`);
|
|
};
|
|
|
|
// Si isLoadingInitialNavigation es true Y estamos en /reportes, mostrar loader
|
|
// Esto evita mostrar el loader si se navega directamente a un sub-reporte.
|
|
if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
// Contenedor principal que se adaptará a su padre
|
|
// Eliminamos 'height: calc(100vh - 64px)' y cualquier margen/padding que controle el espacio exterior
|
|
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
|
|
{/* Panel Lateral para Navegación */}
|
|
<Paper
|
|
elevation={0} // Sin elevación para que se sienta más integrado si el fondo es el mismo
|
|
square // Bordes rectos
|
|
sx={{
|
|
width: { xs: 220, sm: 250, md: 280 }, // Ancho responsivo del panel lateral
|
|
minWidth: { xs: 200, sm: 220 },
|
|
height: '100%', // Ocupa toda la altura del Box padre
|
|
borderRight: (theme) => `1px solid ${theme.palette.divider}`,
|
|
overflowY: 'auto',
|
|
bgcolor: 'background.paper', // O el color que desees para el menú
|
|
// display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical
|
|
}}
|
|
>
|
|
{/* Título del Menú Lateral */}
|
|
<Box
|
|
sx={{
|
|
p: 1.5, // Padding interno para el título
|
|
// borderBottom: (theme) => `1px solid ${theme.palette.divider}`, // Opcional: separador
|
|
// position: 'sticky', // Si quieres que el título quede fijo al hacer scroll en la lista
|
|
// top: 0,
|
|
// zIndex: 1,
|
|
// bgcolor: 'background.paper' // Necesario si es sticky y tiene scroll la lista
|
|
}}
|
|
>
|
|
<Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 /* Pequeño margen para alinear con items */ }}>
|
|
Reportes
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Lista de Categorías y Reportes */}
|
|
{uniqueCategories.length > 0 ? (
|
|
<List component="nav" dense sx={{ pt: 0 }} /* Quitar padding superior de la lista si el título ya lo tiene */ >
|
|
{uniqueCategories.map((category) => {
|
|
const reportsInCategory = allReportModules.filter(r => r.category === category);
|
|
const isExpanded = expandedCategory === category;
|
|
|
|
return (
|
|
<React.Fragment key={category}>
|
|
<ListItemButton
|
|
onClick={() => handleCategoryClick(category)}
|
|
sx={{
|
|
// py: 1.2, // Ajustar padding vertical de items de categoría
|
|
// backgroundColor: isExpanded ? 'action.selected' : 'transparent',
|
|
borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent',
|
|
pr: 1, // Menos padding a la derecha para dar espacio al ícono expander
|
|
'&:hover': {
|
|
backgroundColor: 'action.hover'
|
|
}
|
|
}}
|
|
>
|
|
<ListItemText
|
|
primary={category}
|
|
primaryTypographyProps={{
|
|
fontWeight: isExpanded ? 'bold' : 'normal',
|
|
// color: isExpanded ? 'primary.main' : 'text.primary'
|
|
}}
|
|
/>
|
|
{reportsInCategory.length > 0 && (isExpanded ? <ExpandLess /> : <ExpandMore />)}
|
|
</ListItemButton>
|
|
{reportsInCategory.length > 0 && (
|
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
|
<List component="div" disablePadding dense>
|
|
{reportsInCategory.map((report) => (
|
|
<ListItemButton
|
|
key={report.path}
|
|
selected={isReportActive(report.path)}
|
|
onClick={() => handleReportClick(report.path)}
|
|
sx={{
|
|
pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título)
|
|
py: 0.8, // Padding vertical de items de reporte
|
|
...(isReportActive(report.path) && {
|
|
backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil
|
|
borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo
|
|
'& .MuiListItemText-primary': {
|
|
fontWeight: 'medium', // O 'bold'
|
|
// color: 'primary.main'
|
|
},
|
|
}),
|
|
'&:hover': {
|
|
backgroundColor: (theme) => theme.palette.action.hover
|
|
}
|
|
}}
|
|
>
|
|
<ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/>
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
</Collapse>
|
|
)}
|
|
{reportsInCategory.length === 0 && isExpanded && (
|
|
<ListItemText
|
|
primary="No hay reportes en esta categoría."
|
|
sx={{ pl: 3.5, fontStyle: 'italic', color: 'text.secondary', py:1, typography: 'body2' }}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</List>
|
|
) : (
|
|
<Typography sx={{p:2, fontStyle: 'italic'}}>No hay categorías configuradas.</Typography>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Área Principal para el Contenido del Reporte */}
|
|
<Box
|
|
component="main"
|
|
sx={{
|
|
flexGrow: 1, // Ocupa el espacio restante
|
|
p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo
|
|
overflowY: 'auto',
|
|
height: '100%', // Ocupa toda la altura del Box padre
|
|
bgcolor: 'grey.100' // Un color de fondo diferente para distinguir el área de contenido
|
|
}}
|
|
>
|
|
{/* El Outlet renderiza el componente del reporte específico */}
|
|
{(!location.pathname.startsWith('/reportes/') || !allReportModules.some(r => isReportActive(r.path))) && location.pathname !== '/reportes/' && location.pathname !== '/reportes' && !isLoadingInitialNavigation && (
|
|
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
|
|
El reporte solicitado no existe o la ruta no es válida.
|
|
</Typography>
|
|
)}
|
|
{(location.pathname === '/reportes/' || location.pathname === '/reportes') && !allReportModules.some(r => isReportActive(r.path)) && !isLoadingInitialNavigation && (
|
|
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
|
|
{allReportModules.length > 0 ? "Seleccione una categoría y un reporte del menú lateral." : "No hay reportes configurados."}
|
|
</Typography>
|
|
)}
|
|
<Outlet />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ReportesIndexPage; |