Refinamiento de permisos y ajustes en controles. Añade gestión sobre saldos y visualización. Entre otros..

This commit is contained in:
2025-06-06 18:33:09 -03:00
parent 8fb94f8cef
commit 35e24ab7d2
104 changed files with 5917 additions and 1205 deletions

View File

@@ -7,6 +7,7 @@ import { Outlet, useNavigate, useLocation } from 'react-router-dom';
const contablesSubModules = [
{ label: 'Pagos Distribuidores', path: 'pagos-distribuidores' },
{ label: 'Notas Crédito/Débito', path: 'notas-cd' },
{ label: 'Gestión de Saldos', path: 'gestion-saldos' },
{ label: 'Tipos de Pago', path: 'tipos-pago' },
];

View File

@@ -56,7 +56,6 @@ const GestionarNotasCDPage: React.FC = () => {
const [selectedRow, setSelectedRow] = useState<NotaCreditoDebitoDto | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
// CN001 (Ver), CN002 (Crear), CN003 (Modificar), CN004 (Eliminar)
const puedeVer = isSuperAdmin || tienePermiso("CN001");
const puedeCrear = isSuperAdmin || tienePermiso("CN002");
const puedeModificar = isSuperAdmin || tienePermiso("CN003");

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Paper, IconButton, MenuItem,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, FormControl, InputLabel, Select
} from '@mui/material';
import FilterListIcon from '@mui/icons-material/FilterList';
import EditNoteIcon from '@mui/icons-material/EditNote'; // Icono para ajustar saldo
import saldoService from '../../services/Contables/saldoService';
import empresaService from '../../services/Distribucion/empresaService';
import distribuidorService from '../../services/Distribucion/distribuidorService';
import canillaService from '../../services/Distribucion/canillaService';
import type { SaldoGestionDto } from '../../models/dtos/Contables/SaldoGestionDto';
import type { AjusteSaldoRequestDto } from '../../models/dtos/Contables/AjusteSaldoRequestDto';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | '';
const GestionarSaldosPage: React.FC = () => {
const [saldos, setSaldos] = useState<SaldoGestionDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para modal
// Filtros
const [filtroTipoDestino, setFiltroTipoDestino] = useState<TipoDestinoFiltro>('');
const [filtroIdDestino, setFiltroIdDestino] = useState<number | string>('');
const [filtroIdEmpresa, setFiltroIdEmpresa] = useState<number | string>('');
const [destinatariosDropdown, setDestinatariosDropdown] = useState<(DistribuidorDto | CanillaDto)[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false);
const [modalAjusteOpen, setModalAjusteOpen] = useState(false);
const [saldoParaAjustar, setSaldoParaAjustar] = useState<SaldoGestionDto | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
// No necesitamos menú de acciones por fila si el ajuste es la única acción
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver
const puedeAjustarSaldos = isSuperAdmin || tienePermiso("CS002"); // Permiso para ajustar
const fetchDropdownData = useCallback(async () => {
setLoadingFiltersDropdown(true);
setError(null);
try {
const empData = await empresaService.getAllEmpresas();
setEmpresas(empData);
if (filtroTipoDestino === 'Distribuidores') {
const distData = await distribuidorService.getAllDistribuidores();
setDestinatariosDropdown(distData);
} else if (filtroTipoDestino === 'Canillas') {
const canData = await canillaService.getAllCanillas(undefined, undefined, true); // Solo activos
setDestinatariosDropdown(canData);
} else {
setDestinatariosDropdown([]);
}
} catch (err) {
console.error("Error cargando datos para filtros:", err);
setError("Error al cargar opciones de filtro.");
} finally {
setLoadingFiltersDropdown(false);
}
}, [filtroTipoDestino]);
useEffect(() => {
fetchDropdownData();
}, [fetchDropdownData]);
const cargarSaldos = useCallback(async () => {
if (!puedeVerSaldos) {
setError("No tiene permiso para ver saldos."); setLoading(false); return;
}
setLoading(true); setError(null); setApiErrorMessage(null);
try {
const params = {
destino: filtroTipoDestino || undefined, // Enviar undefined si está vacío
idDestino: filtroIdDestino ? Number(filtroIdDestino) : undefined,
idEmpresa: filtroIdEmpresa ? Number(filtroIdEmpresa) : undefined,
};
const data = await saldoService.getAllSaldosGestion(params);
setSaldos(data);
} catch (err) {
console.error("Error al cargar saldos:", err);
setError('Error al cargar los saldos.');
setSaldos([]);
} finally {
setLoading(false);
}
}, [puedeVerSaldos, filtroTipoDestino, filtroIdDestino, filtroIdEmpresa]);
useEffect(() => {
cargarSaldos();
}, [cargarSaldos]);
const handleOpenAjusteModal = (saldo: SaldoGestionDto) => {
if (!puedeAjustarSaldos) {
setApiErrorMessage("No tiene permiso para ajustar saldos.");
return;
}
setSaldoParaAjustar(saldo);
setApiErrorMessage(null);
setModalAjusteOpen(true);
};
const handleCloseAjusteModal = () => {
setModalAjusteOpen(false);
setSaldoParaAjustar(null);
};
const handleSubmitAjusteModal = async (data: AjusteSaldoRequestDto) => {
if (!puedeAjustarSaldos) return;
setApiErrorMessage(null);
try {
await saldoService.ajustarSaldo(data);
cargarSaldos(); // Recargar lista para ver el saldo actualizado
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al aplicar el ajuste de saldo.';
setApiErrorMessage(message);
throw err; // Para que el modal sepa que hubo error
}
};
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10)); setPage(0);
};
const displayData = saldos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
const formatDate = (dateString?: string | null) => dateString ? new Date(dateString).toLocaleString('es-AR', {timeZone:'UTC'}) : '-';
const formatCurrency = (value: number) => value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' });
if (!loading && !puedeVerSaldos) {
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado a esta sección."}</Alert></Box>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestión de Saldos</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 }}>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}>
<InputLabel>Tipo Destinatario</InputLabel>
<Select value={filtroTipoDestino} label="Tipo Destinatario"
onChange={(e) => {
setFiltroTipoDestino(e.target.value as TipoDestinoFiltro);
setFiltroIdDestino(''); // Resetear destinatario al cambiar tipo
}}>
<MenuItem value=""><em>Todos</em></MenuItem>
<MenuItem value="Distribuidores">Distribuidores</MenuItem>
<MenuItem value="Canillas">Canillitas</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingFiltersDropdown || !filtroTipoDestino}>
<InputLabel>Destinatario Específico</InputLabel>
<Select value={filtroIdDestino} label="Destinatario Específico"
onChange={(e) => setFiltroIdDestino(e.target.value as number | string)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{destinatariosDropdown.map(d => (
<MenuItem key={'idDistribuidor' in d ? d.idDistribuidor : d.idCanilla} value={'idDistribuidor' in d ? d.idDistribuidor : d.idCanilla}>
{'nomApe' in d ? d.nomApe : d.nombre}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
<InputLabel>Empresa</InputLabel>
<Select value={filtroIdEmpresa} label="Empresa"
onChange={(e) => setFiltroIdEmpresa(e.target.value as number | string)}>
<MenuItem value=""><em>Todas</em></MenuItem>
{empresas.map(e => <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>)}
</Select>
</FormControl>
</Box>
{/* No hay botón de "Agregar Saldo", se crean automáticamente */}
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && puedeVerSaldos && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell sx={{fontWeight:'bold'}}>Destinatario</TableCell>
<TableCell sx={{fontWeight:'bold'}}>Tipo</TableCell>
<TableCell sx={{fontWeight:'bold'}}>Empresa</TableCell>
<TableCell align="right" sx={{fontWeight:'bold'}}>Monto Saldo</TableCell>
<TableCell sx={{fontWeight:'bold'}}>Últ. Modificación</TableCell>
{puedeAjustarSaldos && <TableCell align="right" sx={{fontWeight:'bold'}}>Acciones</TableCell>}
</TableRow></TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={puedeAjustarSaldos ? 6 : 5} align="center">No se encontraron saldos.</TableCell></TableRow>
) : (
displayData.map((s) => (
<TableRow key={s.idSaldo} hover
sx={{ backgroundColor: s.monto < 0 ? 'rgba(255, 0, 0, 0.05)' : (s.monto > 0 ? 'rgba(0, 255, 0, 0.05)' : 'inherit')}}
>
<TableCell>{s.nombreDestinatario}</TableCell>
<TableCell>{s.destino}</TableCell>
<TableCell>{s.nombreEmpresa}</TableCell>
<TableCell align="right" sx={{fontWeight:500}}>{formatCurrency(s.monto)}</TableCell>
<TableCell>{formatDate(s.fechaUltimaModificacion)}</TableCell>
{puedeAjustarSaldos && (
<TableCell align="right">
<IconButton size="small" onClick={() => handleOpenAjusteModal(s)} color="primary">
<EditNoteIcon fontSize="small"/>
</IconButton>
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={saldos.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
{saldoParaAjustar &&
<AjusteSaldoModal
open={modalAjusteOpen}
onClose={handleCloseAjusteModal}
onSubmit={handleSubmitAjusteModal}
saldoParaAjustar={saldoParaAjustar}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
}
</Box>
);
};
export default GestionarSaldosPage;

View File

@@ -1,4 +1,3 @@
// src/pages/configuracion/GestionarTiposPagoPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem,
@@ -7,10 +6,10 @@ import {
ListItemIcon,
ListItemText
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; // Icono para agregar
import MoreVertIcon from '@mui/icons-material/MoreVert'; // Icono para más opciones
import EditIcon from '@mui/icons-material/Edit'; // Icono para modificar
import DeleteIcon from '@mui/icons-material/Delete'; // Icono para eliminar
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import tipoPagoService from '../../services/Contables/tipoPagoService';
import type { TipoPago } from '../../models/Entities/TipoPago';
import type { CreateTipoPagoDto } from '../../models/dtos/Contables/CreateTipoPagoDto';
@@ -30,20 +29,26 @@ const GestionarTiposPagoPage: React.FC = () => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const [rowsPerPage, setRowsPerPage] = useState(25); // Cambiado a un valor más común
// Para el menú contextual de cada fila
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedTipoPagoRow, setSelectedTipoPagoRow] = useState<TipoPago | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions(); // Obtener también isSuperAdmin
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso("CT001"); // << AÑADIR ESTA LÍNEA
const puedeCrear = isSuperAdmin || tienePermiso("CT002");
const puedeModificar = isSuperAdmin || tienePermiso("CT003");
const puedeEliminar = isSuperAdmin || tienePermiso("CT004");
const cargarTiposPago = useCallback(async () => {
if (!puedeVer) { // << AÑADIR CHEQUEO DE PERMISO AQUÍ
setError("No tiene permiso para ver los tipos de pago.");
setLoading(false);
setTiposPago([]); // Asegurar que no se muestren datos previos
return;
}
setLoading(true);
setError(null);
try {
@@ -55,7 +60,7 @@ const GestionarTiposPagoPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [filtroNombre]);
}, [filtroNombre, puedeVer]); // << AÑADIR puedeVer A LAS DEPENDENCIAS
useEffect(() => {
cargarTiposPago();
@@ -73,15 +78,15 @@ const GestionarTiposPagoPage: React.FC = () => {
};
const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => {
setApiErrorMessage(null); // Limpiar error previo
setApiErrorMessage(null);
try {
if (editingTipoPago && 'idTipoPago' in data) { // Es Update
if (editingTipoPago && editingTipoPago.idTipoPago) {
await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto);
} else { // Es Create
} else {
await tipoPagoService.createTipoPago(data as CreateTipoPagoDto);
}
cargarTiposPago(); // Recargar lista
// onClose se llama desde el modal en caso de éxito
cargarTiposPago();
// onClose se llama desde el modal si todo va bien
} catch (err: any) {
console.error("Error en submit modal (padre):", err);
if (axios.isAxiosError(err) && err.response) {
@@ -89,11 +94,12 @@ const GestionarTiposPagoPage: React.FC = () => {
} else {
setApiErrorMessage('Ocurrió un error inesperado al guardar.');
}
throw err; // Re-lanzar para que el modal sepa que hubo error y no se cierre
throw err;
}
};
const handleDelete = async (id: number) => {
// ... (sin cambios)
if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) {
setApiErrorMessage(null);
try {
@@ -126,12 +132,24 @@ const GestionarTiposPagoPage: React.FC = () => {
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 25));
setRowsPerPage(parseInt(event.target.value, 10)); // << CORREGIDO: base 10, no 25
setPage(0);
};
const displayData = tiposPago.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
// Renderizado condicional si no tiene permiso para ver
if (!loading && !puedeVer) { // << AÑADIR ESTE BLOQUE
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>
Gestionar Tipos de Pago
</Typography>
<Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert>
</Box>
);
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>
@@ -146,10 +164,8 @@ const GestionarTiposPagoPage: React.FC = () => {
size="small"
value={filtroNombre}
onChange={(e) => setFiltroNombre(e.target.value)}
// sx={{ flexGrow: 1 }} // Opcional, para que ocupe más espacio
disabled={!puedeVer || loading} // Deshabilitar si no puede ver o está cargando
/>
{/* El botón de búsqueda se activa al cambiar el texto, pero puedes añadir uno explícito */}
{/* <Button variant="contained" onClick={cargarTiposPago}>Buscar</Button> */}
</Box>
{puedeCrear && (
<Button
@@ -164,43 +180,50 @@ const GestionarTiposPagoPage: React.FC = () => {
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{/* Mostrar error de carga si no es un error de "sin permiso" y no hay error de API */}
{error && !loading && puedeVer && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && (
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true para mostrar la tabla
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Nombre</TableCell>
<TableCell>Detalle</TableCell>
<TableCell align="right">Acciones</TableCell>
{/* Mostrar columna de acciones solo si tiene algún permiso de acción */}
{(puedeModificar || puedeEliminar) && <TableCell align="right">Acciones</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 && !loading ? (
<TableRow><TableCell colSpan={3} align="center">No se encontraron tipos de pago.</TableCell></TableRow>
<TableRow><TableCell colSpan={(puedeModificar || puedeEliminar) ? 3 : 2} align="center">
No se encontraron tipos de pago.
</TableCell>
</TableRow>
) : (
displayData.map((tipo) => (
<TableRow key={tipo.idTipoPago}>
<TableCell>{tipo.nombre}</TableCell>
<TableCell>{tipo.detalle || '-'}</TableCell>
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, tipo)}
disabled={!puedeModificar && !puedeEliminar}
>
<MoreVertIcon />
</IconButton>
</TableCell>
{(puedeModificar || puedeEliminar) && (
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, tipo)}
disabled={!puedeModificar && !puedeEliminar}
>
<MoreVertIcon />
</IconButton>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]}
rowsPerPageOptions={[25, 50, 100]} // Opciones más estándar
component="div"
count={tiposPago.length}
rowsPerPage={rowsPerPage}
@@ -217,20 +240,19 @@ const GestionarTiposPagoPage: React.FC = () => {
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
{puedeModificar && (
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow!); handleMenuClose(); }}>
{puedeModificar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Modificar</ListItemText>
</MenuItem>
)}
{puedeEliminar && (
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow!.idTipoPago)}>
{puedeEliminar && selectedTipoPagoRow && ( // Asegurar que selectedTipoPagoRow no sea null
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow.idTipoPago)}>
<ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon>
<ListItemText>Eliminar</ListItemText>
</MenuItem>
)}
{/* Si no tiene ningún permiso, el menú podría estar vacío o no mostrarse */}
{(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
{selectedTipoPagoRow && (!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu>
<TipoPagoFormModal

View File

@@ -1,4 +1,3 @@
// src/pages/distribucion/DistribucionIndexPage.tsx
import React, { useState, useEffect } from 'react';
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';

View File

@@ -1,14 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, Chip, FormControlLabel
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, Chip, FormControlLabel, ListItemIcon, ListItemText // << AÑADIR ListItemIcon, ListItemText
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
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
import { useNavigate } from 'react-router-dom'; // << AÑADIR IMPORTACIÓN DE useNavigate
import canillaService from '../../services/Distribucion/canillaService';
import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import type { CreateCanillaDto } from '../../models/dtos/Distribucion/CreateCanillaDto';
@@ -31,17 +34,24 @@ const GestionarCanillitasPage: React.FC = () => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const [rowsPerPage, setRowsPerPage] = useState(25); // << CAMBIADO DE 5 a 25 (valor más común)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedCanillitaRow, setSelectedCanillitaRow] = useState<CanillaDto | null>(null);
const navigate = useNavigate(); // << INICIALIZAR useNavigate
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso("CG001");
const puedeCrear = isSuperAdmin || tienePermiso("CG002");
const puedeModificar = isSuperAdmin || tienePermiso("CG003");
// CG004 para Porcentajes/Montos, se gestionará por separado.
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 puedeVerNovedadesCanilla = puedeVer || puedeGestionarNovedades; // << LÓGICA PARA MOSTRAR LA OPCIÓN
const cargarCanillitas = useCallback(async () => {
if (!puedeVer) {
@@ -51,12 +61,12 @@ const GestionarCanillitasPage: React.FC = () => {
}
setLoading(true); setError(null); setApiErrorMessage(null);
try {
const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 25) : undefined;
const legajoNum = filtroLegajo ? parseInt(filtroLegajo, 10) : undefined; // << CORREGIDO: parseInt con base 10
if (filtroLegajo && isNaN(legajoNum!)) {
setApiErrorMessage("Legajo debe ser un número.");
setCanillitas([]); // Limpiar resultados si el filtro es inválido
setLoading(false);
return;
setApiErrorMessage("Legajo debe ser un número.");
setCanillitas([]);
setLoading(false);
return;
}
const data = await canillaService.getAllCanillas(filtroNomApe, legajoNum, filtroSoloActivos);
setCanillitas(data);
@@ -83,6 +93,7 @@ const GestionarCanillitasPage: React.FC = () => {
await canillaService.createCanilla(data as CreateCanillaDto);
}
cargarCanillitas();
// No es necesario llamar a handleCloseModal aquí si el modal se cierra solo en éxito
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el canillita.';
setApiErrorMessage(message); throw err;
@@ -93,17 +104,22 @@ const GestionarCanillitasPage: React.FC = () => {
setApiErrorMessage(null);
const accion = canillita.baja ? "reactivar" : "dar de baja";
if (window.confirm(`¿Está seguro de que desea ${accion} a ${canillita.nomApe}?`)) {
try {
await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja });
cargarCanillitas();
} catch (err:any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`;
setApiErrorMessage(message);
}
try {
await canillaService.toggleBajaCanilla(canillita.idCanilla, { darDeBaja: !canillita.baja });
cargarCanillitas();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el canillita.`;
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleOpenNovedades = (idCan: number) => {
navigate(`/distribucion/canillas/${idCan}/novedades`);
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, canillita: CanillaDto) => {
setAnchorEl(event.currentTarget); setSelectedCanillitaRow(canillita);
};
@@ -118,98 +134,120 @@ const GestionarCanillitasPage: React.FC = () => {
const displayData = canillitas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!loading && !puedeVer) {
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso."}</Alert></Box>;
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert></Box>; // Mensaje más genérico
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Canillitas</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Filtrar por Nombre/Apellido"
variant="outlined"
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
label="Filtrar por Nombre/Apellido"
variant="outlined"
size="small"
value={filtroNomApe}
onChange={(e) => setFiltroNomApe(e.target.value)}
sx={{ flex: 2, minWidth: '250px' }}
/>
<TextField
label="Filtrar por Legajo"
type="number" // Mantener como number para el input, la conversión se hace al usarlo
variant="outlined"
size="small"
value={filtroLegajo}
onChange={(e) => setFiltroLegajo(e.target.value)}
sx={{ flex: 1, minWidth: '150px' }}
/>
<FormControlLabel
control={
<Switch
checked={filtroSoloActivos === undefined ? true : filtroSoloActivos} // Default a true
onChange={(e) => setFiltroSoloActivos(e.target.checked)}
size="small"
value={filtroNomApe}
onChange={(e) => setFiltroNomApe(e.target.value)}
sx={{ flex: 2, minWidth: '250px' }} // Dar más espacio al nombre
/>
<TextField
label="Filtrar por Legajo"
type="number"
variant="outlined"
size="small"
value={filtroLegajo}
onChange={(e) => setFiltroLegajo(e.target.value)}
sx={{ flex: 1, minWidth: '150px' }}
/>
<FormControlLabel
control={
<Switch
checked={filtroSoloActivos === undefined ? true : filtroSoloActivos}
onChange={(e) => setFiltroSoloActivos(e.target.checked)}
size="small"
/>
}
label="Ver Activos"
sx={{ flexShrink: 0 }} // Para que el label no se comprima demasiado
/>
{/* <Button variant="contained" onClick={cargarCanillitas} size="small">Buscar</Button> */}
</Box>
{puedeCrear && (
/>
}
label="Ver Activos" // Cambiado el label para más claridad
sx={{ flexShrink: 0 }}
/>
</Box>
{puedeCrear && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Canillita</Button>
)}
)}
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{my: 2}}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>}
{/* Mostrar error general si no hay error de API específico */}
{error && !apiErrorMessage && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell>
<TableCell>Zona</TableCell><TableCell>Empresa</TableCell>
<TableCell>Accionista</TableCell><TableCell>Estado</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow></TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No se encontraron canillitas.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}>
<TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</TableCell>
<TableCell>{c.nombreZona}</TableCell><TableCell>{c.empresa === 0 ? '-' : c.nombreEmpresa}</TableCell>
<TableCell>{c.accionista ? <Chip label="Sí" color="success" size="small" variant="outlined"/> : <Chip label="No" color="default" size="small" variant="outlined"/>}</TableCell>
<TableCell>{c.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, c)} disabled={!puedeModificar && !puedeDarBaja}>
<MoreVertIcon />
{!loading && !error && puedeVer && ( // Asegurar que puedeVer sea true también aquí
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Legajo</TableCell><TableCell>Nombre y Apellido</TableCell>
<TableCell>Zona</TableCell><TableCell>Empresa</TableCell>
<TableCell>Accionista</TableCell><TableCell>Estado</TableCell>
{/* Mostrar acciones solo si tiene algún permiso para el menú */}
{(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && <TableCell align="right">Acciones</TableCell>}
</TableRow></TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) ? 7 : 6} align="center">No se encontraron canillitas.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCanilla} hover sx={{ backgroundColor: c.baja ? '#ffebee' : 'inherit' }}>
<TableCell>{c.legajo || '-'}</TableCell><TableCell>{c.nomApe}</TableCell>
<TableCell>{c.nombreZona}</TableCell><TableCell>{c.empresa === 0 ? '-' : c.nombreEmpresa}</TableCell>
<TableCell>{c.accionista ? <Chip label="" color="success" size="small" variant="outlined" /> : <Chip label="No" color="default" size="small" variant="outlined" />}</TableCell>
<TableCell>{c.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell>
{(puedeModificar || puedeDarBaja || puedeVerNovedadesCanilla) && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, c)}
// Deshabilitar si NO tiene NINGUNO de los permisos para las acciones del menú
disabled={!puedeModificar && !puedeDarBaja && !puedeVerNovedadesCanilla}
>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={canillitas.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={canillitas.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && (<MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow!); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
{puedeDarBaja && selectedCanillitaRow && (
<MenuItem onClick={() => handleToggleBaja(selectedCanillitaRow)}>
{selectedCanillitaRow.baja ? <ToggleOnIcon sx={{mr:1}}/> : <ToggleOffIcon sx={{mr:1}}/>}
{selectedCanillitaRow.baja ? 'Reactivar' : 'Dar de Baja'}
</MenuItem>
{/* Mostrar opción de Novedades si tiene permiso de ver canillitas o gestionar novedades */}
{puedeVerNovedadesCanilla && selectedCanillitaRow && (
<MenuItem onClick={() => handleOpenNovedades(selectedCanillitaRow.idCanilla)}>
<ListItemIcon><EventNoteIcon /></ListItemIcon>
<ListItemText>Novedades</ListItemText>
</MenuItem>
)}
{puedeModificar && selectedCanillitaRow && ( // Asegurar que selectedCanillitaRow existe
<MenuItem onClick={() => { handleOpenModal(selectedCanillitaRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Modificar</ListItemText>
</MenuItem>
)}
{puedeDarBaja && selectedCanillitaRow && (
<MenuItem onClick={() => handleToggleBaja(selectedCanillitaRow)}>
<ListItemIcon>{selectedCanillitaRow.baja ? <ToggleOnIcon /> : <ToggleOffIcon />}</ListItemIcon>
<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>
)}
{(!puedeModificar && !puedeDarBaja) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu>
<CanillaFormModal

View File

@@ -146,8 +146,8 @@ const GestionarEmpresasPage: React.FC = () => {
// Si no tiene permiso para ver, mostrar mensaje y salir
if (!loading && !puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="h4" gutterBottom>Gestionar Empresas</Typography>
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Empresas</Typography>
<Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert>
</Box>
);

View File

@@ -3,7 +3,8 @@ import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert, FormControl, InputLabel, Select, Checkbox, Tooltip,
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle
Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle,
ToggleButtonGroup, ToggleButton
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import PrintIcon from '@mui/icons-material/Print';
@@ -19,7 +20,7 @@ import canillaService from '../../services/Distribucion/canillaService';
import type { EntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/EntradaSalidaCanillaDto';
import type { UpdateEntradaSalidaCanillaDto } from '../../models/dtos/Distribucion/UpdateEntradaSalidaCanillaDto';
import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto';
import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto';
import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import type { LiquidarMovimientosCanillaRequestDto } from '../../models/dtos/Distribucion/LiquidarMovimientosCanillaDto';
@@ -28,25 +29,32 @@ import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import reportesService from '../../services/Reportes/reportesService';
type TipoDestinatarioFiltro = 'canillitas' | 'accionistas';
const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const [movimientos, setMovimientos] = useState<EntradaSalidaCanillaDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // Para carga principal de movimientos
const [error, setError] = useState<string | null>(null); // Error general o de carga
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para errores de modal/API
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFecha, setFiltroFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroIdPublicacion, setFiltroIdPublicacion] = useState<number | string>('');
const [filtroIdCanilla, setFiltroIdCanilla] = useState<number | string>('');
const [filtroEstadoLiquidacion, setFiltroEstadoLiquidacion] = useState<'todos' | 'liquidados' | 'noLiquidados'>('noLiquidados');
const [loadingTicketPdf, setLoadingTicketPdf] = useState(false);
const [filtroIdCanillitaSeleccionado, setFiltroIdCanillitaSeleccionado] = useState<number | string>('');
const [filtroTipoDestinatario, setFiltroTipoDestinatario] = useState<TipoDestinatarioFiltro>('canillitas');
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [canillitas, setCanillitas] = useState<CanillaDto[]>([]);
const [loadingTicketPdf, setLoadingTicketPdf] = useState(false);
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
const [destinatariosDropdown, setDestinatariosDropdown] = useState<CanillaDto[]>([]);
const [loadingFiltersDropdown, setLoadingFiltersDropdown] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingMovimiento, setEditingMovimiento] = useState<EntradaSalidaCanillaDto | null>(null);
const [prefillModalData, setPrefillModalData] = useState<{
fecha?: string;
idCanilla?: number | string;
nombreCanilla?: string; // << AÑADIDO PARA PASAR AL MODAL
idPublicacion?: number | string;
} | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
@@ -64,70 +72,123 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const puedeLiquidar = isSuperAdmin || tienePermiso("MC005");
const puedeEliminarLiquidados = isSuperAdmin || tienePermiso("MC006");
// Función para formatear fechas YYYY-MM-DD a DD/MM/YYYY
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]}`;
}
if (parts.length === 3) { return `${parts[2]}/${parts[1]}/${parts[0]}`; }
return datePart;
};
const fetchFiltersDropdownData = useCallback(async () => {
setLoadingFiltersDropdown(true);
try {
const [pubsData, canData] = await Promise.all([
publicacionService.getAllPublicaciones(undefined, undefined, true),
canillaService.getAllCanillas(undefined, undefined, true)
]);
setPublicaciones(pubsData);
setCanillitas(canData);
} catch (err) {
console.error(err); setError("Error al cargar opciones de filtro.");
} finally { setLoadingFiltersDropdown(false); }
useEffect(() => {
const fetchPublicaciones = async () => {
setLoadingFiltersDropdown(true); // Mover al inicio de la carga de pubs
try {
const pubsData = await publicacionService.getPublicacionesForDropdown(true);
setPublicaciones(pubsData);
} catch (err) {
console.error("Error cargando publicaciones para filtro:",err);
setError("Error al cargar publicaciones."); // Usar error general
} finally {
// No poner setLoadingFiltersDropdown(false) aquí, esperar a que ambas cargas terminen
}
};
fetchPublicaciones();
}, []);
useEffect(() => { fetchFiltersDropdownData(); }, [fetchFiltersDropdownData]);
const fetchDestinatariosParaDropdown = useCallback(async () => {
setLoadingFiltersDropdown(true); // Poner al inicio de esta carga también
setFiltroIdCanillitaSeleccionado('');
setDestinatariosDropdown([]);
setError(null); // Limpiar errores de carga de dropdowns previos
try {
const esAccionistaFilter = filtroTipoDestinatario === 'accionistas';
const data = await canillaService.getAllCanillas(undefined, undefined, true, esAccionistaFilter);
setDestinatariosDropdown(data);
} catch (err) {
console.error("Error cargando destinatarios para filtro:", err);
setError("Error al cargar canillitas/accionistas."); // Usar error general
} finally {
setLoadingFiltersDropdown(false); // Poner al final de AMBAS cargas de dropdown
}
}, [filtroTipoDestinatario]);
useEffect(() => {
fetchDestinatariosParaDropdown();
}, [fetchDestinatariosParaDropdown]);
const cargarMovimientos = useCallback(async () => {
if (!puedeVer) { setError("No tiene permiso."); setLoading(false); return; }
if (!puedeVer) { setError("No tiene permiso para ver esta sección."); setLoading(false); return; }
if (!filtroFecha || !filtroIdCanillitaSeleccionado) {
if (loading) setLoading(false);
setMovimientos([]);
return;
}
setLoading(true); setError(null); setApiErrorMessage(null);
try {
let liquidadosFilter: boolean | null = null;
let incluirNoLiquidadosFilter: boolean | null = true; // Por defecto mostrar no liquidados
if (filtroEstadoLiquidacion === 'liquidados') {
liquidadosFilter = true;
incluirNoLiquidadosFilter = false;
} else if (filtroEstadoLiquidacion === 'noLiquidados') {
liquidadosFilter = false;
incluirNoLiquidadosFilter = true;
} // Si es 'todos', ambos son null o true y false respectivamente (backend debe manejarlo)
const params = {
fechaDesde: filtroFechaDesde || null, fechaHasta: filtroFechaHasta || null,
fechaDesde: filtroFecha,
fechaHasta: filtroFecha,
idPublicacion: filtroIdPublicacion ? Number(filtroIdPublicacion) : null,
idCanilla: filtroIdCanilla ? Number(filtroIdCanilla) : null,
liquidados: liquidadosFilter,
incluirNoLiquidados: filtroEstadoLiquidacion === 'todos' ? null : incluirNoLiquidadosFilter,
idCanilla: Number(filtroIdCanillitaSeleccionado),
liquidados: null,
incluirNoLiquidados: null,
};
const data = await entradaSalidaCanillaService.getAllEntradasSalidasCanilla(params);
setMovimientos(data);
setSelectedIdsParaLiquidar(new Set()); // Limpiar selección al recargar
setSelectedIdsParaLiquidar(new Set());
} catch (err) {
console.error(err); setError('Error al cargar movimientos.');
} finally { setLoading(false); }
}, [puedeVer, filtroFechaDesde, filtroFechaHasta, filtroIdPublicacion, filtroIdCanilla, filtroEstadoLiquidacion]);
console.error("Error al cargar movimientos:", err);
setError('Error al cargar movimientos.');
setMovimientos([]);
} finally {
setLoading(false);
}
}, [puedeVer, filtroFecha, filtroIdPublicacion, filtroIdCanillitaSeleccionado]);
useEffect(() => {
if (filtroFecha && filtroIdCanillitaSeleccionado) {
cargarMovimientos();
} else {
setMovimientos([]);
if (loading) setLoading(false); // Asegurar que no se quede en loading si los filtros se limpian
}
}, [cargarMovimientos, filtroFecha, filtroIdCanillitaSeleccionado]); // `cargarMovimientos` ya tiene sus dependencias
useEffect(() => { cargarMovimientos(); }, [cargarMovimientos]);
const handleOpenModal = (item?: EntradaSalidaCanillaDto) => {
setEditingMovimiento(item || null); setApiErrorMessage(null); setModalOpen(true);
if (!puedeCrear && !item) {
setApiErrorMessage("No tiene permiso para registrar nuevos movimientos.");
return;
}
if (item && !puedeModificar) {
setApiErrorMessage("No tiene permiso para modificar movimientos.");
return;
}
if (item) {
setEditingMovimiento(item);
setPrefillModalData(null);
} else {
// --- CAMBIO: Obtener nombre del canillita seleccionado para prefill ---
const canillitaSeleccionado = destinatariosDropdown.find(
c => c.idCanilla === Number(filtroIdCanillitaSeleccionado)
);
setEditingMovimiento(null);
setPrefillModalData({
fecha: filtroFecha,
idCanilla: filtroIdCanillitaSeleccionado,
nombreCanilla: canillitaSeleccionado?.nomApe, // << AÑADIR NOMBRE
idPublicacion: filtroIdPublicacion
});
}
setApiErrorMessage(null);
setModalOpen(true);
};
// ... handleDelete, handleMenuOpen, handleMenuClose, handleSelectRowForLiquidar, handleSelectAllForLiquidar, handleOpenLiquidarDialog, handleCloseLiquidarDialog sin cambios ...
const handleDelete = async (idParte: number) => {
if (window.confirm(`¿Seguro de eliminar este movimiento (ID: ${idParte})?`)) {
setApiErrorMessage(null);
@@ -138,7 +199,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: EntradaSalidaCanillaDto) => {
// Almacenar el idParte en el propio elemento del menú para referencia
event.currentTarget.setAttribute('data-rowid', item.idParte.toString());
setAnchorEl(event.currentTarget);
setSelectedRow(item);
@@ -154,7 +214,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
});
};
const handleSelectAllForLiquidar = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
if (event.target.checked) {
const newSelectedIds = new Set(movimientos.filter(m => !m.liquidado).map(m => m.idParte));
setSelectedIdsParaLiquidar(newSelectedIds);
} else {
@@ -170,74 +230,65 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
setOpenLiquidarDialog(true);
};
const handleCloseLiquidarDialog = () => setOpenLiquidarDialog(false);
const handleConfirmLiquidar = async () => {
if (selectedIdsParaLiquidar.size === 0) {
setApiErrorMessage("No hay movimientos seleccionados para liquidar.");
return;
}
if (!fechaLiquidacionDialog) {
setApiErrorMessage("Debe seleccionar una fecha de liquidación.");
return;
}
// --- VALIDACIÓN DE FECHA ---
const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z'); // Usar Z para consistencia con formatDate si es necesario, o T00:00:00 para local
if (selectedIdsParaLiquidar.size === 0) { /* ... */ return; }
if (!fechaLiquidacionDialog) { /* ... */ return; }
// ... (validación de fecha sin cambios)
const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z');
let fechaMovimientoMasReciente: Date | null = null;
selectedIdsParaLiquidar.forEach(idParte => {
const movimiento = movimientos.find(m => m.idParte === idParte);
if (movimiento && movimiento.fecha) { // Asegurarse que movimiento.fecha existe
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z'); // Consistencia con Z
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime()
if (movimiento && movimiento.fecha) {
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z');
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) {
fechaMovimientoMasReciente = movFecha;
}
}
});
if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) { // Comparar usando getTime()
if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) {
setApiErrorMessage(`La fecha de liquidación (${fechaLiquidacionDate.toLocaleDateString('es-AR', {timeZone: 'UTC'})}) no puede ser inferior a la fecha del movimiento más reciente a liquidar (${(fechaMovimientoMasReciente as Date).toLocaleDateString('es-AR', {timeZone: 'UTC'})}).`);
return;
}
setApiErrorMessage(null);
setLoading(true); // Usar el loading general para la operación de liquidar
setLoading(true);
const liquidarDto: LiquidarMovimientosCanillaRequestDto = {
idsPartesALiquidar: Array.from(selectedIdsParaLiquidar),
fechaLiquidacion: fechaLiquidacionDialog // El backend espera YYYY-MM-DD
fechaLiquidacion: fechaLiquidacionDialog
};
try {
await entradaSalidaCanillaService.liquidarMovimientos(liquidarDto);
setOpenLiquidarDialog(false);
setOpenLiquidarDialog(false);
const primerIdParteLiquidado = Array.from(selectedIdsParaLiquidar)[0];
const movimientoParaTicket = movimientos.find(m => m.idParte === primerIdParteLiquidado);
await cargarMovimientos();
await cargarMovimientos();
if (movimientoParaTicket) {
console.log("Liquidación exitosa, intentando generar ticket para canillita:", movimientoParaTicket.idCanilla);
// --- CAMBIO: NO IMPRIMIR TICKET SI ES ACCIONISTA ---
if (movimientoParaTicket && !movimientoParaTicket.canillaEsAccionista) {
console.log("Liquidación exitosa, generando ticket para canillita NO accionista:", movimientoParaTicket.idCanilla);
await handleImprimirTicketLiquidacion(
movimientoParaTicket.idCanilla,
fechaLiquidacionDialog,
movimientoParaTicket.canillaEsAccionista
fechaLiquidacionDialog,
false // esAccionista = false
);
} else if (movimientoParaTicket && movimientoParaTicket.canillaEsAccionista) {
console.log("Liquidación exitosa para accionista. No se genera ticket automáticamente.");
} else {
console.warn("No se pudo encontrar información del movimiento para generar el ticket post-liquidación.");
console.warn("No se pudo encontrar información del movimiento para ticket post-liquidación.");
}
} catch (err: any) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al liquidar.';
setApiErrorMessage(msg);
setApiErrorMessage(msg);
} finally {
setLoading(false);
}
};
// Esta función se pasa al modal para que la invoque al hacer submit en MODO EDICIÓN
const handleModalEditSubmit = async (data: UpdateEntradaSalidaCanillaDto, idParte: number) => {
// ... (sin cambios)
setApiErrorMessage(null);
try {
await entradaSalidaCanillaService.updateEntradaSalidaCanilla(idParte, data);
@@ -251,32 +302,21 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
const handleCloseModal = () => {
setModalOpen(false);
setEditingMovimiento(null);
// Recargar siempre que se cierre el modal y no haya un error pendiente a nivel de página
// Opcionalmente, podrías tener una bandera ' cambiosGuardados' que el modal active
// para ser más selectivo con la recarga.
setPrefillModalData(null);
if (!apiErrorMessage) {
cargarMovimientos();
}
};
const handleImprimirTicketLiquidacion = useCallback(async (
// Parámetros necesarios para el ticket
idCanilla: number,
fecha: string, // Fecha para la que se genera el ticket (probablemente fechaLiquidacionDialog)
esAccionista: boolean
idCanilla: number, fecha: string, esAccionista: boolean
) => {
// ... (sin cambios)
setLoadingTicketPdf(true);
setApiErrorMessage(null);
try {
const params = {
fecha: fecha.split('T')[0], // Asegurar formato YYYY-MM-DD
idCanilla: idCanilla,
esAccionista: esAccionista,
};
const params = { fecha: fecha.split('T')[0], idCanilla, esAccionista };
const blob = await reportesService.getTicketLiquidacionCanillaPdf(params);
if (blob.type === "application/json") {
const text = await blob.text();
const msg = JSON.parse(text).message ?? "Error inesperado al generar el ticket PDF.";
@@ -287,16 +327,11 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
if (!w) alert("Permita popups para ver el PDF del ticket.");
}
} catch (error: any) {
console.error("Error al generar ticket de liquidación:", error);
const message = axios.isAxiosError(error) && error.response?.data?.message
? error.response.data.message
: 'Ocurrió un error al generar el ticket.';
console.error("Error al generar ticket:", error);
const message = axios.isAxiosError(error) && error.response?.data?.message ? error.response.data.message : 'Error al generar ticket.';
setApiErrorMessage(message);
} finally {
setLoadingTicketPdf(false);
// No cerramos el menú aquí si se llama desde handleConfirmLiquidar
}
}, []); // Dependencias vacías si no usa nada del scope exterior que cambie, o añadir si es necesario
} finally { setLoadingTicketPdf(false); }
}, []);
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
@@ -305,47 +340,77 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
};
const displayData = movimientos.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!loading && !puedeVer && !loadingFiltersDropdown) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>;
if (!loading && !puedeVer && !loadingFiltersDropdown && movimientos.length === 0 && !filtroFecha && !filtroIdCanillitaSeleccionado ) { // Modificado para solo mostrar si no hay filtros y no puede ver
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>;
}
const numSelectedToLiquidate = selectedIdsParaLiquidar.size;
// Corregido: numNotLiquidatedOnPage debe calcularse sobre 'movimientos' filtrados, no solo 'displayData'
// O, si la selección es solo por página, displayData está bien. Asumamos selección por página por ahora.
const numNotLiquidatedOnPage = displayData.filter(m => !m.liquidado).length;
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Entradas/Salidas Canillitas</Typography>
<Typography variant="h5" gutterBottom>Entradas/Salidas Canillitas & Accionistas</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 Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} />
<TextField label="Fecha" type="date" size="small" value={filtroFecha}
onChange={(e) => setFiltroFecha(e.target.value)}
InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }}
required
error={!filtroFecha} // Se marca error si está vacío
helperText={!filtroFecha ? "Fecha es obligatoria" : ""}
/>
<ToggleButtonGroup
color="primary"
value={filtroTipoDestinatario}
exclusive
onChange={(_, newValue: TipoDestinatarioFiltro | null) => {
if (newValue !== null) {
setFiltroTipoDestinatario(newValue);
}
}}
aria-label="Tipo de Destinatario"
size="small"
>
<ToggleButton value="canillitas">Canillitas</ToggleButton>
<ToggleButton value="accionistas">Accionistas</ToggleButton>
</ToggleButtonGroup>
<FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingFiltersDropdown} required error={!filtroIdCanillitaSeleccionado}>
<InputLabel>{filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}</InputLabel>
<Select
value={filtroIdCanillitaSeleccionado}
label={filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}
onChange={(e) => setFiltroIdCanillitaSeleccionado(e.target.value as number | string)}
>
<MenuItem value=""><em>Seleccione uno</em></MenuItem>
{destinatariosDropdown.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe} {c.legajo ? `(Leg: ${c.legajo})`: ''}</MenuItem>)}
</Select>
{!filtroIdCanillitaSeleccionado && <Typography component="p" color="error" variant="caption" sx={{ml:1.5, fontSize:'0.65rem'}}>Selección obligatoria</Typography>}
</FormControl>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
<InputLabel>Publicación</InputLabel>
<Select value={filtroIdPublicacion} label="Publicación" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}>
<InputLabel>Publicación (Opcional)</InputLabel>
<Select value={filtroIdPublicacion} label="Publicación (Opcional)" onChange={(e) => setFiltroIdPublicacion(e.target.value as number | string)}>
<MenuItem value=""><em>Todas</em></MenuItem>
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
<InputLabel>Canillita</InputLabel>
<Select value={filtroIdCanilla} label="Canillita" onChange={(e) => setFiltroIdCanilla(e.target.value as number | string)}>
<MenuItem value=""><em>Todos</em></MenuItem>
{canillitas.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}>
<InputLabel>Estado Liquidación</InputLabel>
<Select value={filtroEstadoLiquidacion} label="Estado Liquidación" onChange={(e) => setFiltroEstadoLiquidacion(e.target.value as 'todos' | 'liquidados' | 'noLiquidados')}>
<MenuItem value="noLiquidados">No Liquidados</MenuItem>
<MenuItem value="liquidados">Liquidados</MenuItem>
<MenuItem value="todos">Todos</MenuItem>
</Select>
</FormControl>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Registrar Movimiento</Button>)}
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && numSelectedToLiquidate > 0 && (
{/* --- CAMBIO: DESHABILITAR BOTÓN SI FILTROS OBLIGATORIOS NO ESTÁN --- */}
{puedeCrear && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenModal()}
disabled={!filtroFecha || !filtroIdCanillitaSeleccionado} // <<-- AÑADIDO
>
Registrar Movimiento
</Button>
)}
{puedeLiquidar && numSelectedToLiquidate > 0 && movimientos.some(m => selectedIdsParaLiquidar.has(m.idParte) && !m.liquidado) && (
<Button variant="contained" color="success" startIcon={<PlaylistAddCheckIcon />} onClick={handleOpenLiquidarDialog}>
Liquidar Seleccionados ({numSelectedToLiquidate})
</Button>
@@ -353,8 +418,12 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Box>
</Paper>
{!filtroFecha && <Alert severity="info" sx={{my:1}}>Por favor, seleccione una fecha.</Alert>}
{filtroFecha && !filtroIdCanillitaSeleccionado && <Alert severity="info" sx={{my:1}}>Por favor, seleccione un {filtroTipoDestinatario === 'canillitas' ? 'canillita' : 'accionista'}.</Alert>}
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{/* Mostrar error general si no hay error de API específico y no está cargando filtros */}
{error && !loading && !apiErrorMessage && !loadingFiltersDropdown && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{loadingTicketPdf &&
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}>
@@ -364,12 +433,13 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
}
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
{!loading && !error && puedeVer && filtroFecha && filtroIdCanillitaSeleccionado && (
// ... (Tabla y Paginación sin cambios)
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && (
{puedeLiquidar && (
<TableCell padding="checkbox">
<Checkbox
indeterminate={numSelectedToLiquidate > 0 && numSelectedToLiquidate < numNotLiquidatedOnPage && numNotLiquidatedOnPage > 0}
@@ -381,7 +451,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
)}
<TableCell>Fecha</TableCell>
<TableCell>Publicación</TableCell>
<TableCell>Canillita</TableCell>
<TableCell>{filtroTipoDestinatario === 'canillitas' ? 'Canillita' : 'Accionista'}</TableCell>
<TableCell align="right">Salida</TableCell>
<TableCell align="right">Entrada</TableCell>
<TableCell align="right">Vendidos</TableCell>
@@ -397,19 +467,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<TableRow>
<TableCell
colSpan={
(puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' ? 1 : 0) +
9 +
((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0)
(puedeLiquidar ? 1 : 0) + 9 + ((puedeModificar || puedeEliminar || puedeLiquidar) ? 1 : 0)
}
align="center"
>
No se encontraron movimientos.
No se encontraron movimientos con los filtros aplicados.
</TableCell>
</TableRow>
) : (
displayData.map((m) => (
<TableRow key={m.idParte} hover selected={selectedIdsParaLiquidar.has(m.idParte)}>
{puedeLiquidar && filtroEstadoLiquidacion !== 'liquidados' && (
{puedeLiquidar && (
<TableCell padding="checkbox">
<Checkbox
checked={selectedIdsParaLiquidar.has(m.idParte)}
@@ -440,8 +508,8 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<TableCell align="right">
<IconButton
onClick={(e) => handleMenuOpen(e, m)}
data-rowid={m.idParte.toString()} // Guardar el id de la fila aquí
disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar} // Lógica simplificada, refinar si es necesario
data-rowid={m.idParte.toString()}
disabled={m.liquidado && !puedeEliminarLiquidados && !puedeLiquidar}
>
<MoreVertIcon />
</IconButton>
@@ -462,19 +530,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && selectedRow && !selectedRow.liquidado && (
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
{/* Opción de Imprimir Ticket Liq. */}
{selectedRow && selectedRow.liquidado && ( // Solo mostrar si ya está liquidado (para reimprimir)
{/* --- CAMBIO: MOSTRAR REIMPRIMIR TICKET SIEMPRE SI ESTÁ LIQUIDADO --- */}
{selectedRow && selectedRow.liquidado && puedeLiquidar && ( // Usar puedeLiquidar para consistencia
<MenuItem
onClick={() => {
if (selectedRow) { // selectedRow no será null aquí debido a la condición anterior
if (selectedRow) {
handleImprimirTicketLiquidacion(
selectedRow.idCanilla,
selectedRow.fechaLiquidado || selectedRow.fecha, // Usar fechaLiquidado si existe, sino la fecha del movimiento
selectedRow.canillaEsAccionista
selectedRow.fechaLiquidado || selectedRow.fecha,
selectedRow.canillaEsAccionista // Pasar si es accionista
);
}
// handleMenuClose() es llamado por handleImprimirTicketLiquidacion
}}
disabled={loadingTicketPdf}
>
@@ -483,13 +549,10 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
Reimprimir Ticket Liq.
</MenuItem>
)}
{selectedRow && ( // Opción de Eliminar
{selectedRow && (
((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados))
) && (
<MenuItem onClick={() => {
if (selectedRow) handleDelete(selectedRow.idParte);
}}>
<MenuItem onClick={() => { if (selectedRow) handleDelete(selectedRow.idParte); }}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar
</MenuItem>
)}
@@ -498,13 +561,15 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
<EntradaSalidaCanillaFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleModalEditSubmit}
onSubmit={handleModalEditSubmit} // Este onSubmit es solo para edición
initialData={editingMovimiento}
prefillData={prefillModalData}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
{/* ... (Dialog de Liquidación sin cambios) ... */}
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
<DialogTitle>Confirmar Liquidación</DialogTitle>
<DialogContent>
<DialogContentText>
@@ -523,7 +588,6 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -0,0 +1,301 @@
// src/pages/Distribucion/GestionarNovedadesCanillaPage.tsx
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, TextField, Tooltip
} 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';
import DeleteIcon from '@mui/icons-material/Delete';
import FilterListIcon from '@mui/icons-material/FilterList';
import novedadCanillaService from '../../services/Distribucion/novedadCanillaService';
import canillaService from '../../services/Distribucion/canillaService';
import type { NovedadCanillaDto } from '../../models/dtos/Distribucion/NovedadCanillaDto';
import type { CreateNovedadCanillaDto } from '../../models/dtos/Distribucion/CreateNovedadCanillaDto';
import type { UpdateNovedadCanillaDto } from '../../models/dtos/Distribucion/UpdateNovedadCanillaDto';
import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import NovedadCanillaFormModal from '../../components/Modals/Distribucion/NovedadCanillaFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
const GestionarNovedadesCanillaPage: React.FC = () => {
const { idCanilla: idCanillaStr } = useParams<{ idCanilla: string }>();
const navigate = useNavigate();
const idCanilla = Number(idCanillaStr);
const [canillita, setCanillita] = useState<CanillaDto | null>(null);
const [novedades, setNovedades] = useState<NovedadCanillaDto[]>([]);
const [loading, setLoading] = useState(true);
const [errorPage, setErrorPage] = useState<string | null>(null); // Error general de la página
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>('');
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>('');
const [modalOpen, setModalOpen] = useState(false);
const [editingNovedad, setEditingNovedad] = useState<NovedadCanillaDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); // Para modal/delete
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedNovedadRow, setSelectedNovedadRow] = useState<NovedadCanillaDto | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionarNovedades = isSuperAdmin || tienePermiso("CG006");
const puedeVerCanillitas = isSuperAdmin || tienePermiso("CG001");
// Cargar datos del canillita (solo una vez o si idCanilla cambia)
useEffect(() => {
if (isNaN(idCanilla)) {
setErrorPage("ID de Canillita inválido.");
setLoading(false); // Detener carga principal
return;
}
if (!puedeVerCanillitas && !puedeGestionarNovedades) {
setErrorPage("No tiene permiso para acceder a esta sección.");
setLoading(false);
return;
}
setLoading(true); // Iniciar carga para datos del canillita
const fetchCanillita = async () => {
try {
if (puedeVerCanillitas) {
const canData = await canillaService.getCanillaById(idCanilla);
setCanillita(canData);
} else {
// Si no puede ver detalles del canillita pero sí novedades, al menos mostrar ID
setCanillita({ idCanilla, nomApe: `ID ${idCanilla}` } as CanillaDto);
}
} catch (err) {
console.error("Error cargando datos del canillita:", err);
setErrorPage(`Error al cargar datos del canillita (ID: ${idCanilla}).`);
}
// No ponemos setLoading(false) aquí, porque la carga de novedades sigue.
};
fetchCanillita();
}, [idCanilla, puedeVerCanillitas, puedeGestionarNovedades]);
// Cargar/filtrar novedades
const cargarNovedades = useCallback(async () => {
if (isNaN(idCanilla) || (!puedeGestionarNovedades && !puedeVerCanillitas)) {
// Los permisos ya se validaron en el useEffect anterior, pero es bueno tenerlo
return;
}
// Si ya está cargando los datos del canillita, no iniciar otra carga paralela
// Se usará el mismo 'loading' para ambas operaciones iniciales.
// if (!loading) setLoading(true); // No es necesario si el useEffect anterior ya lo hizo
setApiErrorMessage(null); // Limpiar errores de API de acciones previas
// setErrorPage(null); // No limpiar error de página aquí, podría ser por el canillita
try {
const params = {
fechaDesde: filtroFechaDesde || null,
fechaHasta: filtroFechaHasta || null,
};
const dataNovedades = await novedadCanillaService.getNovedadesPorCanilla(idCanilla, params);
setNovedades(dataNovedades);
// Si no hay datos con filtros, no es un error de API, simplemente no hay datos.
// El mensaje de "no hay novedades" se maneja en la tabla.
} catch (err: any) {
console.error("Error al cargar/filtrar novedades:", err);
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al cargar las novedades.';
setErrorPage(message); // Usar el error de página para problemas de carga de novedades
setNovedades([]); // Limpiar en caso de error
} finally {
// Solo poner setLoading(false) después de que AMBAS cargas (canillita y novedades) se intenten.
// Como se llaman en secuencia implícita por los useEffect, el último setLoading(false) es el de novedades.
setLoading(false);
}
}, [idCanilla, puedeGestionarNovedades, puedeVerCanillitas, filtroFechaDesde, filtroFechaHasta]);
// useEffect para cargar novedades cuando los filtros o el canillita (o permisos) cambian
useEffect(() => {
// Solo cargar si tenemos un idCanilla válido y permisos
if (!isNaN(idCanilla) && (puedeGestionarNovedades || puedeVerCanillitas)) {
cargarNovedades();
} else if (isNaN(idCanilla)){
setErrorPage("ID de Canillita inválido.");
setLoading(false);
} else if (!puedeGestionarNovedades && !puedeVerCanillitas) {
setErrorPage("No tiene permiso para acceder a esta sección.");
setLoading(false);
}
}, [idCanilla, cargarNovedades, puedeGestionarNovedades, puedeVerCanillitas]); // `cargarNovedades` ya tiene sus dependencias
const handleOpenModal = (item?: NovedadCanillaDto) => {
if (!puedeGestionarNovedades) {
setApiErrorMessage("No tiene permiso para agregar o editar novedades.");
return;
}
setEditingNovedad(item || null); setApiErrorMessage(null); setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false); setEditingNovedad(null);
};
const handleSubmitModal = async (data: CreateNovedadCanillaDto | UpdateNovedadCanillaDto, idNovedad?: number) => {
if (!puedeGestionarNovedades) return;
setApiErrorMessage(null);
try {
if (editingNovedad && idNovedad) {
await novedadCanillaService.updateNovedad(idNovedad, data as UpdateNovedadCanillaDto);
} else {
await novedadCanillaService.createNovedad(data as CreateNovedadCanillaDto);
}
cargarNovedades(); // Recargar lista de novedades
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la novedad.';
setApiErrorMessage(message); throw err;
}
};
const handleDelete = async (idNovedadDelRow: number) => {
if (!puedeGestionarNovedades) return;
if (window.confirm(`¿Seguro de eliminar esta novedad (ID: ${idNovedadDelRow})?`)) {
setApiErrorMessage(null);
try {
await novedadCanillaService.deleteNovedad(idNovedadDelRow);
cargarNovedades(); // Recargar lista de novedades
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar la novedad.';
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, item: NovedadCanillaDto) => {
setAnchorEl(event.currentTarget); setSelectedNovedadRow(item);
};
const handleMenuClose = () => {
setAnchorEl(null); setSelectedNovedadRow(null);
};
const formatDate = (dateString?: string | null) => dateString ? new Date(dateString).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-';
if (loading && !canillita) { // Muestra cargando solo si aún no tenemos los datos del canillita
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
}
if (errorPage && !canillita) { // Si hay un error al cargar el canillita, no mostrar nada más
return <Alert severity="error" sx={{ m: 2 }}>{errorPage}</Alert>;
}
// Si no tiene permiso para la sección en general
if (!puedeGestionarNovedades && !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>
Novedades de: {canillita?.nomApe || `Canillita ID ${idCanilla}`}
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2}}>
{puedeGestionarNovedades && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: {xs: 2, sm:0} }}>
Agregar Novedad
</Button>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<FilterListIcon sx={{color: 'action.active', alignSelf:'center'}} />
<TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde}
onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }}
disabled={loading} // Deshabilitar durante cualquier carga
/>
<TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta}
onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }}
disabled={loading} // Deshabilitar durante cualquier carga
/>
</Box>
</Box>
</Paper>
{/* Mostrar error de API (de submit/delete) o error de carga de novedades */}
{(apiErrorMessage || (errorPage && novedades.length === 0 && !loading)) && (
<Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage || errorPage}</Alert>
)}
{loading && <Box sx={{display:'flex', justifyContent:'center', my:2}}><CircularProgress size={30} /></Box>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Fecha</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: '70%' }}>Detalle de Novedad</TableCell>
{puedeGestionarNovedades && <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>}
</TableRow></TableHead>
<TableBody>
{novedades.length === 0 && !loading ? (
<TableRow><TableCell colSpan={puedeGestionarNovedades ? 3 : 2} align="center">
No hay novedades registradas { (filtroFechaDesde || filtroFechaHasta) && "con los filtros aplicados"}.
</TableCell></TableRow>
) : (
novedades.map((nov) => (
<TableRow key={nov.idNovedad} hover>
<TableCell>{formatDate(nov.fecha)}</TableCell>
<TableCell>
<Tooltip title={nov.detalle || ''} arrow>
<Typography variant="body2" sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '500px'
}}>
{nov.detalle || '-'}
</Typography>
</Tooltip>
</TableCell>
{puedeGestionarNovedades && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, nov)} disabled={!puedeGestionarNovedades}>
<MoreVertIcon />
</IconButton>
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
</TableContainer>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeGestionarNovedades && selectedNovedadRow && (
<MenuItem onClick={() => { handleOpenModal(selectedNovedadRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{mr:1}}/> Editar</MenuItem>)}
{puedeGestionarNovedades && selectedNovedadRow && (
<MenuItem onClick={() => handleDelete(selectedNovedadRow.idNovedad)}><DeleteIcon fontSize="small" sx={{mr:1}}/> Eliminar</MenuItem>)}
</Menu>
{idCanilla &&
<NovedadCanillaFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
idCanilla={idCanilla}
nombreCanilla={canillita?.nomApe}
initialData={editingNovedad}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
}
</Box>
);
};
export default GestionarNovedadesCanillaPage;

View File

@@ -37,15 +37,16 @@ const GestionarOtrosDestinosPage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions();
// Permisos para Otros Destinos (OD001 a OD004) - Revisa tus códigos de permiso
const puedeVer = isSuperAdmin || tienePermiso("OD001"); // Asumiendo OD001 es ver entidad
const puedeVer = isSuperAdmin || tienePermiso("OD001");
const puedeCrear = isSuperAdmin || tienePermiso("OD002");
const puedeModificar = isSuperAdmin || tienePermiso("OD003");
const puedeEliminar = isSuperAdmin || tienePermiso("OD004");
const cargarOtrosDestinos = useCallback(async () => {
if (!puedeVer) {
setError("No tiene permiso para ver esta sección.");
setError("No tiene permiso para ver los 'Otros Destinos'.");
setLoading(false);
setOtrosDestinos([]);
return;
}
setLoading(true);
@@ -131,8 +132,8 @@ const GestionarOtrosDestinosPage: React.FC = () => {
if (!loading && !puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Typography variant="h4" gutterBottom>Gestionar Otros Destinos</Typography>
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Gestionar Otros Destinos</Typography> {/* Cambiado h4 a h5 */}
<Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert>
</Box>
);

View File

@@ -241,7 +241,7 @@ const GestionarPublicacionesPage: React.FC = () => {
</FormControl>
<FormControlLabel
control={<Switch checked={filtroSoloHabilitadas === undefined ? true : filtroSoloHabilitadas} onChange={(e) => setFiltroSoloHabilitadas(e.target.checked)} size="small" />}
label="Solo Habilitadas"
label="Ver Habilitadas"
/>
</Box>
{puedeCrear && (<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Publicación</Button>)}

View File

@@ -37,13 +37,19 @@ const GestionarZonasPage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions();
// Ajustar códigos de permiso para Zonas
const puedeVer = isSuperAdmin || tienePermiso("ZD001"); // Permiso para ver Zonas
const puedeCrear = isSuperAdmin || tienePermiso("ZD002");
const puedeModificar = isSuperAdmin || tienePermiso("ZD003");
const puedeEliminar = isSuperAdmin || tienePermiso("ZD004");
const cargarZonas = useCallback(async () => {
if (!puedeVer) {
setError("No tiene permiso para ver las zonas.");
setLoading(false);
setZonas([]); // Asegurar que no se muestren datos previos
return;
}
setLoading(true);
setError(null);
try {
@@ -134,6 +140,17 @@ const GestionarZonasPage: React.FC = () => {
// Adaptar para paginación
const displayData = zonas.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!loading && !puedeVer) {
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>
Gestionar Zonas
</Typography>
{/* El error de "sin permiso" ya fue seteado en cargarZonas */}
<Alert severity="error">{error || "No tiene permiso para acceder a esta sección."}</Alert>
</Box>
);
}
return (
<Box sx={{ p: 1 }}>
@@ -150,7 +167,6 @@ const GestionarZonasPage: React.FC = () => {
value={filtroNombre}
onChange={(e) => setFiltroNombre(e.target.value)}
/>
{/* <TextField label="Filtrar por Descripción" ... /> */}
</Box>
{puedeCrear && (
<Button
@@ -165,11 +181,11 @@ const GestionarZonasPage: React.FC = () => {
</Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{error && !loading && puedeVer && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && (
{!loading && !error && puedeVer && (
<TableContainer component={Paper}>
<Table>
<TableHead>

View File

@@ -283,8 +283,8 @@ const ReporteComparativaConsumoBobinasPage: React.FC = () => {
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>

View File

@@ -4,12 +4,12 @@ import { Box, Typography, Paper, CircularProgress, Alert, Button, Divider, type
import reportesService from '../../services/Reportes/reportesService';
import type { ControlDevolucionesDataResponseDto } from '../../models/dtos/Reportes/ControlDevolucionesDataResponseDto';
import SeleccionaReporteControlDevoluciones from './SeleccionaReporteControlDevoluciones';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
const ReporteControlDevolucionesPage: React.FC = () => {
// ... (estados y funciones de manejo de datos sin cambios significativos, excepto cómo se renderiza) ...
const [reportData, setReportData] = useState<ControlDevolucionesDataResponseDto | null>(null);
const [loading, setLoading] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
@@ -17,6 +17,8 @@ const ReporteControlDevolucionesPage: React.FC = () => {
const [apiErrorParams, setApiErrorParams] = useState<string | null>(null);
const [showParamSelector, setShowParamSelector] = useState(true);
const [currentParams, setCurrentParams] = useState<{ fecha: string; idEmpresa: number; nombreEmpresa?: string } | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR003");
const numberLocaleFormatter = (value: number | null | undefined, showSign = false): string => {
if (value == null) return '';
@@ -26,6 +28,11 @@ const ReporteControlDevolucionesPage: React.FC = () => {
};
const handleGenerarReporte = useCallback(async (params: { fecha: string; idEmpresa: number }) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
@@ -159,92 +166,92 @@ const ReporteControlDevolucionesPage: React.FC = () => {
}, [reportData]);
const handleExportToExcel = useCallback(() => {
if (!reportData || !calculatedValues || !currentParams) {
alert("No hay datos para exportar.");
return;
}
if (!reportData || !calculatedValues || !currentParams) {
alert("No hay datos para exportar.");
return;
}
const dataForExcel: any[][] = [];
const dataForExcel: any[][] = [];
// --- Títulos y Cabecera ---
dataForExcel.push(["Control de Devoluciones"]); // Título Principal
dataForExcel.push(["Canillas / Accionistas"]); // Subtítulo
dataForExcel.push([]); // Fila vacía para espaciado
// --- Títulos y Cabecera ---
dataForExcel.push(["Control de Devoluciones"]); // Título Principal
dataForExcel.push(["Canillas / Accionistas"]); // Subtítulo
dataForExcel.push([]); // Fila vacía para espaciado
dataForExcel.push([
`Fecha Consultada: ${currentParams.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', {timeZone:'UTC'}) : ''}`,
"", // Celda vacía para espaciado o para alinear con la segunda columna si fuera necesario
`Cantidad Canillas: ${calculatedValues.cantidadCanillas}`
]);
dataForExcel.push([]);
dataForExcel.push([
`Fecha Consultada: ${currentParams.fecha ? new Date(currentParams.fecha + 'T00:00:00').toLocaleDateString('es-AR', { timeZone: 'UTC' }) : ''}`,
"", // Celda vacía para espaciado o para alinear con la segunda columna si fuera necesario
`Cantidad Canillas: ${calculatedValues.cantidadCanillas}`
]);
dataForExcel.push([]);
dataForExcel.push([currentParams.nombreEmpresa || 'EL DIA']); // Nombre de la Empresa/Publicación
dataForExcel.push([]);
dataForExcel.push([currentParams.nombreEmpresa || 'EL DIA']); // Nombre de la Empresa/Publicación
dataForExcel.push([]);
// --- Cuerpo del Reporte ---
dataForExcel.push(["Ingresados por Remito:", calculatedValues.ingresadosPorRemito]);
dataForExcel.push(["----------------------------------", "-------------------"]); // Línea divisoria (estilo simple)
// --- Cuerpo del Reporte ---
dataForExcel.push(["Ingresados por Remito:", calculatedValues.ingresadosPorRemito]);
dataForExcel.push(["----------------------------------", "-------------------"]); // Línea divisoria (estilo simple)
dataForExcel.push(["Accionistas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosAcc]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosAcc]);
dataForExcel.push(["Total", -calculatedValues.totalAcc]); // Fila de Total con estilo
dataForExcel.push([]);
dataForExcel.push(["Accionistas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosAcc]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosAcc]);
dataForExcel.push(["Total", -calculatedValues.totalAcc]); // Fila de Total con estilo
dataForExcel.push([]);
dataForExcel.push(["Canillitas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosCan]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosCan]);
dataForExcel.push(["Total", -calculatedValues.totalCan]); // Fila de Total con estilo
dataForExcel.push(["==================================", "==================="]); // Línea divisoria sólida
dataForExcel.push(["Canillitas"]);
dataForExcel.push(["Llevados", -calculatedValues.llevadosCan]);
dataForExcel.push(["Devueltos", calculatedValues.devueltosCan]);
dataForExcel.push(["Total", -calculatedValues.totalCan]); // Fila de Total con estilo
dataForExcel.push(["==================================", "==================="]); // Línea divisoria sólida
dataForExcel.push(["Total Devolución a la Fecha", calculatedValues.totalDevolucionFecha]);
dataForExcel.push(["Total Devolución Días Anteriores", calculatedValues.totalDevolucionOtrosDias]);
dataForExcel.push(["Total Devolución", calculatedValues.totalDevolucion]); // Fila de Total con estilo
dataForExcel.push(["----------------------------------", "-------------------"]);
dataForExcel.push(["Total Devolución a la Fecha", calculatedValues.totalDevolucionFecha]);
dataForExcel.push(["Total Devolución Días Anteriores", calculatedValues.totalDevolucionOtrosDias]);
dataForExcel.push(["Total Devolución", calculatedValues.totalDevolucion]); // Fila de Total con estilo
dataForExcel.push(["----------------------------------", "-------------------"]);
dataForExcel.push(["Sin Cargo", calculatedValues.sinCargo]);
dataForExcel.push(["Sobrantes", -calculatedValues.sobrantes]);
dataForExcel.push(["Diferencia", calculatedValues.diferencia]); // Fila de Total con estilo
dataForExcel.push(["Sin Cargo", calculatedValues.sinCargo]);
dataForExcel.push(["Sobrantes", -calculatedValues.sobrantes]);
dataForExcel.push(["Diferencia", calculatedValues.diferencia]); // Fila de Total con estilo
// --- Crear Hoja y Libro ---
const ws = XLSX.utils.aoa_to_sheet(dataForExcel);
// --- Crear Hoja y Libro ---
const ws = XLSX.utils.aoa_to_sheet(dataForExcel);
// Ajustar anchos de columna (opcional, pero recomendado)
// Esto es un cálculo aproximado, puedes ajustarlo
const colWidths = [
{ wch: 40 }, // Columna A (Etiquetas)
{ wch: 15 }, // Columna B (Valores)
{ wch: 25 } // Columna C (para Cantidad Canillas)
];
ws['!cols'] = colWidths;
// Ajustar anchos de columna (opcional, pero recomendado)
// Esto es un cálculo aproximado, puedes ajustarlo
const colWidths = [
{ wch: 40 }, // Columna A (Etiquetas)
{ wch: 15 }, // Columna B (Valores)
{ wch: 25 } // Columna C (para Cantidad Canillas)
];
ws['!cols'] = colWidths;
// Fusionar celdas para títulos (opcional, requiere más trabajo con la estructura de 'ws')
// Ejemplo para el título principal (ocuparía A1:C1)
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push({ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }); // Fusionar A1 a C1
ws['!merges'].push({ s: { r: 1, c: 0 }, e: { r: 1, c: 2 } }); // Fusionar A2 a C2
ws['!merges'].push({ s: { r: 5, c: 0 }, e: { r: 5, c: 2 } }); // Fusionar celda de Nombre Empresa
// Fusionar celdas para títulos (opcional, requiere más trabajo con la estructura de 'ws')
// Ejemplo para el título principal (ocuparía A1:C1)
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push({ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }); // Fusionar A1 a C1
ws['!merges'].push({ s: { r: 1, c: 0 }, e: { r: 1, c: 2 } }); // Fusionar A2 a C2
ws['!merges'].push({ s: { r: 5, c: 0 }, e: { r: 5, c: 2 } }); // Fusionar celda de Nombre Empresa
// Aplicar formato numérico (esto es más avanzado y depende de cómo quieras los números en Excel)
// Por ahora, los números se exportarán como números si son de tipo number en dataForExcel.
// Para formato de moneda o miles, tendrías que modificar las celdas en el objeto 'ws'
// o asegurarte de que los valores en 'dataForExcel' ya estén como strings formateados si Excel no los interpreta bien.
// Por simplicidad, los dejamos como números y Excel usará su formato por defecto.
// Aplicar formato numérico (esto es más avanzado y depende de cómo quieras los números en Excel)
// Por ahora, los números se exportarán como números si son de tipo number en dataForExcel.
// Para formato de moneda o miles, tendrías que modificar las celdas en el objeto 'ws'
// o asegurarte de que los valores en 'dataForExcel' ya estén como strings formateados si Excel no los interpreta bien.
// Por simplicidad, los dejamos como números y Excel usará su formato por defecto.
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "ControlDevoluciones");
let fileName = "ReporteControlDevoluciones";
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') ?? `Emp${currentParams.idEmpresa}`}`;
fileName += `_${currentParams.fecha}`;
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "ControlDevoluciones");
let fileName = "ReporteControlDevoluciones";
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') ?? `Emp${currentParams.idEmpresa}`}`;
fileName += `_${currentParams.fecha}`;
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
}, [reportData, calculatedValues, currentParams]);
}, [reportData, calculatedValues, currentParams]);
// Componente para una fila del reporte usando Box con Flexbox
interface ReportRowProps {
@@ -306,6 +313,9 @@ const ReporteControlDevolucionesPage: React.FC = () => {
);
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react';
import {
Box, Typography, Paper, CircularProgress, Button
Box, Typography, Paper, CircularProgress, Button,
Alert
} from '@mui/material';
import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
@@ -9,6 +10,7 @@ import type { ReporteCuentasDistribuidorResponseDto } from '../../models/dtos/Re
import type { BalanceCuentaDistDto } from '../../models/dtos/Reportes/BalanceCuentaDistDto';
import type { BalanceCuentaDebCredDto } from '../../models/dtos/Reportes/BalanceCuentaDebCredDto';
import type { BalanceCuentaPagosDto } from '../../models/dtos/Reportes/BalanceCuentaPagosDto';
import { usePermissions } from '../../hooks/usePermissions';
import SeleccionaReporteCuentasDistribuidores from './SeleccionaReporteCuentasDistribuidores';
import * as XLSX from 'xlsx';
import axios from 'axios';
@@ -20,6 +22,7 @@ type PagoConSaldo = BalanceCuentaPagosDto & { id: string; saldoAcumulado: number
const ReporteCuentasDistribuidoresPage: React.FC = () => {
const [originalReportData, setOriginalReportData] = useState<ReporteCuentasDistribuidorResponseDto | null>(null);
const [movimientosConSaldo, setMovimientosConSaldo] = useState<MovimientoConSaldo[]>([]);
const [error, setError] = useState<string | null>(null);
const [notasConSaldo, setNotasConSaldo] = useState<NotaConSaldo[]>([]);
const [pagosConSaldo, setPagosConSaldo] = useState<PagoConSaldo[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -34,6 +37,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
nombreDistribuidor?: string;
nombreEmpresa?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR001");
// Calcula saldos acumulados seccion por seccion
const calcularSaldosPorSeccion = (data: ReporteCuentasDistribuidorResponseDto) => {
@@ -227,6 +232,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
fechaDesde: string;
fechaHasta: string;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setError(null);
setLoading(true);
setApiErrorParams(null);
setOriginalReportData(null);
@@ -237,8 +248,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const distSvc = (await import('../../services/Distribucion/distribuidorService')).default;
const empSvc = (await import('../../services/Distribucion/empresaService')).default;
const [distData, empData] = await Promise.all([
distSvc.getDistribuidorById(params.idDistribuidor),
empSvc.getEmpresaById(params.idEmpresa)
distSvc.getDistribuidorLookupById(params.idDistribuidor),
empSvc.getEmpresaLookupById(params.idEmpresa)
]);
setCurrentParams({
...params,
@@ -273,20 +284,39 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
}, []);
const handleExportToExcel = useCallback(() => {
if (!originalReportData) return;
const wb = XLSX.utils.book_new();
if (movimientosConSaldo.length) {
if (
!originalReportData ||
(movimientosConSaldo.length === 0 &&
notasConSaldo.length === 0 &&
pagosConSaldo.length === 0)
) {
alert("No hay datos para exportar."); // O un mensaje más amigable
return;
}
const wb = XLSX.utils.book_new();// Se crea un nuevo libro
// Movimientos
if (movimientosConSaldo.length) { // <--- CHEQUEO 1
// Si movimientosConSaldo está vacío, esta hoja no se añade
const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Movimientos');
}
if (notasConSaldo.length) {
// Notas
if (notasConSaldo.length) { // <--- CHEQUEO 2
// Si notasConSaldo está vacío, esta hoja no se añade
const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Notas');
}
if (pagosConSaldo.length) {
// Pagos
if (pagosConSaldo.length) { // <--- CHEQUEO 3
// Si pagosConSaldo está vacío, esta hoja no se añade
const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Pagos');
}
// Si ninguno de los arrays tiene datos, el libro 'wb' quedará vacío.
// Y la siguiente línea dará el error:
XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`);
}, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]);
@@ -297,13 +327,16 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const blob = await reportesService.getReporteCuentasDistribuidorPdf(currentParams);
window.open(URL.createObjectURL(blob), '_blank');
} catch {
/* manejar error */
setError('Ocurrió un error al generar el PDF.');
} finally {
setLoadingPdf(false);
}
}, [currentParams]);
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center' }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>
@@ -328,6 +361,11 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
return (
<Box sx={{ p: 2 }}>
{error && (
<Paper sx={{ mb: 2, p: 2, backgroundColor: '#ffeaea' }}>
<Typography color="error">{error}</Typography>
</Paper>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">Cuenta Corriente Distribuidor</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>

View File

@@ -0,0 +1,404 @@
// src/pages/Reportes/ReporteListadoDistMensualPage.tsx
import React, { useState, useCallback, useMemo } from 'react';
import {
Box, Typography, Paper, CircularProgress, Alert, Button,
} from '@mui/material';
import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
import reportesService from '../../services/Reportes/reportesService';
import type { ListadoDistCanMensualDiariosDto } from '../../models/dtos/Reportes/ListadoDistCanMensualDiariosDto';
import type { ListadoDistCanMensualPubDto } from '../../models/dtos/Reportes/ListadoDistCanMensualPubDto';
import SeleccionaReporteListadoDistMensual, { type TipoListadoDistMensual } from './SeleccionaReporteListadoDistMensual';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
// Interfaces para DataGrid (añadiendo 'id')
interface GridDiariosItem extends ListadoDistCanMensualDiariosDto { id: string; }
interface GridPubItem extends ListadoDistCanMensualPubDto { id: string; }
const ReporteListadoDistMensualPage: React.FC = () => {
const [reporteDiariosData, setReporteDiariosData] = useState<GridDiariosItem[]>([]);
const [reportePubData, setReportePubData] = useState<GridPubItem[]>([]);
const [currentReportVariant, setCurrentReportVariant] = useState<TipoListadoDistMensual | null>(null);
const [loading, setLoading] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiErrorParams, setApiErrorParams] = useState<string | null>(null);
const [showParamSelector, setShowParamSelector] = useState(true);
const [currentParams, setCurrentParams] = useState<{
fechaDesde: string;
fechaHasta: string;
esAccionista: boolean;
tipoReporte: TipoListadoDistMensual;
nombreTipoVendedor?: string;
mesAnio?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR009"); // Asumiendo RR009 para este reporte
const currencyFormatter = (value?: number | null) => value != null ? value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }) : '-';
const numberFormatter = (value?: number | null) => value != null ? Number(value).toLocaleString('es-AR') : '-';
const handleGenerarReporte = useCallback(async (params: {
fechaDesde: string;
fechaHasta: string;
esAccionista: boolean;
tipoReporte: TipoListadoDistMensual;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
setReporteDiariosData([]);
setReportePubData([]);
setCurrentReportVariant(params.tipoReporte);
const mesAnioStr = new Date(params.fechaDesde + 'T00:00:00').toLocaleDateString('es-AR', { month: 'long', year: 'numeric', timeZone: 'UTC' });
setCurrentParams({ ...params, nombreTipoVendedor: params.esAccionista ? "Accionistas" : "Canillitas", mesAnio: mesAnioStr });
try {
if (params.tipoReporte === 'diarios') {
const data = await reportesService.getListadoDistMensualDiarios(params);
setReporteDiariosData(data.map((item, i) => ({ ...item, id: `diario-${item.canilla}-${i}` })));
if (data.length === 0) setError("No se encontraron datos para la variante 'Desglose por Diarios'.");
} else { // 'publicaciones'
const data = await reportesService.getListadoDistMensualPorPublicacion(params);
setReportePubData(data.map((item, i) => ({ ...item, id: `pub-${item.canilla}-${item.publicacion}-${i}` })));
if (data.length === 0) setError("No se encontraron datos para la variante 'Desglose por Publicación'.");
}
setShowParamSelector(false);
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message : 'Ocurrió un error al generar el reporte.';
setApiErrorParams(message);
} finally {
setLoading(false);
}
}, [puedeVerReporte]);
const handleVolverAParametros = useCallback(() => {
setShowParamSelector(true);
setReporteDiariosData([]);
setReportePubData([]);
setError(null);
setApiErrorParams(null);
setCurrentParams(null);
setCurrentReportVariant(null);
}, []);
const handleExportToExcel = useCallback(() => {
if (reporteDiariosData.length === 0 && reportePubData.length === 0) {
alert("No hay datos para exportar."); return;
}
const wb = XLSX.utils.book_new();
let fileName = "ListadoDistMensual";
if (currentParams) {
fileName += `_${currentParams.nombreTipoVendedor?.replace(/\s+/g, '')}`;
fileName += `_${currentParams.mesAnio?.replace(/\s+/g, '-').replace('/', '-')}`;
}
if (currentReportVariant === 'diarios' && reporteDiariosData.length > 0) {
const data = reporteDiariosData.map(({ id, ...r }) => ({
"Canillita": r.canilla, "El Día (Cant.)": r.elDia, "El Plata (Cant.)": r.elPlata,
"Total Vendidos": r.vendidos, "Imp. El Día": r.importeElDia, "Imp. El Plata": r.importeElPlata,
"Importe Total": r.importeTotal
}));
const totalesDiarios = {
"Canillita": "TOTALES", "El Día (Cant.)": reporteDiariosData.reduce((s, i) => s + (i.elDia ?? 0), 0),
"El Plata (Cant.)": reporteDiariosData.reduce((s, i) => s + (i.elPlata ?? 0), 0),
"Total Vendidos": reporteDiariosData.reduce((s, i) => s + (i.vendidos ?? 0), 0),
"Imp. El Día": reporteDiariosData.reduce((s, i) => s + (i.importeElDia ?? 0), 0),
"Imp. El Plata": reporteDiariosData.reduce((s, i) => s + (i.importeElPlata ?? 0), 0),
"Importe Total": reporteDiariosData.reduce((s, i) => s + (i.importeTotal ?? 0), 0)
};
data.push(totalesDiarios);
const ws = XLSX.utils.json_to_sheet(data);
const headers = Object.keys(data[0] || {});
ws['!cols'] = headers.map(h => ({ wch: Math.max(...data.map(row => (row as any)[h]?.toString().length ?? 0), h.length) + 2 }));
XLSX.utils.book_append_sheet(wb, ws, "Desglose Diarios");
fileName += "_Diarios.xlsx";
} else if (currentReportVariant === 'publicaciones' && reportePubData.length > 0) {
// --- INICIO DE CAMBIOS PARA TOTALES EN EXCEL DE PUBLICACIONES ---
const dataAgrupadaParaExcel: any[] = [];
const canillitasUnicos = [...new Set(reportePubData.map(item => item.canilla))];
canillitasUnicos.sort().forEach(nombreCanillita => {
const itemsDelCanillita = reportePubData.filter(item => item.canilla === nombreCanillita);
itemsDelCanillita.forEach(item => {
dataAgrupadaParaExcel.push({
"Canillita": item.canilla,
"Publicación": item.publicacion,
"Llevados": item.totalCantSalida,
"Devueltos": item.totalCantEntrada,
"A Rendir": item.totalRendir
});
});
// Fila de Total por Canillita
const totalLlevadosCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalCantSalida ?? 0), 0);
const totalDevueltosCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalCantEntrada ?? 0), 0);
const totalRendirCanillita = itemsDelCanillita.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0);
dataAgrupadaParaExcel.push({
"Canillita": `Total ${nombreCanillita}`,
"Publicación": "",
"Llevados": totalLlevadosCanillita,
"Devueltos": totalDevueltosCanillita,
"A Rendir": totalRendirCanillita,
});
dataAgrupadaParaExcel.push({}); // Fila vacía para separar
});
// Fila de Total General
const totalGeneralLlevados = reportePubData.reduce((sum, item) => sum + (item.totalCantSalida ?? 0), 0);
const totalGeneralDevueltos = reportePubData.reduce((sum, item) => sum + (item.totalCantEntrada ?? 0), 0);
const totalGeneralRendir = reportePubData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0);
dataAgrupadaParaExcel.push({
"Canillita": "TOTAL GENERAL",
"Publicación": "",
"Llevados": totalGeneralLlevados,
"Devueltos": totalGeneralDevueltos,
"A Rendir": totalGeneralRendir,
});
const ws = XLSX.utils.json_to_sheet(dataAgrupadaParaExcel);
const headers = ["Canillita", "Publicación", "Llevados", "Devueltos", "A Rendir"]; // Definir orden
ws['!cols'] = headers.map(h => ({ wch: Math.max(...dataAgrupadaParaExcel.map(row => (row as any)[h]?.toString().length ?? 0), h.length) + 2 }));
XLSX.utils.book_append_sheet(wb, ws, "Desglose Publicaciones");
fileName += "_Publicaciones.xlsx";
}
if (wb.SheetNames.length > 0) XLSX.writeFile(wb, fileName);
else alert("No hay datos para la variante seleccionada para exportar.");
}, [reporteDiariosData, reportePubData, currentReportVariant, currentParams]);
const handleGenerarYAbrirPdf = useCallback(async () => {
if (!currentParams) { setError("Seleccione parámetros."); return; }
if (!puedeVerReporte) { setError("Sin permiso para PDF."); return; }
setLoadingPdf(true); setError(null);
try {
let blob;
if (currentParams.tipoReporte === 'diarios') {
blob = await reportesService.getListadoDistMensualDiariosPdf(currentParams);
} else {
blob = await reportesService.getListadoDistMensualPorPublicacionPdf(currentParams);
}
if (blob.type === "application/json") {
const text = await blob.text();
const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF.";
setError(msg);
setLoadingPdf(false); // Asegurar que se detenga el loader
return;
}
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank');
if (!w) alert("Permita popups para ver el PDF.");
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Ocurrió un error al generar el PDF.';
setError(message);
} finally {
setLoadingPdf(false);
}
}, [currentParams, puedeVerReporte]);
const FooterSimple = () => (<GridFooterContainer><GridFooter /></GridFooterContainer>);
const totalGeneralDiarios = useMemo(() => {
if (reporteDiariosData.length === 0) return null;
return {
elDia: reporteDiariosData.reduce((s, i) => s + (i.elDia ?? 0), 0),
elPlata: reporteDiariosData.reduce((s, i) => s + (i.elPlata ?? 0), 0),
vendidos: reporteDiariosData.reduce((s, i) => s + (i.vendidos ?? 0), 0),
importeElDia: reporteDiariosData.reduce((s, i) => s + (i.importeElDia ?? 0), 0),
importeElPlata: reporteDiariosData.reduce((s, i) => s + (i.importeElPlata ?? 0), 0),
importeTotal: reporteDiariosData.reduce((s, i) => s + (i.importeTotal ?? 0), 0)
};
}, [reporteDiariosData]);
// eslint-disable-next-line react/display-name
const FooterDiarios = () => {
if (!totalGeneralDiarios) return <GridFooterContainer><GridFooter sx={{ borderTop: 'none' }} /></GridFooterContainer>;
return (
<GridFooterContainer sx={{
display: 'flex',
justifyContent: 'space-between', // Separa la paginación (izquierda) de los totales (derecha)
alignItems: 'center',
width: '100%',
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
minHeight: '52px', // Altura estándar para el footer
// No es necesario p: aquí si los hijos lo manejan o el GridFooterContainer lo aplica por defecto
}}>
{/* Box para la paginación estándar */}
<Box sx={{
display: 'flex',
alignItems: 'center',
flexShrink: 0, // Evita que este box se encoja si los totales son anchos
overflow: 'hidden', // Para asegurar que no desborde si el contenido interno es muy ancho
px: 1, // Padding horizontal para el contenedor de la paginación
// Considera un flexGrow o un width/maxWidth si necesitas más control sobre el espacio de la paginación
// Ejemplo: flexGrow: 1, maxWidth: 'calc(100% - 250px)' (para dejar espacio a los totales)
}}>
<GridFooter
sx={{
borderTop: 'none', // Quitar el borde superior del GridFooter interno
width: 'auto', // Permite que el GridFooter se ajuste a su contenido (paginador)
'& .MuiToolbar-root': { // Ajustar padding del toolbar de paginación
paddingLeft: 0, // O un valor pequeño si es necesario
paddingRight: 0,
},
// Mantenemos oculto el contador de filas seleccionadas si no lo queremos
'& .MuiDataGrid-selectedRowCount': { display: 'none' },
}}
/>
</Box>
{/* Box para los totales personalizados */}
<Box sx={{
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
whiteSpace: 'nowrap', // Evita que los totales hagan salto de línea
overflowX: 'auto', // Scroll DENTRO de este Box si los totales son muy anchos
px: 2, // Padding horizontal para el contenedor de los totales (ajusta pr:2 de tu ejemplo)
flexShrink: 1, // Permitir que este contenedor se encoja si la paginación necesita más espacio
}}>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[0].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>TOTALES:</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[1].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.elDia)}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[2].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.elPlata)}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[3].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{numberFormatter(totalGeneralDiarios.vendidos)}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[4].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{currencyFormatter(totalGeneralDiarios.importeElDia)}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[5].width, textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{currencyFormatter(totalGeneralDiarios.importeElPlata)}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsDiarios[6].width, textAlign: 'right', fontWeight: 'bold' }}>{currencyFormatter(totalGeneralDiarios.importeTotal)}</Typography>
</Box>
</GridFooterContainer>
);
};
const columnsDiarios: GridColDef<GridDiariosItem>[] = [
{ field: 'canilla', headerName: 'Nombre', width: 250, flex: 1.5 },
{ field: 'elDia', headerName: 'El Día (Cant)', type: 'number', width: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) },
{ field: 'elPlata', headerName: 'El Plata (Cant)', type: 'number', width: 120, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) },
{ field: 'vendidos', headerName: 'Total Vendidos', type: 'number', width: 130, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) },
{ field: 'importeElDia', headerName: 'Imp. El Día', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) },
{ field: 'importeElPlata', headerName: 'Imp. El Plata', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) },
{ field: 'importeTotal', headerName: 'Importe Total', type: 'number', width: 160, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) },
];
const columnsPublicaciones: GridColDef<GridPubItem>[] = [
{ field: 'canilla', headerName: 'Canillita', width: 250, flex: 1.2 },
{ field: 'publicacion', headerName: 'Publicación', width: 200, flex: 1 },
{ field: 'totalCantSalida', headerName: 'Llevados', type: 'number', width: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) },
{ field: 'totalCantEntrada', headerName: 'Devueltos', type: 'number', width: 100, align: 'right', headerAlign: 'right', valueFormatter: (value) => numberFormatter(value as number) },
{ field: 'totalRendir', headerName: 'A Rendir', type: 'number', width: 150, align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(value as number) },
];
const rowsDiarios = useMemo(() => reporteDiariosData, [reporteDiariosData]);
const rowsPubs = useMemo(() => reportePubData, [reportePubData]);
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>
<SeleccionaReporteListadoDistMensual
onGenerarReporte={handleGenerarReporte}
isLoading={loading}
apiErrorMessage={apiErrorParams}
/>
</Paper>
</Box>
);
}
if (!loading && !puedeVerReporte && !showParamSelector) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error" sx={{ m: 2 }}>No tiene permiso para ver este reporte.</Alert>
<Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small">
Volver
</Button>
</Box>
);
}
const renderReportContent = () => {
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>;
if (error && !loading) return <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>;
if (currentReportVariant === 'diarios') {
if (reporteDiariosData.length === 0) return <Typography sx={{ mt: 2, fontStyle: 'italic' }}>No hay datos para la variante "Desglose por Diarios".</Typography>;
return (
<Paper sx={{ height: 'calc(100vh - 320px)', width: '100%', mt: 2 }}>
<DataGrid
rows={rowsDiarios}
columns={columnsDiarios}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
slots={{ footer: FooterDiarios }}
density="compact"
hideFooterSelectedRowCount
disableRowSelectionOnClick
initialState={{
pagination: { paginationModel: { pageSize: 100 } },
}}
pageSizeOptions={[25, 50, 100]}
/>
</Paper>
);
}
if (currentReportVariant === 'publicaciones') {
if (reportePubData.length === 0) return <Typography sx={{ mt: 2, fontStyle: 'italic' }}>No hay datos para la variante "Desglose por Publicación".</Typography>;
return (
<Paper sx={{ height: 'calc(100vh - 320px)', width: '100%', mt: 2 }}>
<DataGrid
rows={rowsPubs}
columns={columnsPublicaciones}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
slots={{ footer: FooterSimple }} // Para esta tabla, un footer simple sin totales complejos por ahora
density="compact"
initialState={{
pagination: { paginationModel: { pageSize: 100 } },
}}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick
/>
</Paper>
);
}
return null;
};
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h5">Listado Distribución Mensual ({currentParams?.nombreTipoVendedor})</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button onClick={handleGenerarYAbrirPdf} variant="contained" disabled={loadingPdf || (reporteDiariosData.length === 0 && reportePubData.length === 0) || !!error} size="small">
{loadingPdf ? <CircularProgress size={20} color="inherit" /> : "Abrir PDF"}
</Button>
<Button onClick={handleExportToExcel} variant="outlined" disabled={(reporteDiariosData.length === 0 && reportePubData.length === 0) || !!error} size="small">
Exportar a Excel
</Button>
<Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small">
Nuevos Parámetros
</Button>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Mes: {currentParams?.mesAnio || '-'}
</Typography>
{renderReportContent()}
</Box>
);
};
export default ReporteListadoDistMensualPage;

View File

@@ -8,6 +8,7 @@ import { esES } from '@mui/x-data-grid/locales';
import reportesService from '../../services/Reportes/reportesService';
import type { ListadoDistribucionDistribuidoresResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionDistribuidoresResponseDto';
import SeleccionaReporteListadoDistribucion from './SeleccionaReporteListadoDistribucion';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
@@ -26,6 +27,8 @@ const ReporteListadoDistribucionPage: React.FC = () => {
nombrePublicacion?: string;
nombreDistribuidor?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR002");
// --- ESTADO PARA TOTALES CALCULADOS (PARA EL FOOTER DEL DETALLE) ---
const [totalesDetalle, setTotalesDetalle] = useState({
@@ -35,7 +38,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
promedioGeneralVentaNeta: 0,
porcentajeDevolucionGeneral: 0,
});
const [totalesPromedios, setTotalesPromedios] = useState({
cantDias: 0,
promLlevados: 0,
@@ -51,12 +54,17 @@ const ReporteListadoDistribucionPage: React.FC = () => {
fechaDesde: string;
fechaHasta: string;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
setReportData(null);
setTotalesDetalle({ llevados:0, devueltos:0, ventaNeta:0, promedioGeneralVentaNeta:0, porcentajeDevolucionGeneral:0 });
setTotalesPromedios({ cantDias:0, promLlevados:0, promDevueltos:0, promVentas:0, porcentajeDevolucionGeneral:0});
setTotalesDetalle({ llevados: 0, devueltos: 0, ventaNeta: 0, promedioGeneralVentaNeta: 0, porcentajeDevolucionGeneral: 0 });
setTotalesPromedios({ cantDias: 0, promLlevados: 0, promDevueltos: 0, promVentas: 0, porcentajeDevolucionGeneral: 0 });
const pubService = (await import('../../services/Distribucion/publicacionService')).default;
@@ -74,17 +82,17 @@ const ReporteListadoDistribucionPage: React.FC = () => {
try {
const data = await reportesService.getListadoDistribucionDistribuidores(params);
let acumuladoVentaNeta = 0;
let diasConVenta = 0;
const detalleConCalculos = data.detalleSimple.map((item, index) => {
const llevados = item.llevados || 0;
const devueltos = item.devueltos || 0;
const ventaNeta = llevados - devueltos;
if (llevados > 0) diasConVenta++; // O si ventaNeta > 0, dependiendo de la definición de "día con actividad"
acumuladoVentaNeta += ventaNeta;
return {
...item,
id: `simple-${index}`,
@@ -97,13 +105,13 @@ const ReporteListadoDistribucionPage: React.FC = () => {
const totalLlevadosDetalle = detalleConCalculos.reduce((sum, item) => sum + (item.llevados || 0), 0);
const totalDevueltosDetalle = detalleConCalculos.reduce((sum, item) => sum + (item.devueltos || 0), 0);
const totalVentaNetaDetalle = totalLlevadosDetalle - totalDevueltosDetalle;
setTotalesDetalle({
llevados: totalLlevadosDetalle,
devueltos: totalDevueltosDetalle,
ventaNeta: totalVentaNetaDetalle,
promedioGeneralVentaNeta: diasConVenta > 0 ? totalVentaNetaDetalle / diasConVenta : 0,
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
llevados: totalLlevadosDetalle,
devueltos: totalDevueltosDetalle,
ventaNeta: totalVentaNetaDetalle,
promedioGeneralVentaNeta: diasConVenta > 0 ? totalVentaNetaDetalle / diasConVenta : 0,
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
});
@@ -112,7 +120,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
id: `prom-${index}`,
porcentajeDevolucion: item.promedio_Llevados > 0 ? (item.promedio_Devueltos / item.promedio_Llevados) * 100 : 0,
}));
// Calcular totales para la tabla de promedios (ponderados por Cant. Días)
const totalDiasPromedios = promediosConCalculos.reduce((sum, item) => sum + (item.cant || 0), 0);
const totalPonderadoLlevados = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0);
@@ -190,7 +198,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
"Prom. Ventas": rest.promedio_Ventas,
"% Devolución": (rest as any).porcentajeDevolucion, // Ya calculado
}));
// Fila de totales para promedios
// Fila de totales para promedios
promediosToExport.push({
"Día Semana": "General",
"Cant. Días": totalesPromedios.cantDias,
@@ -222,7 +230,7 @@ const ReporteListadoDistribucionPage: React.FC = () => {
setError(null);
try {
const blob = await reportesService.getListadoDistribucionDistribuidoresPdf(currentParams);
if (blob.type === "application/json") {
if (blob.type === "application/json") {
const text = await blob.text();
const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF.";
setError(msg);
@@ -261,15 +269,15 @@ const ReporteListadoDistribucionPage: React.FC = () => {
// --- Custom Footer para Detalle Diario ---
const CustomFooterDetalle = () => (
<GridFooterContainer
sx={{
<GridFooterContainer
sx={{
// Asegurar que el contenedor pueda usar todo el ancho
// y que los items internos puedan distribuirse.
// justifyContent: 'space-between' // Esto podría ayudar
}}
>
{/* Contenedor para los elementos del footer por defecto (paginación, etc.) */}
<Box sx={{
<Box sx={{
// flexGrow: 1, // Originalmente teníamos esto, puede ser muy agresivo
display: 'flex', // Para alinear los items del paginador por defecto
alignItems: 'center',
@@ -277,66 +285,66 @@ const ReporteListadoDistribucionPage: React.FC = () => {
minWidth: '400px', // AJUSTA ESTE VALOR según lo que necesites
flexShrink: 0, // Evita que se encoja demasiado si los totales son muy anchos
}}>
<GridFooter sx={{
borderTop: 'none',
// '& .MuiTablePagination-toolbar': { // Para los elementos dentro del paginador
// flexWrap: 'wrap', // Permitir que los elementos internos del paginador se envuelvan
// justifyContent: 'flex-start',
// },
// '& .MuiTablePagination-spacer': { // El espaciador puede ser un problema
// display: 'none', // Prueba quitándolo
// }
}} />
<GridFooter sx={{
borderTop: 'none',
// '& .MuiTablePagination-toolbar': { // Para los elementos dentro del paginador
// flexWrap: 'wrap', // Permitir que los elementos internos del paginador se envuelvan
// justifyContent: 'flex-start',
// },
// '& .MuiTablePagination-spacer': { // El espaciador puede ser un problema
// display: 'none', // Prueba quitándolo
// }
}} />
</Box>
{/* Contenedor para tus totales, alineado a la derecha */}
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto', // Empuja a la derecha
flexShrink: 1, // Permite que este se encoja si es necesario, pero no demasiado
overflowX: 'auto', // Si los totales son muchos, que tengan su propio scroll
whiteSpace: 'nowrap', // Evitar que los textos de totales se partan
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto', // Empuja a la derecha
flexShrink: 1, // Permite que este se encoja si es necesario, pero no demasiado
overflowX: 'auto', // Si los totales son muchos, que tengan su propio scroll
whiteSpace: 'nowrap', // Evitar que los textos de totales se partan
}}>
{/* Mantén esta estructura, pero quizás necesitas jugar con los minWidth/flex de los Typography */}
<Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { minimumFractionDigits: 3, maximumFractionDigits: 3 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { minimumFractionDigits: 3, maximumFractionDigits: 3 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box>
</GridFooterContainer>
);
// --- Custom Footer para Promedios por Día (Ajustado para flex) ---
const CustomFooterPromedios = () => (
<GridFooterContainer sx={{ /* justifyContent: 'space-between' */ }}>
<Box sx={{
<Box sx={{
// flexGrow: 1,
display: 'flex',
display: 'flex',
alignItems: 'center',
minWidth: '400px', // AJUSTA ESTE VALOR
flexShrink: 0,
}}>
<GridFooter sx={{ borderTop: 'none' }} />
</Box>
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto',
flexShrink: 1,
overflowX: 'auto',
whiteSpace: 'nowrap',
<Box sx={{
p: 1,
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
marginLeft: 'auto',
flexShrink: 1,
overflowX: 'auto',
whiteSpace: 'nowrap',
}}>
<Typography variant="subtitle2" sx={{ flexBasis: columnsPromedios[0].width || 'auto', minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>Generales:</Typography>
<Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[2].flex, minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[3].flex, minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[4].flex, minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[2].flex, minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[3].flex, minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[4].flex, minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ flex: columnsPromedios[5].flex, minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box>
</GridFooterContainer>
@@ -344,6 +352,9 @@ const ReporteListadoDistribucionPage: React.FC = () => {
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>

View File

@@ -0,0 +1,362 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Box, Typography, Paper, CircularProgress, Alert, Button, Tooltip
} from '@mui/material';
import { DataGrid, type GridColDef, GridFooterContainer, GridFooter } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
import reportesService from '../../services/Reportes/reportesService';
import type { NovedadesCanillasReporteDto } from '../../models/dtos/Reportes/NovedadesCanillasReporteDto';
import type { CanillaGananciaReporteDto } from '../../models/dtos/Reportes/CanillaGananciaReporteDto';
import SeleccionaReporteNovedadesCanillas from './SeleccionaReporteNovedadesCanillas';
import { usePermissions } from '../../hooks/usePermissions';
import * as XLSX from 'xlsx';
import axios from 'axios';
interface NovedadesCanillasReporteGridItem extends NovedadesCanillasReporteDto {
id: string;
}
interface CanillaGananciaGridItem extends CanillaGananciaReporteDto {
id: string;
}
const ReporteNovedadesCanillasPage: React.FC = () => {
const [novedadesData, setNovedadesData] = useState<NovedadesCanillasReporteGridItem[]>([]);
const [gananciasData, setGananciasData] = useState<CanillaGananciaGridItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiErrorParams, setApiErrorParams] = useState<string | null>(null);
const [showParamSelector, setShowParamSelector] = useState(true);
const [currentParams, setCurrentParams] = useState<{
idEmpresa: number;
fechaDesde: string;
fechaHasta: string;
nombreEmpresa?: string;
} | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerReporte = isSuperAdmin || tienePermiso("RR004");
const currencyFormatter = (value: number | null | undefined) => // Helper para formato moneda
value != null ? value.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' }) : '';
const handleGenerarReporte = useCallback(async (params: {
idEmpresa: number;
fechaDesde: string;
fechaHasta: string;
}) => {
if (!puedeVerReporte) {
setError("No tiene permiso para generar este reporte.");
setLoading(false);
return;
}
setLoading(true);
setError(null);
setApiErrorParams(null);
setNovedadesData([]);
setGananciasData([]); // Limpiar datos de ganancias
let empresaNombre = `Empresa ${params.idEmpresa}`;
try {
const empresaService = (await import('../../services/Distribucion/empresaService')).default;
const empData = await empresaService.getEmpresaLookupById(params.idEmpresa);
if (empData) empresaNombre = empData.nombre;
} catch (e) { console.warn("No se pudo obtener nombre de empresa para el reporte", e); }
setCurrentParams({ ...params, nombreEmpresa: empresaNombre });
try {
// Llamadas concurrentes para ambos conjuntos de datos
const [novedadesResult, gananciasResult] = await Promise.all([
reportesService.getNovedadesCanillasReporte(params),
reportesService.getCanillasGananciasReporte(params) // << LLAMAR AL NUEVO SERVICIO
]);
const novedadesConIds = novedadesResult.map((item, index) => ({
...item,
id: `nov-${item.nomApe || 'sinnom'}-${item.fecha || 'sinfec'}-${index}`
}));
setNovedadesData(novedadesConIds);
const gananciasConIds = gananciasResult.map((item, index) => ({
...item,
id: `gan-${item.canilla || 'sincan'}-${index}`
}));
setGananciasData(gananciasConIds);
if (novedadesConIds.length === 0 && gananciasConIds.length === 0) {
setError("No se encontraron datos para los parámetros seleccionados.");
}
setShowParamSelector(false);
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Ocurrió un error al generar el reporte.';
setApiErrorParams(message);
} finally {
setLoading(false);
}
}, [puedeVerReporte]);
const handleVolverAParametros = useCallback(() => {
setShowParamSelector(true);
setNovedadesData([]);
setGananciasData([]); // Limpiar también
setError(null);
setApiErrorParams(null);
setCurrentParams(null);
}, []);
const handleExportToExcel = useCallback(() => {
if (novedadesData.length === 0 && gananciasData.length === 0) { // Chequear ambos
alert("No hay datos para exportar.");
return;
}
const wb = XLSX.utils.book_new();
// Hoja de Ganancias
if (gananciasData.length > 0) {
const gananciasToExport = gananciasData.map(({ id, ...rest }) => ({
"Canilla": rest.canilla,
"Legajo": rest.legajo ?? '-',
"Faltas": rest.faltas ?? 0,
"Francos": rest.francos ?? 0,
"Comisiones": rest.totalRendir ?? 0,
}));
const totalComisiones = gananciasData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0);
gananciasToExport.push({
"Canilla": "Total", "Legajo": "", "Faltas": 0, "Francos": 0,
"Comisiones": totalComisiones
});
const wsGanancias = XLSX.utils.json_to_sheet(gananciasToExport);
const headersGan = Object.keys(gananciasToExport[0] || {});
wsGanancias['!cols'] = headersGan.map(h => {
const maxLen = Math.max(...gananciasToExport.map(row => (row as any)[h]?.toString().length ?? 0), h.length);
return { wch: maxLen + 2 };
});
XLSX.utils.book_append_sheet(wb, wsGanancias, "ResumenCanillas");
}
// Hoja de Novedades
if (novedadesData.length > 0) {
const novedadesToExport = novedadesData.map(({ id, ...rest }) => ({
"Canillita": rest.nomApe,
"Fecha": rest.fecha ? new Date(rest.fecha).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-',
"Detalle": rest.detalle,
}));
const wsNovedades = XLSX.utils.json_to_sheet(novedadesToExport);
const headersNov = Object.keys(novedadesToExport[0] || {});
wsNovedades['!cols'] = headersNov.map(h => {
const maxLen = Math.max(...novedadesToExport.map(row => (row as any)[h]?.toString().length ?? 0), h.length);
if (h === "Detalle") return { wch: Math.max(maxLen + 2, 50) };
return { wch: maxLen + 2 };
});
XLSX.utils.book_append_sheet(wb, wsNovedades, "DetalleNovedades");
}
let fileName = "ReporteNovedadesCanillas";
if (currentParams) {
fileName += `_${currentParams.nombreEmpresa?.replace(/\s+/g, '') || `Emp${currentParams.idEmpresa}`}`;
fileName += `_${currentParams.fechaDesde}_a_${currentParams.fechaHasta}`;
}
fileName += ".xlsx";
XLSX.writeFile(wb, fileName);
}, [novedadesData, gananciasData, currentParams]);
const handleGenerarYAbrirPdf = useCallback(async () => {
// ... (sin cambios, ya que el PDF del backend ya debería estar manejando ambos DataSets)
if (!currentParams) {
setError("Primero debe generar el reporte en pantalla.");
return;
}
if (!puedeVerReporte) {
setError("No tiene permiso para generar este PDF.");
return;
}
setLoadingPdf(true);
setError(null);
try {
const blob = await reportesService.getNovedadesCanillasReportePdf(currentParams);
if (blob.type === "application/json") {
const text = await blob.text();
const msg = JSON.parse(text).message ?? "Error inesperado al generar PDF.";
setError(msg);
} else {
const url = URL.createObjectURL(blob);
const w = window.open(url, '_blank');
if (!w) alert("Permita popups para ver el PDF.");
}
} catch (err: any){
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Ocurrió un error al generar el PDF.';
setError(message);
} finally {
setLoadingPdf(false);
}
}, [currentParams, puedeVerReporte]);
// Columnas para la tabla de Resumen/Ganancias
const columnsGanancias: GridColDef<CanillaGananciaGridItem>[] = [
{ field: 'canilla', headerName: 'Canilla', width: 250, flex: 1.5 },
{ field: 'legajo', headerName: 'Legajo', width: 100, type: 'number' },
{ field: 'faltas', headerName: 'Faltas', width: 100, type: 'number', align: 'right', headerAlign: 'right' },
{ field: 'francos', headerName: 'Francos', width: 100, type: 'number', align: 'right', headerAlign: 'right' },
{ field: 'totalRendir', headerName: 'Comisiones', width: 150, type: 'number', align: 'right', headerAlign: 'right', valueFormatter: (value) => currencyFormatter(Number(value)) },
];
// Columnas para la tabla de Detalles de Novedades (ya existentes)
const columnsNovedades: GridColDef<NovedadesCanillasReporteGridItem>[] = [
{ field: 'nomApe', headerName: 'Canillita', width: 250, flex: 1.5 },
{
field: 'fecha',
headerName: 'Fecha',
width: 120,
type: 'date',
valueGetter: (value) => value ? new Date(value as string) : null,
valueFormatter: (value) => value ? new Date(value as string).toLocaleDateString('es-AR', {timeZone: 'UTC'}) : '-',
},
{ field: 'detalle', headerName: 'Detalle Novedad', flex: 2, minWidth: 350,
renderCell: (params) => (
<Tooltip title={params.value || ''} arrow placement="top">
<Typography variant="body2" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: '100%' }}>
{params.value || '-'}
</Typography>
</Tooltip>
)
},
];
const rowsGanancias = useMemo(() => gananciasData, [gananciasData]);
const rowsNovedades = useMemo(() => novedadesData, [novedadesData]);
const totalComisionesGanancias = useMemo(() =>
gananciasData.reduce((sum, item) => sum + (item.totalRendir ?? 0), 0),
[gananciasData]);
// eslint-disable-next-line react/display-name
const FooterGanancias = () => (
<GridFooterContainer sx={{ justifyContent: 'flex-end', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}>
<GridFooter />
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold' }}>
<Typography variant="subtitle1" sx={{ mr: 2 }}>Total Comisiones:</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{currencyFormatter(totalComisionesGanancias)}</Typography>
</Box>
</GridFooterContainer>
);
const FooterNovedades = () => ( <GridFooterContainer sx={{ justifyContent: 'flex-end', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}><GridFooter /></GridFooterContainer>);
if (showParamSelector) {
if (!loading && !puedeVerReporte) {
return <Alert severity="error" sx={{ m: 2 }}>No tiene permiso para acceder a este reporte.</Alert>;
}
return (
<Box sx={{ p: 2, display: 'flex', justifyContent: 'center', mt: 2 }}>
<Paper sx={{ width: '100%', maxWidth: 600 }} elevation={3}>
<SeleccionaReporteNovedadesCanillas
onGenerarReporte={handleGenerarReporte}
isLoading={loading}
apiErrorMessage={apiErrorParams}
/>
</Paper>
</Box>
);
}
if (!loading && !puedeVerReporte && !showParamSelector) {
// ... (renderizado de "sin permiso" sin cambios)
return (
<Box sx={{ p: 2 }}>
<Alert severity="error" sx={{ m: 2 }}>No tiene permiso para ver este reporte.</Alert>
<Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small">
Volver
</Button>
</Box>
);
}
return (
<Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Typography variant="h5">Reporte: Listado de Novedades Canillitas</Typography> {/* Título más genérico */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button onClick={handleGenerarYAbrirPdf} variant="contained" disabled={loadingPdf || (novedadesData.length === 0 && gananciasData.length === 0) || !!error} size="small">
{loadingPdf ? <CircularProgress size={20} color="inherit" /> : "Abrir PDF"}
</Button>
<Button onClick={handleExportToExcel} variant="outlined" disabled={(novedadesData.length === 0 && gananciasData.length === 0) || !!error} size="small">
Exportar a Excel
</Button>
<Button onClick={handleVolverAParametros} variant="outlined" color="secondary" size="small">
Nuevos Parámetros
</Button>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Empresa: {currentParams?.nombreEmpresa || '-'} |
Período: {currentParams?.fechaDesde ? new Date(currentParams.fechaDesde + 'T00:00:00').toLocaleDateString('es-AR') : ''} al {currentParams?.fechaHasta ? new Date(currentParams.fechaHasta + 'T00:00:00').toLocaleDateString('es-AR') : ''}
</Typography>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{(error && !loading) && <Alert severity="info" sx={{ my: 2 }}>{error}</Alert>}
{/* Sección de Ganancias/Resumen */}
{!loading && !error && currentParams && (
<>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>Resumen de Actividad</Typography>
{gananciasData.length > 0 ? (
<Paper sx={{ height: 250, width: '100%', mb: 3 }}>
<DataGrid
rows={rowsGanancias}
columns={columnsGanancias}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
slots={{ footer: FooterGanancias }}
density="compact"
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick
rowHeight={48}
sx={{
'& .MuiDataGrid-cell': { overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiDataGrid-columnHeaderTitleContainer': { overflow: 'hidden' },
}}
/>
</Paper>
) : (
<Typography sx={{mt:1, mb:3, fontStyle:'italic'}}>No hay datos de resumen de actividad para mostrar.</Typography>
)}
{/* Sección de Detalle de Novedades */}
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>Otras Novedades (Detalle)</Typography>
{novedadesData.length > 0 ? (
<Paper sx={{ height: 250, width: '100%', mb: 3 }}> {/* Ajustar altura si es necesario */}
<DataGrid
rows={rowsNovedades}
columns={columnsNovedades}
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
slots={{ footer: FooterNovedades }}
density="compact"
initialState={{ pagination: { paginationModel: { pageSize: 25 } } }}
pageSizeOptions={[25, 50, 100]}
disableRowSelectionOnClick
rowHeight={48}
sx={{
'& .MuiDataGrid-cell': { overflow: 'hidden', textOverflow: 'ellipsis' },
'& .MuiDataGrid-columnHeaderTitleContainer': { overflow: 'hidden' },
}}
/>
</Paper>
) : (
<Typography sx={{mt:1, fontStyle:'italic'}}>No hay detalles de otras novedades para mostrar.</Typography>
)}
</>
)}
{!loading && !error && novedadesData.length === 0 && gananciasData.length === 0 && currentParams && (
<Typography sx={{mt:2, fontStyle:'italic'}}>No se encontraron datos para los criterios seleccionados.</Typography>
)}
</Box>
);
};
export default ReporteNovedadesCanillasPage;

View File

@@ -21,12 +21,15 @@ const allReportModules: { category: string; label: string; path: string }[] = [
{ 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' },
];
const predefinedCategoryOrder = [
'Balance de Cuentas',
'Listados Distribución',
'Ctrl. Devoluciones',
'Novedades de Canillitas',
'Existencia Papel',
'Movimientos Bobinas',
'Consumos Bobinas',

View File

@@ -4,7 +4,7 @@ import {
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import empresaService from '../../services/Distribucion/empresaService';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
interface SeleccionaReporteControlDevolucionesProps {
onGenerarReporte: (params: {
@@ -24,7 +24,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev
}) => {
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
const [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingEmpresas, setLoadingEmpresas] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
@@ -32,7 +32,7 @@ const SeleccionaReporteControlDevoluciones: React.FC<SeleccionaReporteControlDev
const fetchEmpresas = async () => {
setLoadingEmpresas(true);
try {
const data = await empresaService.getAllEmpresas(); // Solo habilitadas
const data = await empresaService.getEmpresasDropdown(); // Solo habilitadas
setEmpresas(data);
} catch (error) {
console.error("Error al cargar empresas:", error);

View File

@@ -3,9 +3,9 @@ import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import distribuidorService from '../../services/Distribucion/distribuidorService';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService';
interface SeleccionaReporteCuentasDistribuidoresProps {
@@ -30,8 +30,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDto[]>([]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -40,8 +40,8 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidores(), // Asume que este servicio existe
empresaService.getAllEmpresas() // Asume que este servicio existe
distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe
empresaService.getEmpresasDropdown() // Asume que este servicio existe
]);
setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla
setEmpresas(empData);

View File

@@ -0,0 +1,118 @@
import React, { useState } from 'react';
import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl,
ToggleButtonGroup, ToggleButton, RadioGroup, FormControlLabel, Radio
} from '@mui/material';
export type TipoListadoDistMensual = 'diarios' | 'publicaciones';
interface SeleccionaReporteListadoDistMensualProps {
onGenerarReporte: (params: {
fechaDesde: string; // yyyy-MM-dd (primer día del mes)
fechaHasta: string; // yyyy-MM-dd (último día del mes)
esAccionista: boolean;
tipoReporte: TipoListadoDistMensual;
}) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
apiErrorMessage?: string | null;
}
const SeleccionaReporteListadoDistMensual: React.FC<SeleccionaReporteListadoDistMensualProps> = ({
onGenerarReporte,
isLoading,
apiErrorMessage
}) => {
const [mesAnio, setMesAnio] = useState<string>(new Date().toISOString().substring(0, 7)); // Formato "YYYY-MM"
const [esAccionista, setEsAccionista] = useState<boolean>(false); // Default a Canillitas
const [tipoReporte, setTipoReporte] = useState<TipoListadoDistMensual>('publicaciones'); // Default a Por Publicación
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!mesAnio) errors.mesAnio = 'Debe seleccionar un Mes/Año.';
// esAccionista y tipoReporte siempre tendrán un valor debido a los defaults y ToggleButton/RadioGroup
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleGenerar = () => {
if (!validate()) return;
const [year, month] = mesAnio.split('-').map(Number);
const fechaDesde = new Date(year, month - 1, 1).toISOString().split('T')[0];
const fechaHasta = new Date(year, month, 0).toISOString().split('T')[0]; // Último día del mes
onGenerarReporte({
fechaDesde,
fechaHasta,
esAccionista,
tipoReporte
});
};
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}>
<Typography variant="h6" gutterBottom>
Parámetros: Listado Distribución Mensual
</Typography>
<TextField
label="Mes y Año"
type="month"
value={mesAnio}
onChange={(e) => { setMesAnio(e.target.value); setLocalErrors(p => ({ ...p, mesAnio: null })); }}
margin="normal"
fullWidth
required
error={!!localErrors.mesAnio}
helperText={localErrors.mesAnio}
disabled={isLoading}
InputLabelProps={{ shrink: true }}
/>
<FormControl component="fieldset" margin="normal" fullWidth disabled={isLoading}>
<Typography component="legend" variant="subtitle2" sx={{mb:0.5, color: 'rgba(0, 0, 0, 0.6)'}}>Tipo de Vendedor</Typography>
<ToggleButtonGroup
color="primary"
value={esAccionista ? 'accionistas' : 'canillitas'}
exclusive
onChange={(_, newValue) => {
if (newValue !== null) setEsAccionista(newValue === 'accionistas');
}}
aria-label="Tipo de Vendedor"
size="small"
>
<ToggleButton value="canillitas">Canillitas</ToggleButton>
<ToggleButton value="accionistas">Accionistas</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<FormControl component="fieldset" margin="normal" fullWidth disabled={isLoading}>
<Typography component="legend" variant="subtitle2" sx={{mb:0.5, color: 'rgba(0, 0, 0, 0.6)'}}>Variante del Reporte</Typography>
<RadioGroup
row
aria-label="Variante del Reporte"
name="tipoReporte"
value={tipoReporte}
onChange={(e) => setTipoReporte(e.target.value as TipoListadoDistMensual)}
>
<FormControlLabel value="publicaciones" control={<Radio size="small" />} label="Por Publicación" />
<FormControlLabel value="diarios" control={<Radio size="small" />} label="Por Diarios (El Día/El Plata)" />
</RadioGroup>
</FormControl>
{apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={handleGenerar} variant="contained" disabled={isLoading}>
{isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'}
</Button>
</Box>
</Box>
);
};
export default SeleccionaReporteListadoDistMensual;

View File

@@ -3,9 +3,9 @@ import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { PublicacionDto } from '../../models/dtos/Distribucion/PublicacionDto';
import type { PublicacionDropdownDto } from '../../models/dtos/Distribucion/PublicacionDropdownDto';
import publicacionService from '../../services/Distribucion/publicacionService';
import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import distribuidorService from '../../services/Distribucion/distribuidorService';
interface SeleccionaReporteListadoDistribucionProps {
@@ -30,8 +30,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis
const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDto[]>([]);
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -40,8 +40,8 @@ const SeleccionaReporteListadoDistribucion: React.FC<SeleccionaReporteListadoDis
setLoadingDropdowns(true);
try {
const [distData, pubData] = await Promise.all([
distribuidorService.getAllDistribuidores(),
publicacionService.getAllPublicaciones(undefined, undefined, true) // Solo habilitadas
distribuidorService.getAllDistribuidoresDropdown(),
publicacionService.getPublicacionesForDropdown(true) // Solo habilitadas
]);
setDistribuidores(distData.map(d => d));
setPublicaciones(pubData.map(p => p));

View File

@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react';
import {
Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService';
interface SeleccionaReporteNovedadesCanillasProps {
onGenerarReporte: (params: {
idEmpresa: number;
fechaDesde: string;
fechaHasta: string;
}) => Promise<void>;
onCancel?: () => void; // Opcional si se usa dentro de ReportesIndexPage
isLoading?: boolean;
apiErrorMessage?: string | null;
}
const SeleccionaReporteNovedadesCanillas: React.FC<SeleccionaReporteNovedadesCanillasProps> = ({
onGenerarReporte,
// onCancel,
isLoading,
apiErrorMessage
}) => {
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
const [fechaDesde, setFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [fechaHasta, setFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const fetchEmpresas = async () => {
setLoadingDropdowns(true);
try {
const data = await empresaService.getEmpresasDropdown();
setEmpresas(data);
} catch (error) {
console.error("Error al cargar empresas:", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar empresas.' }));
} finally {
setLoadingDropdowns(false);
}
};
fetchEmpresas();
}, []);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!idEmpresa) errors.idEmpresa = 'Debe seleccionar una empresa.';
if (!fechaDesde) errors.fechaDesde = 'Fecha Desde es obligatoria.';
if (!fechaHasta) errors.fechaHasta = 'Fecha Hasta es obligatoria.';
if (fechaDesde && fechaHasta && new Date(fechaDesde) > new Date(fechaHasta)) {
errors.fechaHasta = 'Fecha Hasta no puede ser anterior a Fecha Desde.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleGenerar = () => {
if (!validate()) return;
onGenerarReporte({
idEmpresa: Number(idEmpresa),
fechaDesde,
fechaHasta
});
};
return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}>
<Typography variant="h6" gutterBottom>
Parámetros: Reporte Novedades de Canillitas
</Typography>
<FormControl fullWidth margin="normal" error={!!localErrors.idEmpresa} disabled={isLoading || loadingDropdowns}>
<InputLabel id="empresa-novedades-select-label" required>Empresa</InputLabel>
<Select
labelId="empresa-novedades-select-label"
label="Empresa"
value={idEmpresa}
onChange={(e) => { setIdEmpresa(e.target.value as number); setLocalErrors(p => ({ ...p, idEmpresa: null })); }}
>
<MenuItem value="" disabled><em>Seleccione una empresa</em></MenuItem>
{empresas.map((e) => (
<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>
))}
</Select>
{localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>}
</FormControl>
<TextField
label="Fecha Desde"
type="date"
value={fechaDesde}
onChange={(e) => { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }}
margin="normal"
fullWidth
required
error={!!localErrors.fechaDesde}
helperText={localErrors.fechaDesde}
disabled={isLoading}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="Fecha Hasta"
type="date"
value={fechaHasta}
onChange={(e) => { setFechaHasta(e.target.value); setLocalErrors(p => ({ ...p, fechaHasta: null })); }}
margin="normal"
fullWidth
required
error={!!localErrors.fechaHasta}
helperText={localErrors.fechaHasta}
disabled={isLoading}
InputLabelProps={{ shrink: true }}
/>
{apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>}
{localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{/* {onCancel && <Button onClick={onCancel} color="secondary" disabled={isLoading}>Cancelar</Button>} */}
<Button onClick={handleGenerar} variant="contained" disabled={isLoading || loadingDropdowns}>
{isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'}
</Button>
</Box>
</Box>
);
};
export default SeleccionaReporteNovedadesCanillas;

View File

@@ -1,27 +1,79 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Button, Paper, CircularProgress, Alert
Box, Typography, Button, Paper, CircularProgress, Alert
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import perfilService from '../../services/Usuarios/perfilService';
import type { PermisoAsignadoDto } from '../../models/dtos/Usuarios/PermisoAsignadoDto';
import type { PerfilDto } from '../../models/dtos/Usuarios/PerfilDto';
import { usePermissions } from '../../hooks/usePermissions'; // Para verificar si el usuario actual puede estar aquí
import { usePermissions as usePagePermissions } from '../../hooks/usePermissions'; // Renombrar para evitar conflicto
import axios from 'axios';
import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklist'; // Importar el componente
import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklist';
const SECCION_PERMISSIONS_PREFIX = "SS";
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS002") return "Contables";
if (codAcc === "SS003") return "Impresión";
if (codAcc === "SS004") return "Reportes";
if (codAcc === "SS005") return "Radios";
if (codAcc === "SS006") return "Usuarios";
return null;
};
const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
const moduloLower = permisoModulo.toLowerCase();
if (moduloLower.includes("distribuidores") ||
moduloLower.includes("canillas") ||
moduloLower.includes("publicaciones distribución") ||
moduloLower.includes("zonas distribuidores") ||
moduloLower.includes("movimientos distribuidores") ||
moduloLower.includes("empresas") ||
moduloLower.includes("otros destinos") ||
moduloLower.includes("ctrl. devoluciones") ||
moduloLower.includes("movimientos canillas") ||
moduloLower.includes("salidas otros destinos")) {
return "Distribución";
}
if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) {
return "Contables";
}
if (moduloLower.includes("impresión tiradas") ||
moduloLower.includes("impresión bobinas") ||
moduloLower.includes("impresión plantas") ||
moduloLower.includes("tipos bobinas")) {
return "Impresión";
}
if (moduloLower.includes("radios")) {
return "Radios";
}
if (moduloLower.includes("usuarios") ||
moduloLower.includes("perfiles")) {
return "Usuarios";
}
if (moduloLower.includes("reportes")) {
return "Reportes";
}
if (moduloLower.includes("permisos")) {
return "Permisos (Definición)";
}
return permisoModulo;
};
const AsignarPermisosAPerfilPage: React.FC = () => {
const { idPerfil } = useParams<{ idPerfil: string }>();
const navigate = useNavigate();
const { tienePermiso, isSuperAdmin } = usePermissions();
const { tienePermiso: tienePermisoPagina, isSuperAdmin } = usePagePermissions(); // Renombrado
const puedeAsignar = isSuperAdmin || tienePermiso("PU004");
const puedeAsignar = isSuperAdmin || tienePermisoPagina("PU004");
const [perfil, setPerfil] = useState<PerfilDto | null>(null);
const [permisosDisponibles, setPermisosDisponibles] = useState<PermisoAsignadoDto[]>([]);
// Usamos un Set para los IDs de los permisos seleccionados para eficiencia
const [permisosSeleccionados, setPermisosSeleccionados] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -32,29 +84,28 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
const cargarDatos = useCallback(async () => {
if (!puedeAsignar) {
setError("Acceso denegado. No tiene permiso para asignar permisos.");
setLoading(false);
return;
setError("Acceso denegado. No tiene permiso para asignar permisos.");
setLoading(false);
return;
}
if (isNaN(idPerfilNum)) {
setError("ID de Perfil inválido.");
setLoading(false);
return;
setError("ID de Perfil inválido.");
setLoading(false);
return;
}
setLoading(true); setError(null); setSuccessMessage(null);
try {
const [perfilData, permisosData] = await Promise.all([
perfilService.getPerfilById(idPerfilNum),
perfilService.getPermisosPorPerfil(idPerfilNum)
perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado'
]);
setPerfil(perfilData);
setPermisosDisponibles(permisosData);
// Inicializar los permisos seleccionados basados en los que vienen 'asignado: true'
setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id)));
} catch (err) {
console.error(err);
setError('Error al cargar datos del perfil o permisos.');
if (axios.isAxiosError(err) && err.response?.status === 404) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
setError(`Perfil con ID ${idPerfilNum} no encontrado.`);
}
} finally {
@@ -66,22 +117,83 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
cargarDatos();
}, [cargarDatos]);
const handlePermisoChange = (permisoId: number, asignado: boolean) => {
setPermisosSeleccionados(prev => {
const next = new Set(prev);
if (asignado) {
next.add(permisoId);
} else {
next.delete(permisoId);
}
return next;
const handlePermisoChange = useCallback((
permisoId: number,
asignadoViaCheckboxHijo: boolean, // Este valor es el 'e.target.checked' si el clic fue en un hijo
esPermisoSeccionClick = false,
moduloConceptualAsociado?: string // Este es el módulo conceptual del padre SSxxx o del grupo del hijo
) => {
setPermisosSeleccionados(prevSelected => {
const newSelected = new Set(prevSelected);
const permisoActual = permisosDisponibles.find(p => p.id === permisoId);
if (!permisoActual) return prevSelected;
const permisosDelModuloHijo = moduloConceptualAsociado
? permisosDisponibles.filter(p => {
const mc = getModuloConceptualDelPermiso(p.modulo); // Usar la función helper
return mc === moduloConceptualAsociado && !p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX);
})
: [];
if (esPermisoSeccionClick && moduloConceptualAsociado) {
const idPermisoSeccion = permisoActual.id;
const estabaSeccionSeleccionada = prevSelected.has(idPermisoSeccion);
const todosHijosEstabanSeleccionados = permisosDelModuloHijo.length > 0 && permisosDelModuloHijo.every(p => prevSelected.has(p.id));
const ningunHijoEstabaSeleccionado = permisosDelModuloHijo.every(p => !prevSelected.has(p.id));
if (!estabaSeccionSeleccionada) { // Estaba Off, pasa a "Solo Sección" (Indeterminate si hay hijos)
newSelected.add(idPermisoSeccion);
// NO se marcan los hijos
} else if (estabaSeccionSeleccionada && (ningunHijoEstabaSeleccionado || !todosHijosEstabanSeleccionados) && permisosDelModuloHijo.length > 0 ) {
// Estaba "Solo Sección" o "Parcial Hijos", pasa a "Sección + Todos los Hijos"
newSelected.add(idPermisoSeccion); // Asegurar
permisosDelModuloHijo.forEach(p => newSelected.add(p.id));
} else { // Estaba "Sección + Todos los Hijos" (o no había hijos), pasa a Off
newSelected.delete(idPermisoSeccion);
permisosDelModuloHijo.forEach(p => newSelected.delete(p.id));
}
} else if (!esPermisoSeccionClick && moduloConceptualAsociado) { // Clic en un permiso hijo
if (asignadoViaCheckboxHijo) {
newSelected.add(permisoId);
const permisoSeccionPadre = permisosDisponibles.find(
ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado
);
if (permisoSeccionPadre && !newSelected.has(permisoSeccionPadre.id)) {
newSelected.add(permisoSeccionPadre.id); // Marcar padre si no estaba
}
} else { // Desmarcando un hijo
newSelected.delete(permisoId);
const permisoSeccionPadre = permisosDisponibles.find(
ps => ps.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX) && getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptualAsociado
);
if (permisoSeccionPadre) {
const algunOtroHijoSeleccionado = permisosDelModuloHijo.some(p => p.id !== permisoId && newSelected.has(p.id));
if (!algunOtroHijoSeleccionado && newSelected.has(permisoSeccionPadre.id)) {
// Si era el último hijo y el padre estaba marcado, NO desmarcamos el padre automáticamente.
// El estado indeterminate se encargará visualmente.
// Si quisiéramos que se desmarque el padre, aquí iría: newSelected.delete(permisoSeccionPadre.id);
}
}
}
} else { // Permiso sin módulo conceptual asociado (ej: "Permisos (Definición)")
if (asignadoViaCheckboxHijo) {
newSelected.add(permisoId);
} else {
newSelected.delete(permisoId);
}
}
if (successMessage) setSuccessMessage(null);
if (error) setError(null);
return newSelected;
});
// Limpiar mensajes al cambiar selección
if (successMessage) setSuccessMessage(null);
if (error) setError(null);
};
}, [permisosDisponibles, successMessage, error]);
const handleGuardarCambios = async () => {
// ... (sin cambios) ...
if (!puedeAsignar || !perfil) return;
setSaving(true); setError(null); setSuccessMessage(null);
try {
@@ -89,13 +201,12 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
permisosIds: Array.from(permisosSeleccionados)
});
setSuccessMessage('Permisos actualizados correctamente.');
// Opcional: recargar datos, aunque el estado local ya está actualizado
// cargarDatos();
await cargarDatos();
} catch (err: any) {
console.error(err);
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Error al guardar los permisos.';
? err.response.data.message
: 'Error al guardar los permisos.';
setError(message);
} finally {
setSaving(false);
@@ -103,56 +214,54 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
}
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
}
if (error && !perfil) { // Si hay un error crítico al cargar el perfil
return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
}
if (!puedeAsignar) {
return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>;
}
if (!perfil) { // Si no hay error, pero el perfil es null después de cargar (no debería pasar si no hay error)
return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado.</Alert>;
}
if (error && !perfil) {
return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
}
if (!puedeAsignar) {
return <Alert severity="error" sx={{ m: 2 }}>Acceso denegado.</Alert>;
}
if (!perfil && !loading) {
return <Alert severity="warning" sx={{ m: 2 }}>Perfil no encontrado o error al cargar.</Alert>;
}
return (
<Box sx={{ p: 1 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}>
Volver a Perfiles
</Button>
<Typography variant="h5" gutterBottom>
Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
ID Perfil: {perfil?.id}
</Typography>
return (
<Box sx={{ p: 1 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/usuarios/perfiles')} sx={{ mb: 2 }}>
Volver a Perfiles
</Button>
<Typography variant="h5" gutterBottom>
Asignar Permisos al Perfil: {perfil?.nombrePerfil || 'Cargando...'}
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
ID Perfil: {perfil?.id}
</Typography>
{error && !successMessage && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{successMessage && <Alert severity="success" sx={{ mb: 2 }}>{successMessage}</Alert>}
<Paper sx={{ p: 2, mt: 2 }}>
<PermisosChecklist
permisosDisponibles={permisosDisponibles}
permisosSeleccionados={permisosSeleccionados}
onPermisoChange={handlePermisoChange}
disabled={saving}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
startIcon={saving ? <CircularProgress size={20} color="inherit" /> : <SaveIcon />}
onClick={handleGuardarCambios}
disabled={saving || !puedeAsignar}
>
Guardar Cambios
</Button>
<Paper sx={{ p: { xs: 1, sm: 2 }, mt: 2 }}>
<PermisosChecklist
permisosDisponibles={permisosDisponibles}
permisosSeleccionados={permisosSeleccionados}
onPermisoChange={handlePermisoChange}
disabled={saving}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
startIcon={saving ? <CircularProgress size={20} color="inherit" /> : <SaveIcon />}
onClick={handleGuardarCambios}
disabled={saving || !puedeAsignar}
>
Guardar Cambios
</Button>
</Box>
</Paper>
</Box>
</Paper>
</Box>
);
);
};
export default AsignarPermisosAPerfilPage;