feat(Reportes): Refactoriza vista Dist. General y corrige totales PDF
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 5m35s

Frontend:
- Se refactoriza la página `ReporteListadoDistribucionGeneralPage.tsx` para reemplazar la tabla HTML estándar por el componente `DataGrid` de MUI X.
- Se implementa el cálculo y la visualización de una fila de totales para las tablas de "Resumen Diario" y "Promedios por Día", mejorando la legibilidad y consistencia con otros reportes.
- Se actualiza la exportación a Excel para incluir estas nuevas filas de totales.
- Se corrigen errores de tipado (TypeScript) relacionados con la importación de DTOs.

Backend:
- Se ajusta la lógica en `ListadoDistribucionGeneralViewModel.cs` para calcular correctamente la fila "General" de promedios en la exportación a PDF.
- Anteriormente, el promedio se calculaba incorrectamente dividiendo por el total de días del mes. Ahora, se calcula un promedio real basado únicamente en los días con actividad (tirada > 0), asegurando que los datos del PDF coincidan con los de la interfaz.
This commit is contained in:
2025-07-28 11:34:40 -03:00
parent 28c1b88a92
commit 7e4d3282fb
6 changed files with 249 additions and 96 deletions

View File

@@ -14,8 +14,6 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
public string MesConsultado { get; set; } = string.Empty;
public string FechaReporte { get; set; } = DateTime.Now.ToString("dd/MM/yyyy");
// --- PROPIEDAD PARA LOS TOTALES GENERALES DE PROMEDIOS ---
// Esta propiedad calcula los promedios generales basados en los datos del resumen mensual.
public ListadoDistribucionGeneralPromedioDiaDto? PromedioGeneral
{
get
@@ -24,21 +22,30 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{
return null;
}
// 1. Filtrar solo los días con actividad para no diluir el promedio.
var diasActivos = ResumenMensual.Where(r => r.CantidadTirada > 0).ToList();
// Contar solo los días con tirada > 0 para promediar correctamente
var diasConTirada = ResumenMensual.Count(d => d.CantidadTirada > 0);
if (diasConTirada == 0) return null;
if (!diasActivos.Any())
{
return null; // No hay días con actividad, no se puede calcular el promedio.
}
// 2. Usar el conteo de días activos como divisor.
var totalDiasActivos = diasActivos.Count;
return new ListadoDistribucionGeneralPromedioDiaDto
{
Dia = "General",
CantidadDias = diasConTirada,
PromedioTirada = (int)ResumenMensual.Average(r => r.CantidadTirada),
PromedioSinCargo = (int)ResumenMensual.Average(r => r.SinCargo),
PromedioPerdidos = (int)ResumenMensual.Average(r => r.Perdidos),
PromedioLlevados = (int)ResumenMensual.Average(r => r.Llevados),
PromedioDevueltos = (int)ResumenMensual.Average(r => r.Devueltos),
PromedioVendidos = (int)ResumenMensual.Average(r => r.Vendidos)
CantidadDias = totalDiasActivos,
// 3. Calcular el promedio real: Suma de valores / Cantidad de días activos.
// Se usa división entera para que coincida con el formato sin decimales.
PromedioTirada = diasActivos.Sum(r => r.CantidadTirada) / totalDiasActivos,
PromedioSinCargo = diasActivos.Sum(r => r.SinCargo) / totalDiasActivos,
PromedioPerdidos = diasActivos.Sum(r => r.Perdidos) / totalDiasActivos,
PromedioLlevados = diasActivos.Sum(r => r.Llevados) / totalDiasActivos,
PromedioDevueltos = diasActivos.Sum(r => r.Devueltos) / totalDiasActivos,
PromedioVendidos = diasActivos.Sum(r => r.Vendidos) / totalDiasActivos
};
}
}

View File

@@ -12,7 +12,6 @@ namespace GestionIntegral.Api.Services.Reportes
{
private readonly IReportesRepository _reportesRepository;
private readonly ILogger<ReportesService> _logger;
// No necesitas _connectionFactory aquí si toda la lógica de BD está en el repositorio.
public ReportesService(IReportesRepository reportesRepository, ILogger<ReportesService> logger)
{

View File

@@ -1,4 +1,3 @@
// src/pages/Reportes/ReporteListadoDistribucionCanillasPage.tsx
import React, { useState, useCallback } from 'react';
import {
Box, Typography, Paper, CircularProgress, Alert, Button

View File

@@ -1,16 +1,30 @@
import React, { useState, useCallback } from 'react';
import {
Box, Typography, Paper, CircularProgress, Alert, Button,
TableContainer, Table, TableHead, TableRow, TableCell, TableBody
Box, Typography, Paper, CircularProgress, Alert, Button
} from '@mui/material';
import { DataGrid, type GridColDef, GridFooterContainer } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
import reportesService from '../../services/Reportes/reportesService';
import type { ListadoDistribucionGeneralResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralResponseDto';
import type { ListadoDistribucionGeneralResumenDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralResumenDto';
import type { ListadoDistribucionGeneralPromedioDiaDto } from '../../models/dtos/Reportes/ListadoDistribucionGeneralPromedioDiaDto';
import SeleccionaReporteListadoDistribucionGeneral from './SeleccionaReporteListadoDistribucionGeneral';
import * as XLSX from 'xlsx';
import axios from 'axios';
interface ResumenDiarioExtendido extends ListadoDistribucionGeneralResumenDto {
id: string;
}
interface PromediosPorDiaExtendido extends ListadoDistribucionGeneralPromedioDiaDto {
id: string;
}
const ReporteListadoDistribucionGeneralPage: React.FC = () => {
const [reportData, setReportData] = useState<ListadoDistribucionGeneralResponseDto | null>(null);
const [resumenCalculado, setResumenCalculado] = useState<ResumenDiarioExtendido[]>([]);
const [promediosCalculado, setPromediosCalculado] = useState<PromediosPorDiaExtendido[]>([]);
const [loading, setLoading] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -18,12 +32,32 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
const [showParamSelector, setShowParamSelector] = useState(true);
const [currentParams, setCurrentParams] = useState<{
idPublicacion: number;
fechaDesde: string; // Primer día del mes
fechaHasta: string; // Último día del mes
nombrePublicacion?: string; // Para el nombre del archivo
mesAnioParaNombreArchivo?: string; // Para el nombre del archivo (ej. YYYY-MM)
fechaDesde: string;
fechaHasta: string;
nombrePublicacion?: string;
mesAnioParaNombreArchivo?: string;
} | null>(null);
const [totalesResumen, setTotalesResumen] = useState({
cantidadTirada: 0,
sinCargo: 0,
perdidos: 0,
llevados: 0,
devueltos: 0,
vendidos: 0,
});
const [totalesPromedios, setTotalesPromedios] = useState({
cantidadDias: 0,
promedioTirada: 0,
promedioSinCargo: 0,
promedioPerdidos: 0,
promedioLlevados: 0,
promedioDevueltos: 0,
promedioVendidos: 0,
});
const handleGenerarReporte = useCallback(async (params: {
idPublicacion: number;
fechaDesde: string;
@@ -32,19 +66,67 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
setLoading(true);
setError(null);
setApiErrorParams(null);
// Para el nombre del archivo y título del PDF
setReportData(null);
setResumenCalculado([]);
setPromediosCalculado([]);
setTotalesResumen({ cantidadTirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 });
setTotalesPromedios({ cantidadDias: 0, promedioTirada: 0, promedioSinCargo: 0, promedioPerdidos: 0, promedioLlevados: 0, promedioDevueltos: 0, promedioVendidos: 0 });
const pubService = (await import('../../services/Distribucion/publicacionService')).default;
const pubData = await pubService.getPublicacionById(params.idPublicacion);
const mesAnioParts = params.fechaDesde.split('-'); // YYYY-MM-DD -> [YYYY, MM, DD]
const mesAnioParts = params.fechaDesde.split('-');
const mesAnioNombre = `${mesAnioParts[1]}/${mesAnioParts[0]}`;
setCurrentParams({ ...params, nombrePublicacion: pubData?.nombre, mesAnioParaNombreArchivo: mesAnioNombre });
setCurrentParams({...params, nombrePublicacion: pubData?.nombre, mesAnioParaNombreArchivo: mesAnioNombre });
try {
const data = await reportesService.getListadoDistribucionGeneral(params);
const resumenConId = data.resumen.map((item, index) => ({
...item,
id: `resumen-${index}`
}));
setResumenCalculado(resumenConId);
const totalesResumenCalculados = data.resumen.reduce((acc, item) => ({
cantidadTirada: acc.cantidadTirada + item.cantidadTirada,
sinCargo: acc.sinCargo + item.sinCargo,
perdidos: acc.perdidos + item.perdidos,
llevados: acc.llevados + item.llevados,
devueltos: acc.devueltos + item.devueltos,
vendidos: acc.vendidos + item.vendidos,
}), { cantidadTirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 });
setTotalesResumen(totalesResumenCalculados);
const promediosConId = data.promediosPorDia.map((item, index) => ({
...item,
id: `promedio-${index}`
}));
setPromediosCalculado(promediosConId);
const totalDias = data.promediosPorDia.reduce((sum, item) => sum + item.cantidadDias, 0);
const promediosPonderados = data.promediosPorDia.reduce((acc, item) => ({
tirada: acc.tirada + item.promedioTirada * item.cantidadDias,
sinCargo: acc.sinCargo + item.promedioSinCargo * item.cantidadDias,
perdidos: acc.perdidos + item.promedioPerdidos * item.cantidadDias,
llevados: acc.llevados + item.promedioLlevados * item.cantidadDias,
devueltos: acc.devueltos + item.promedioDevueltos * item.cantidadDias,
vendidos: acc.vendidos + item.promedioVendidos * item.cantidadDias,
}), { tirada: 0, sinCargo: 0, perdidos: 0, llevados: 0, devueltos: 0, vendidos: 0 });
setTotalesPromedios({
cantidadDias: totalDias,
promedioTirada: totalDias > 0 ? promediosPonderados.tirada / totalDias : 0,
promedioSinCargo: totalDias > 0 ? promediosPonderados.sinCargo / totalDias : 0,
promedioPerdidos: totalDias > 0 ? promediosPonderados.perdidos / totalDias : 0,
promedioLlevados: totalDias > 0 ? promediosPonderados.llevados / totalDias : 0,
promedioDevueltos: totalDias > 0 ? promediosPonderados.devueltos / totalDias : 0,
promedioVendidos: totalDias > 0 ? promediosPonderados.vendidos / totalDias : 0,
});
setReportData(data);
if ((!data.resumen || data.resumen.length === 0) && (!data.promediosPorDia || data.promediosPorDia.length === 0)) {
if (resumenConId.length === 0 && promediosConId.length === 0) {
setError("No se encontraron datos para los parámetros seleccionados.");
}
setShowParamSelector(false);
@@ -65,17 +147,19 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
setError(null);
setApiErrorParams(null);
setCurrentParams(null);
setResumenCalculado([]);
setPromediosCalculado([]);
}, []);
const handleExportToExcel = useCallback(() => {
if (!reportData || (!reportData.resumen?.length && !reportData.promediosPorDia?.length)) {
if (!resumenCalculado.length && !promediosCalculado.length) {
alert("No hay datos para exportar.");
return;
}
const wb = XLSX.utils.book_new();
if (reportData.resumen?.length) {
const resumenToExport = reportData.resumen.map(item => ({
if (resumenCalculado.length) {
const resumenToExport = resumenCalculado.map(item => ({
"Fecha": item.fecha ? new Date(item.fecha).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-',
"Tirada": item.cantidadTirada,
"Sin Cargo": item.sinCargo,
@@ -84,12 +168,21 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
"Devueltos": item.devueltos,
"Vendidos": item.vendidos,
}));
resumenToExport.push({
"Fecha": "TOTALES",
"Tirada": totalesResumen.cantidadTirada,
"Sin Cargo": totalesResumen.sinCargo,
"Perdidos": totalesResumen.perdidos,
"Llevados": totalesResumen.llevados,
"Devueltos": totalesResumen.devueltos,
"Vendidos": totalesResumen.vendidos,
});
const wsResumen = XLSX.utils.json_to_sheet(resumenToExport);
XLSX.utils.book_append_sheet(wb, wsResumen, "ResumenDiario");
}
if (reportData.promediosPorDia?.length) {
const promediosToExport = reportData.promediosPorDia.map(item => ({
if (promediosCalculado.length) {
const promediosToExport = promediosCalculado.map(item => ({
"Día Semana": item.dia,
"Cant. Días": item.cantidadDias,
"Prom. Tirada": item.promedioTirada,
@@ -99,6 +192,16 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
"Prom. Devueltos": item.promedioDevueltos,
"Prom. Vendidos": item.promedioVendidos,
}));
promediosToExport.push({
"Día Semana": "GENERAL",
"Cant. Días": totalesPromedios.cantidadDias,
"Prom. Tirada": totalesPromedios.promedioTirada,
"Prom. Sin Cargo": totalesPromedios.promedioSinCargo,
"Prom. Perdidos": totalesPromedios.promedioPerdidos,
"Prom. Llevados": totalesPromedios.promedioLlevados,
"Prom. Devueltos": totalesPromedios.promedioDevueltos,
"Prom. Vendidos": totalesPromedios.promedioVendidos,
});
const wsPromedios = XLSX.utils.json_to_sheet(promediosToExport);
XLSX.utils.book_append_sheet(wb, wsPromedios, "PromediosPorDia");
}
@@ -110,7 +213,7 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
}
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
}, [reportData, currentParams]);
}, [resumenCalculado, promediosCalculado, currentParams, totalesResumen, totalesPromedios]);
const handleGenerarYAbrirPdf = useCallback(async () => {
if (!currentParams) {
@@ -122,8 +225,8 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
try {
const blob = await reportesService.getListadoDistribucionGeneralPdf({
idPublicacion: currentParams.idPublicacion,
fechaDesde: currentParams.fechaDesde, // El servicio y SP esperan fechaDesde para el mes/año
fechaHasta: currentParams.fechaHasta // El SP no usa esta, pero el servicio de reporte sí para el nombre
fechaDesde: currentParams.fechaDesde,
fechaHasta: currentParams.fechaHasta
});
if (blob.type === "application/json") {
const text = await blob.text();
@@ -141,6 +244,56 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
}
}, [currentParams]);
const columnsResumen: GridColDef[] = [
{ field: 'fecha', headerName: 'Fecha', flex: 0.8, minWidth: 100, valueFormatter: (value) => value ? new Date(value).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-' },
{ field: 'cantidadTirada', headerName: 'Tirada', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'sinCargo', headerName: 'Sin Cargo', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'perdidos', headerName: 'Perdidos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'llevados', headerName: 'Llevados', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'devueltos', headerName: 'Devueltos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'vendidos', headerName: 'Vendidos', type: 'number', flex: 1, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
];
const columnsPromedios: GridColDef[] = [
{ field: 'dia', headerName: 'Día Semana', flex: 1, minWidth: 130 },
{ field: 'cantidadDias', headerName: 'Cant. Días', type: 'number', flex: 0.8, minWidth: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR') },
{ field: 'promedioTirada', headerName: 'Prom. Tirada', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
{ field: 'promedioSinCargo', headerName: 'Prom. Sin Cargo', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
{ field: 'promedioPerdidos', headerName: 'Prom. Perdidos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
{ field: 'promedioLlevados', headerName: 'Prom. Llevados', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
{ field: 'promedioDevueltos', headerName: 'Prom. Devueltos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
{ field: 'promedioVendidos', headerName: 'Prom. Vendidos', type: 'number', flex: 1, minWidth: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => Number(value).toLocaleString('es-AR', { maximumFractionDigits: 0 }) },
];
const CustomFooterResumen = () => (
<GridFooterContainer>
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', width: '100%' }}>
<Typography variant="subtitle2" sx={{ minWidth: columnsResumen[0].minWidth, fontWeight: 'bold' }}>TOTALES:</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[1].flex, minWidth: columnsResumen[1].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.cantidadTirada.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[2].flex, minWidth: columnsResumen[2].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.sinCargo.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[3].flex, minWidth: columnsResumen[3].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.perdidos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[4].flex, minWidth: columnsResumen[4].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.llevados.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[5].flex, minWidth: columnsResumen[5].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.devueltos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsResumen[6].flex, minWidth: columnsResumen[6].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesResumen.vendidos.toLocaleString('es-AR')}</Typography>
</Box>
</GridFooterContainer>
);
const CustomFooterPromedios = () => (
<GridFooterContainer>
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', width: '100%' }}>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[0].flex, minWidth: columnsPromedios[0].minWidth, fontWeight: 'bold' }}>GENERAL:</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[1].flex, minWidth: columnsPromedios[1].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.cantidadDias.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[2].flex, minWidth: columnsPromedios[2].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioTirada.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[3].flex, minWidth: columnsPromedios[3].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioSinCargo.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[4].flex, minWidth: columnsPromedios[4].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioPerdidos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[5].flex, minWidth: columnsPromedios[5].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[6].flex, minWidth: columnsPromedios[6].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[7].flex, minWidth: columnsPromedios[7].minWidth, textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.promedioVendidos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
</Box>
</GridFooterContainer>
);
if (showParamSelector) {
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
@@ -179,70 +332,34 @@ const ReporteListadoDistribucionGeneralPage: React.FC = () => {
{!loading && !error && reportData && (
<>
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>Resumen Diario</Typography>
{reportData.resumen && reportData.resumen.length > 0 ? (
<TableContainer component={Paper} sx={{ maxHeight: '300px', mb: 3 }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Fecha</TableCell>
<TableCell align="right">Tirada</TableCell>
<TableCell align="right">Sin Cargo</TableCell>
<TableCell align="right">Perdidos</TableCell>
<TableCell align="right">Llevados</TableCell>
<TableCell align="right">Devueltos</TableCell>
<TableCell align="right">Vendidos</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reportData.resumen.map((row, idx) => (
<TableRow key={`resumen-${idx}`}>
<TableCell>{row.fecha ? new Date(row.fecha).toLocaleDateString('es-AR', { timeZone: 'UTC' }) : '-'}</TableCell>
<TableCell align="right">{row.cantidadTirada.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.sinCargo.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.perdidos.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.llevados.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.devueltos.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.vendidos.toLocaleString('es-AR')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (<Typography>No hay datos de resumen diario.</Typography>)}
{resumenCalculado.length > 0 ? (
<Paper sx={{ height: 450, width: '100%', mb: 3 }}>
<DataGrid
rows={resumenCalculado}
columns={columnsResumen}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
density="compact"
slots={{ footer: CustomFooterResumen }}
hideFooterPagination
hideFooterSelectedRowCount
/>
</Paper>
) : (<Typography sx={{ fontStyle: 'italic' }}>No hay datos de resumen diario.</Typography>)}
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>Promedios por Día de Semana</Typography>
{reportData.promediosPorDia && reportData.promediosPorDia.length > 0 ? (
<TableContainer component={Paper} sx={{ maxHeight: '300px' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Día</TableCell>
<TableCell align="right">Cant. Días</TableCell>
<TableCell align="right">Prom. Tirada</TableCell>
<TableCell align="right">Prom. Sin Cargo</TableCell>
<TableCell align="right">Prom. Perdidos</TableCell>
<TableCell align="right">Prom. Llevados</TableCell>
<TableCell align="right">Prom. Devueltos</TableCell>
<TableCell align="right">Prom. Vendidos</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reportData.promediosPorDia.map((row, idx) => (
<TableRow key={`promedio-${idx}`}>
<TableCell>{row.dia}</TableCell>
<TableCell align="right">{row.cantidadDias.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioTirada.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioSinCargo.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioPerdidos.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioLlevados.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioDevueltos.toLocaleString('es-AR')}</TableCell>
<TableCell align="right">{row.promedioVendidos.toLocaleString('es-AR')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (<Typography>No hay datos de promedios por día.</Typography>)}
{promediosCalculado.length > 0 ? (
<Paper sx={{ height: 400, width: '100%' }}>
<DataGrid
rows={promediosCalculado}
columns={columnsPromedios}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
density="compact"
slots={{ footer: CustomFooterPromedios }}
hideFooterPagination
hideFooterSelectedRowCount
/>
</Paper>
) : (<Typography sx={{ fontStyle: 'italic' }}>No hay datos de promedios por día.</Typography>)}
</>
)}
</Box>

View File

@@ -70,7 +70,7 @@ El sistema está organizado en varios módulos clave para cubrir todas las área
- **Acceso a Datos:** Dapper (Micro ORM)
- **Base de Datos:** Microsoft SQL Server
- **Autenticación:** JWT Bearer Tokens
- **Reportes PDF:** `Microsoft.ReportingServices.ReportViewerControl.WebForms` (a través de wrappers para .NET Core)
- **Reportes PDF:** QuestPDF C# PDF Generation Library
- **Exportación Excel:** NPOI
### Frontend
@@ -158,6 +158,8 @@ cd GestionIntegralWeb
### Backend (`Backend/GestionIntegral.Api/`)
```
/Controllers/ # Controladores de la API, organizados por módulo
Reportes/
PdfTemplates/ # Templates de los reportes utilizados mediante QuestPDF
/Data/
/Repositories/ # Lógica de acceso a datos con Dapper para cada entidad
DbConnectionFactory.cs # Clase para crear conexiones a la BD

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
# --- Servicio del Backend ---
api-gestion:
image: dmolinari/gestionintegralweb-backend:latest
restart: always
networks:
- shared-net
environment:
- DB_SA_PASSWORD=${DB_SA_PASSWORD}
- ConnectionStrings__DefaultConnection=Server=db-sqlserver;Database=SistemaGestion;User ID=sa;Password=${DB_SA_PASSWORD};TrustServerCertificate=True;
ports:
- "8081:8080"
# --- Servicio del Frontend ---
web-gestion:
image: dmolinari/gestionintegralweb-frontend:latest
restart: always
networks:
- shared-net
ports:
- "8080:80"
depends_on:
- api-gestion
networks:
# Nos conectamos a la red que creará el otro stack
shared-net:
external: true