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

@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert, InputAdornment
} from '@mui/material';
import type { SaldoGestionDto } from '../../../models/dtos/Contables/SaldoGestionDto';
import type { AjusteSaldoRequestDto } from '../../../models/dtos/Contables/AjusteSaldoRequestDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 450 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 3,
};
interface AjusteSaldoModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: AjusteSaldoRequestDto) => Promise<void>; // El padre maneja la recarga
saldoParaAjustar: SaldoGestionDto | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
open,
onClose,
onSubmit,
saldoParaAjustar,
errorMessage,
clearErrorMessage
}) => {
const [montoAjuste, setMontoAjuste] = useState<string>('');
const [justificacion, setJustificacion] = useState('');
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
if (open) {
setMontoAjuste('');
setJustificacion('');
setLocalErrors({});
clearErrorMessage();
}
}, [open, clearErrorMessage]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
const numMontoAjuste = parseFloat(montoAjuste);
if (!montoAjuste.trim()) {
errors.montoAjuste = 'El monto de ajuste es obligatorio.';
} else if (isNaN(numMontoAjuste)) {
errors.montoAjuste = 'El monto debe ser un número.';
} else if (numMontoAjuste === 0) {
errors.montoAjuste = 'El monto de ajuste no puede ser cero.';
}
if (!justificacion.trim()) {
errors.justificacion = 'La justificación es obligatoria.';
} else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) {
errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion') => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
if (!validate() || !saldoParaAjustar) return;
setLoading(true);
try {
const dataToSubmit: AjusteSaldoRequestDto = {
destino: saldoParaAjustar.destino as 'Distribuidores' | 'Canillas',
idDestino: saldoParaAjustar.idDestino,
idEmpresa: saldoParaAjustar.idEmpresa,
montoAjuste: parseFloat(montoAjuste),
justificacion,
};
await onSubmit(dataToSubmit);
onClose(); // Cerrar en éxito (el padre recargará)
} catch (error: any) {
// El error de API es manejado por la página padre
console.error("Error en submit de AjusteSaldoModal:", error);
} finally {
setLoading(false);
}
};
if (!saldoParaAjustar) return null;
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
Ajustar Saldo Manualmente
</Typography>
<Typography variant="body2" gutterBottom>
Destinatario: <strong>{saldoParaAjustar.nombreDestinatario}</strong> ({saldoParaAjustar.destino})
</Typography>
<Typography variant="body2" gutterBottom>
Empresa: <strong>{saldoParaAjustar.nombreEmpresa}</strong>
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Saldo Actual: <strong>{saldoParaAjustar.monto.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
label="Monto de Ajuste (+/-)"
type="number"
fullWidth
required
value={montoAjuste}
onChange={(e) => { setMontoAjuste(e.target.value); handleInputChange('montoAjuste'); }}
margin="normal"
error={!!localErrors.montoAjuste}
helperText={localErrors.montoAjuste || 'Ingrese un valor positivo para aumentar deuda o negativo para disminuirla.'}
disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }}
inputProps={{ step: "0.01" }}
autoFocus
/>
<TextField
label="Justificación del Ajuste"
fullWidth
required
value={justificacion}
onChange={(e) => { setJustificacion(e.target.value); handleInputChange('justificacion'); }}
margin="normal"
multiline
rows={3}
error={!!localErrors.justificacion}
helperText={localErrors.justificacion || ''}
disabled={loading}
inputProps={{ maxLength: 250 }}
/>
{errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Aplicar Ajuste'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default AjusteSaldoModal;

View File

@@ -8,14 +8,14 @@ import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
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 { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto';
// import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto'; // Ya no es necesario cargar todos los canillitas aquí
import publicacionService from '../../../services/Distribucion/publicacionService';
import canillaService from '../../../services/Distribucion/canillaService';
// import canillaService from '../../../services/Distribucion/canillaService'; // Ya no es necesario
import entradaSalidaCanillaService from '../../../services/Distribucion/entradaSalidaCanillaService';
import type { CreateBulkEntradaSalidaCanillaDto } from '../../../models/dtos/Distribucion/CreateBulkEntradaSalidaCanillaDto';
import type { EntradaSalidaCanillaItemDto } from '../../../models/dtos/Distribucion/EntradaSalidaCanillaItemDto';
import axios from 'axios';
import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto';
const modalStyle = {
position: 'absolute' as 'absolute',
@@ -34,14 +34,21 @@ const modalStyle = {
interface EntradaSalidaCanillaFormModalProps {
open: boolean;
onClose: () => void;
// El onSubmit de la página padre se usa solo para edición. La creación se maneja internamente.
onSubmit: (data: UpdateEntradaSalidaCanillaDto, idParte: number) => Promise<void>;
initialData?: EntradaSalidaCanillaDto | null;
initialData?: EntradaSalidaCanillaDto | null; // Para edición
prefillData?: { // Para creación, prellenar desde la página padre
fecha?: string; // YYYY-MM-DD
idCanilla?: number | string;
nombreCanilla?: string; // << AÑADIR NOMBRE PARA MOSTRAR
idPublicacion?: number | string; // Para pre-seleccionar la primera publicación en la lista de items
} | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
interface FormRowItem {
id: string;
id: string; // ID temporal para el frontend
idPublicacion: number | string;
cantSalida: string;
cantEntrada: string;
@@ -50,56 +57,62 @@ interface FormRowItem {
const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps> = ({
open,
onClose, // Este onClose es el que se pasa desde GestionarEntradasSalidasCanillaPage
onSubmit, // Este onSubmit es el que se pasa para la lógica de EDICIÓN
onClose,
onSubmit: onSubmitEdit, // Renombrar para claridad, ya que solo se usa para editar
initialData,
prefillData,
errorMessage: parentErrorMessage,
clearErrorMessage
}) => {
const [idCanilla, setIdCanilla] = useState<number | string>('');
const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]);
const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>('');
// Estados para los campos que SÍ son editables o parte del formulario de items
const [editIdPublicacion, setEditIdPublicacion] = useState<number | string>(''); // Solo para modo edición
const [editCantSalida, setEditCantSalida] = useState<string>('0');
const [editCantEntrada, setEditCantEntrada] = useState<string>('0');
const [editObservacion, setEditObservacion] = useState('');
const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]); // Iniciar con una fila
const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]);
const [canillitas, setCanillitas] = useState<CanillaDto[]>([]);
const [loading, setLoading] = useState(false); // Loading para submit
const [loadingDropdowns, setLoadingDropdowns] = useState(false); // Loading para canillas/pubs
const [loadingItems, setLoadingItems] = useState(false); // Loading para pre-carga de items
const [items, setItems] = useState<FormRowItem[]>([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
// Estados para datos de dropdowns
const [publicaciones, setPublicaciones] = useState<PublicacionDropdownDto[]>([]); // Sigue siendo necesario para la lista de items
// Estados de carga y error
const [loading, setLoading] = useState(false);
const [loadingDropdowns, setLoadingDropdowns] = useState(false); // Solo para publicaciones
const [loadingItems, setLoadingItems] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const [modalSpecificApiError, setModalSpecificApiError] = useState<string | null>(null);
const isEditing = Boolean(initialData);
const isEditing = Boolean(initialData && initialData.idParte);
// Efecto para cargar datos de dropdowns (Publicaciones, Canillitas) SOLO UNA VEZ o cuando open cambia a true
// Datos que vienen prellenados y no son editables en el modal (Fecha y Canillita)
const displayFecha = isEditing ? (initialData?.fecha ? initialData.fecha.split('T')[0] : '') : (prefillData?.fecha || '');
const displayIdCanilla = isEditing ? initialData?.idCanilla : prefillData?.idCanilla;
const displayNombreCanilla = isEditing ? initialData?.nomApeCanilla : prefillData?.nombreCanilla;
// Cargar publicaciones para el dropdown de items
useEffect(() => {
const fetchDropdownData = async () => {
const fetchPublicacionesDropdown = async () => {
setLoadingDropdowns(true);
setLocalErrors(prev => ({ ...prev, dropdowns: null }));
try {
const [pubsData, canillitasData] = await Promise.all([
publicacionService.getAllPublicaciones(undefined, undefined, true),
canillaService.getAllCanillas(undefined, undefined, true)
]);
// Usar getPublicacionesForDropdown si lo tienes, sino getAllPublicaciones
const pubsData = await publicacionService.getPublicacionesForDropdown(true);
setPublicaciones(pubsData);
setCanillitas(canillitasData);
} catch (error) {
console.error("Error al cargar datos para dropdowns", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar datos necesarios (publicaciones/canillitas).' }));
console.error("Error al cargar publicaciones para dropdown", error);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar publicaciones.' }));
} finally {
setLoadingDropdowns(false);
}
};
if (open) {
fetchDropdownData();
fetchPublicacionesDropdown();
}
}, [open]);
// Efecto para inicializar el formulario cuando se abre o cambia initialData
// Inicializar formulario y/o pre-cargar items
useEffect(() => {
if (open) {
clearErrorMessage();
@@ -107,65 +120,90 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
setLocalErrors({});
if (isEditing && initialData) {
setIdCanilla(initialData.idCanilla || '');
setFecha(initialData.fecha ? initialData.fecha.split('T')[0] : new Date().toISOString().split('T')[0]);
setEditIdPublicacion(initialData.idPublicacion || '');
setEditCantSalida(initialData.cantSalida?.toString() || '0');
setEditCantEntrada(initialData.cantEntrada?.toString() || '0');
setEditObservacion(initialData.observacion || '');
setItems([]); // En modo edición, no pre-cargamos items de la lista
} else {
// Modo NUEVO: resetear campos principales y dejar que el efecto de 'fecha' cargue los items
setIdCanilla('');
setFecha(new Date().toISOString().split('T')[0]); // Fecha actual por defecto
// Los items se cargarán por el siguiente useEffect basado en la fecha
setItems([]); // No hay lista de items en modo edición de un solo movimiento
} else { // Modo Creación
// Limpiar campos de edición
setEditIdPublicacion('');
setEditCantSalida('0');
setEditCantEntrada('0');
setEditObservacion('');
// Lógica para pre-cargar items basada en displayFecha y prefillData.idPublicacion
// (Esta lógica se mueve al siguiente useEffect que depende de displayFecha y publicaciones)
// Por ahora, solo aseguramos que `items` se resetee si es necesario.
const idPubPrefill = prefillData?.idPublicacion;
if (idPubPrefill && publicaciones.length > 0) {
// Si ya tenemos publicaciones, y un prefill de publicación, intentamos setearlo
const diaSemana = new Date(displayFecha + 'T00:00:00Z').getUTCDay();
setLoadingItems(true);
publicacionService.getPublicacionesPorDiaSemana(diaSemana)
.then(pubsPorDefecto => {
let itemsIniciales: FormRowItem[];
if (pubsPorDefecto.find(p => p.idPublicacion === Number(idPubPrefill))) {
// Si la publicación prellenada está en las de por defecto, la usamos
itemsIniciales = [{ id: Date.now().toString(), idPublicacion: Number(idPubPrefill), cantSalida: '0', cantEntrada: '0', observacion: '' }];
} else if (pubsPorDefecto.length > 0) {
// Si no, pero hay otras por defecto, usamos la primera de ellas
itemsIniciales = pubsPorDefecto.map(pub => ({
id: `${Date.now().toString()}-${pub.idPublicacion}`,
idPublicacion: pub.idPublicacion,
cantSalida: '0', cantEntrada: '0', observacion: ''
}));
} else {
// Si no hay ninguna por defecto, y la prellenada no aplica, usamos la prellenada sola o vacía
itemsIniciales = [{ id: Date.now().toString(), idPublicacion: idPubPrefill || '', cantSalida: '0', cantEntrada: '0', observacion: '' }];
}
setItems(itemsIniciales.length > 0 ? itemsIniciales : [{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
})
.catch(() => setItems([{ id: Date.now().toString(), idPublicacion: idPubPrefill || '', cantSalida: '0', cantEntrada: '0', observacion: '' }])) // Fallback
.finally(() => setLoadingItems(false));
} else if (publicaciones.length === 0 && !loadingDropdowns) { // Si no hay prefill de pub o no hay pubs aún
setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
}
}
}
}, [open, initialData, isEditing, clearErrorMessage]);
}, [open, initialData, isEditing, prefillData, clearErrorMessage, publicaciones, loadingDropdowns, displayFecha]); // Añadir displayFecha
// Efecto para pre-cargar/re-cargar items cuando cambia la FECHA (en modo NUEVO)
// y cuando las publicaciones están disponibles.
// Efecto para pre-cargar items por defecto cuando cambia la FECHA (displayFecha) en modo NUEVO
useEffect(() => {
if (open && !isEditing && publicaciones.length > 0 && fecha) { // Asegurarse que 'fecha' tiene un valor
const diaSemana = new Date(fecha + 'T00:00:00Z').getUTCDay(); // Usar UTC para getDay consistente
setLoadingItems(true); // Indicador de carga para los items
setLocalErrors(prev => ({ ...prev, general: null }));
if (open && !isEditing && publicaciones.length > 0 && displayFecha) {
const diaSemana = new Date(displayFecha + 'T00:00:00Z').getUTCDay();
setLoadingItems(true);
publicacionService.getPublicacionesPorDiaSemana(diaSemana)
.then(pubsPorDefecto => {
if (pubsPorDefecto.length > 0) {
const itemsPorDefecto = pubsPorDefecto.map(pub => ({
id: `${Date.now().toString()}-${pub.idPublicacion}`,
idPublicacion: pub.idPublicacion,
cantSalida: '0',
cantEntrada: '0',
observacion: ''
}));
setItems(itemsPorDefecto);
} else {
// Si no hay configuraciones para el día, iniciar con una fila vacía
setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
}
const itemsPorDefecto = pubsPorDefecto.map(pub => ({
id: `${Date.now().toString()}-${pub.idPublicacion}`,
idPublicacion: pub.idPublicacion,
cantSalida: '0',
cantEntrada: '0',
observacion: ''
}));
setItems(itemsPorDefecto.length > 0 ? itemsPorDefecto : [{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
})
.catch(err => {
console.error("Error al cargar/recargar publicaciones por defecto para el día:", err);
console.error("Error al cargar publicaciones por defecto para el día:", err);
setLocalErrors(prev => ({ ...prev, general: 'Error al pre-cargar publicaciones del día.' }));
setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
})
.finally(() => setLoadingItems(false));
} else if (open && !isEditing && publicaciones.length === 0 && !loadingDropdowns) {
// Si las publicaciones aún no se cargaron pero los dropdowns terminaron de cargar, iniciar con 1 item vacío.
setItems([{ id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
}
}, [open, isEditing, fecha, publicaciones, loadingDropdowns]); // Dependencias clave
}, [open, isEditing, displayFecha, publicaciones]); // Dependencia de displayFecha y publicaciones
const validate = (): boolean => {
// ... (lógica de validación sin cambios, pero 'idCanilla' y 'fecha' ya no son estados del modal)
const currentErrors: { [key: string]: string | null } = {};
if (!idCanilla) currentErrors.idCanilla = 'Seleccione un canillita.';
if (!fecha.trim()) currentErrors.fecha = 'La fecha es obligatoria.';
else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) currentErrors.fecha = 'Formato de fecha inválido (YYYY-MM-DD).';
// Validar displayIdCanilla y displayFecha si es modo creación
if (!isEditing) {
if (!displayIdCanilla) currentErrors.idCanilla = 'El canillita es obligatorio (provisto por la página).';
if (!displayFecha || !displayFecha.trim()) currentErrors.fecha = 'La fecha es obligatoria (provista por la página).';
else if (!/^\d{4}-\d{2}-\d{2}$/.test(displayFecha)) currentErrors.fecha = 'Formato de fecha inválido.';
}
// ... resto de la validación para items (modo creación) o campos edit (modo edición) ...
if (isEditing) {
const salidaNum = parseInt(editCantSalida, 10);
const entradaNum = parseInt(editCantEntrada, 10);
@@ -177,14 +215,15 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
} else if (!isNaN(salidaNum) && !isNaN(entradaNum) && entradaNum > salidaNum) {
currentErrors.editCantEntrada = 'Cant. Entrada no puede ser mayor a Cant. Salida.';
}
} else {
if (!editIdPublicacion) { // En edición, la publicación es fija, pero debe existir
currentErrors.editIdPublicacion = 'Error: Publicación no especificada para edición.';
}
} else { // Modo Creación (Bulk)
let hasValidItemWithQuantityOrPub = false;
const publicacionIdsEnLote = new Set<number>();
if (items.length === 0) {
currentErrors.general = "Debe agregar al menos una publicación.";
}
items.forEach((item, index) => {
const salidaNum = parseInt(item.cantSalida, 10);
const entradaNum = parseInt(item.cantEntrada, 10);
@@ -199,9 +238,8 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
const pubIdNum = Number(item.idPublicacion);
if (publicacionIdsEnLote.has(pubIdNum)) {
currentErrors[`item_${item.id}_idPublicacion`] = `Pub. ${index + 1} duplicada.`;
} else {
publicacionIdsEnLote.add(pubIdNum);
}
} else { publicacionIdsEnLote.add(pubIdNum); }
if (item.cantSalida.trim() === '' || isNaN(salidaNum) || salidaNum < 0) {
currentErrors[`item_${item.id}_cantSalida`] = `Salida Pub. ${index + 1} inválida.`;
}
@@ -213,18 +251,11 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
if (item.idPublicacion !== '') hasValidItemWithQuantityOrPub = true;
}
});
const allItemsAreEmptyAndNoPubSelected = items.every(
itm => itm.idPublicacion === '' &&
(itm.cantSalida.trim() === '' || parseInt(itm.cantSalida, 10) === 0) &&
(itm.cantEntrada.trim() === '' || parseInt(itm.cantEntrada, 10) === 0) &&
itm.observacion.trim() === ''
);
if (!isEditing && items.length > 0 && !hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) {
const allItemsAreEmptyAndNoPubSelected = items.every(itm => itm.idPublicacion === '' && (itm.cantSalida.trim() === '' || parseInt(itm.cantSalida, 10) === 0) && (itm.cantEntrada.trim() === '' || parseInt(itm.cantEntrada, 10) === 0) && itm.observacion.trim() === '');
if (!hasValidItemWithQuantityOrPub && !allItemsAreEmptyAndNoPubSelected) {
currentErrors.general = "Debe seleccionar una publicación para los ítems con cantidades y/o observaciones.";
} else if (!isEditing && items.length > 0 && !items.some(i => i.idPublicacion !== '' && (parseInt(i.cantSalida, 10) > 0 || parseInt(i.cantEntrada, 10) > 0)) && !allItemsAreEmptyAndNoPubSelected) {
currentErrors.general = "Debe ingresar cantidades para al menos una publicación con datos significativos.";
} else if (items.length > 0 && !items.some(i => i.idPublicacion !== '' && (parseInt(i.cantSalida, 10) > 0 || parseInt(i.cantEntrada, 10) > 0 || i.observacion.trim() !== '')) && !allItemsAreEmptyAndNoPubSelected) {
currentErrors.general = "Debe ingresar datos significativos (cantidades u observación) para al menos una publicación seleccionada.";
}
}
setLocalErrors(currentErrors);
@@ -232,6 +263,7 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
};
const handleInputChange = (fieldName: string) => {
// ... (sin cambios)
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (parentErrorMessage) clearErrorMessage();
if (modalSpecificApiError) setModalSpecificApiError(null);
@@ -252,16 +284,17 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
cantEntrada: entradaNum,
observacion: editObservacion.trim() || undefined,
};
// Aquí se llama al onSubmit que viene de la página padre (GestionarEntradasSalidasCanillaPage)
// para la lógica de actualización.
await onSubmit(dataToSubmitSingle, initialData.idParte);
onClose(); // Cerrar el modal DESPUÉS de un submit de edición exitoso
} else {
// Lógica de creación BULK (se maneja internamente en el modal)
await onSubmitEdit(dataToSubmitSingle, initialData.idParte);
} else { // Modo Creación
if (!displayIdCanilla || !displayFecha) {
setModalSpecificApiError("Faltan datos del canillita o la fecha para crear los movimientos.");
setLoading(false);
return;
}
const itemsToSubmit: EntradaSalidaCanillaItemDto[] = items
.filter(item =>
item.idPublicacion && Number(item.idPublicacion) > 0 &&
((parseInt(item.cantSalida, 10) >= 0 && parseInt(item.cantEntrada, 10) >= 0) && (parseInt(item.cantSalida, 10) > 0 || parseInt(item.cantEntrada, 10) > 0) || item.observacion.trim() !== '')
( (parseInt(item.cantSalida, 10) >= 0 && parseInt(item.cantEntrada, 10) >= 0) && (parseInt(item.cantSalida, 10) > 0 || parseInt(item.cantEntrada, 10) > 0) || item.observacion.trim() !== '' )
)
.map(item => ({
idPublicacion: Number(item.idPublicacion),
@@ -271,37 +304,30 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
}));
if (itemsToSubmit.length === 0) {
setLocalErrors(prev => ({ ...prev, general: "No hay movimientos válidos para registrar..." }));
setLocalErrors(prev => ({ ...prev, general: "No hay movimientos válidos para registrar." }));
setLoading(false);
return;
}
const bulkData: CreateBulkEntradaSalidaCanillaDto = {
idCanilla: Number(idCanilla),
fecha,
idCanilla: Number(displayIdCanilla), // Usar el displayIdCanilla
fecha: displayFecha, // Usar el displayFecha
items: itemsToSubmit,
};
await entradaSalidaCanillaService.createBulkEntradasSalidasCanilla(bulkData);
onClose(); // Cerrar el modal DESPUÉS de un submit de creación bulk exitoso
}
// onClose(); // Movido dentro de los bloques if/else para asegurar que solo se llama tras éxito
onClose();
} catch (error: any) {
console.error("Error en submit de EntradaSalidaCanillaFormModal:", error);
if (axios.isAxiosError(error) && error.response) {
setModalSpecificApiError(error.response.data?.message || 'Error al procesar la solicitud.');
} else {
setModalSpecificApiError('Ocurrió un error inesperado.');
}
// NO llamar a onClose() aquí si hubo un error, para que el modal permanezca abierto
// y muestre el modalSpecificApiError.
// Si la edición (que usa el 'onSubmit' del padre) lanza un error, ese error se propagará
// al padre y el padre decidirá si el modal se cierra o no (actualmente no lo cierra).
const message = axios.isAxiosError(error) && error.response?.data?.message
? error.response.data.message
: 'Ocurrió un error inesperado al procesar la solicitud.';
setModalSpecificApiError(message);
} finally {
setLoading(false);
}
};
const handleAddRow = () => {
const handleAddRow = () => { /* ... (sin cambios) ... */
if (items.length >= publicaciones.length) {
setLocalErrors(prev => ({ ...prev, general: "No se pueden agregar más filas que publicaciones disponibles." }));
return;
@@ -309,15 +335,13 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
setItems([...items, { id: Date.now().toString(), idPublicacion: '', cantSalida: '0', cantEntrada: '0', observacion: '' }]);
setLocalErrors(prev => ({ ...prev, general: null }));
};
const handleRemoveRow = (idToRemove: string) => {
const handleRemoveRow = (idToRemove: string) => { /* ... (sin cambios) ... */
if (items.length <= 1 && !isEditing) return;
setItems(items.filter(item => item.id !== idToRemove));
};
const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => {
setItems(items.map(itemRow => itemRow.id === id ? { ...itemRow, [field]: value } : itemRow)); // CORREGIDO: item a itemRow para evitar conflicto de nombres de variable con el `item` del map en el JSX
if (localErrors[`item_${id}_${field}`]) { // Aquí item se refiere al id del item.
const handleItemChange = (id: string, field: keyof Omit<FormRowItem, 'id'>, value: string | number) => { /* ... (sin cambios) ... */
setItems(items.map(itemRow => itemRow.id === id ? { ...itemRow, [field]: value } : itemRow));
if (localErrors[`item_${id}_${field}`]) {
setLocalErrors(prev => ({ ...prev, [`item_${id}_${field}`]: null }));
}
if (localErrors.general) setLocalErrors(prev => ({ ...prev, general: null }));
@@ -325,200 +349,102 @@ const EntradaSalidaCanillaFormModal: React.FC<EntradaSalidaCanillaFormModalProps
if (modalSpecificApiError) setModalSpecificApiError(null);
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
{isEditing ? 'Editar Movimiento Canillita' : 'Registrar Movimientos Canillita'}
{isEditing ? `Editar Movimiento (ID: ${initialData?.idParte})` : 'Registrar Nuevos Movimientos'}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idCanilla} required>
<InputLabel id="canilla-esc-select-label">Canillita</InputLabel>
<Select labelId="canilla-esc-select-label" label="Canillita" value={idCanilla}
onChange={(e) => { setIdCanilla(e.target.value as number); handleInputChange('idCanilla'); }}
disabled={loading || loadingDropdowns || isEditing}
>
<MenuItem value="" disabled><em>Seleccione un canillita</em></MenuItem>
{canillitas.map((c) => (<MenuItem key={c.idCanilla} value={c.idCanilla}>{`${c.nomApe} (Leg: ${c.legajo || 'S/L'})`}</MenuItem>))}
</Select>
{localErrors.idCanilla && <FormHelperText>{localErrors.idCanilla}</FormHelperText>}
</FormControl>
{/* --- MOSTRAR DATOS PRELLENADOS/FIJOS --- */}
<Paper variant="outlined" sx={{ p: 1.5, mb: 2, backgroundColor: 'grey.100' }}>
<Typography variant="body1" component="div" sx={{ display: 'flex', justifyContent: 'space-between', flexWrap:'wrap' }}>
<Box><strong>{isEditing ? "Canillita:" : "Para Canillita:"}</strong> {displayNombreCanilla || 'N/A'}</Box>
<Box><strong>{isEditing ? "Fecha Movimiento:" : "Para Fecha:"}</strong> {displayFecha ? new Date(displayFecha + 'T00:00:00Z').toLocaleDateString('es-AR', {timeZone: 'UTC'}) : 'N/A'}</Box>
</Typography>
{localErrors.idCanilla && <Typography color="error" variant="caption" display="block">{localErrors.idCanilla}</Typography>}
{localErrors.fecha && <Typography color="error" variant="caption" display="block">{localErrors.fecha}</Typography>}
</Paper>
{/* --- FIN DATOS PRELLENADOS --- */}
<TextField label="Fecha Movimientos" type="date" value={fecha} required
onChange={(e) => { setFecha(e.target.value); handleInputChange('fecha'); }}
margin="dense" fullWidth error={!!localErrors.fecha} helperText={localErrors.fecha || ''}
disabled={loading || isEditing} InputLabelProps={{ shrink: true }}
autoFocus={!isEditing && !idCanilla} // AutoFocus si es nuevo y no hay canillita seleccionado
/>
{/* El Select de Canillita y TextField de Fecha se eliminan de aquí si son fijos */}
{isEditing && initialData && (
<Paper elevation={1} sx={{ p: 1.5, mt: 1 }}>
<Typography variant="body2" gutterBottom color="text.secondary">Editando para Publicación: {publicaciones.find(p => p.idPublicacion === editIdPublicacion)?.nombre || `ID ${editIdPublicacion}`}</Typography>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5 }}>
<TextField label="Cant. Salida" type="number" value={editCantSalida}
onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }}
margin="dense" fullWidth error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }}
/>
<TextField label="Cant. Entrada" type="number" value={editCantEntrada}
onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }}
margin="dense" fullWidth error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1 }}
/>
</Box>
<TextField label="Observación (General)" value={editObservacion}
onChange={(e) => setEditObservacion(e.target.value)}
margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }}
{isEditing && initialData && (
<Paper elevation={1} sx={{ p: 1.5, mt: 1 }}>
<Typography variant="body2" gutterBottom color="text.secondary">
Editando para Publicación: {publicaciones.find(p => p.idPublicacion === editIdPublicacion)?.nombre || `ID ${editIdPublicacion}`}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}>
<TextField label="Cant. Salida" type="number" value={editCantSalida}
onChange={(e) => { setEditCantSalida(e.target.value); handleInputChange('editCantSalida'); }}
margin="dense" error={!!localErrors.editCantSalida} helperText={localErrors.editCantSalida || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }}
/>
</Paper>
)}
<TextField label="Cant. Entrada" type="number" value={editCantEntrada}
onChange={(e) => { setEditCantEntrada(e.target.value); handleInputChange('editCantEntrada'); }}
margin="dense" error={!!localErrors.editCantEntrada} helperText={localErrors.editCantEntrada || ''}
disabled={loading} inputProps={{ min: 0 }} sx={{ flex: 1, minWidth:'120px' }}
/>
</Box>
<TextField label="Observación (Movimiento)" value={editObservacion} // Label cambiado
onChange={(e) => setEditObservacion(e.target.value)}
margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{ mt: 1 }}
/>
</Paper>
)}
{!isEditing && (
<Box>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5 }}>Movimientos por Publicación:</Typography>
{/* Indicador de carga para los items */}
{loadingItems && <Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}><CircularProgress size={20} /></Box>}
{!loadingItems && items.map((itemRow, index) => (
<Paper
key={itemRow.id}
elevation={1}
sx={{
p: 1.5,
mb: 1,
}}
>
{/* Nivel 1: contenedor “padre” sin wrap */}
<Box
sx={{
display: 'flex',
alignItems: 'center', // centra ícono + campos
gap: 1,
// NOTA: aquí NO ponemos flexWrap, por defecto es 'nowrap'
}}
>
{/* Nivel 2: contenedor que agrupa solo los campos y sí puede hacer wrap */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap', // los campos sí hacen wrap si no caben
flexGrow: 1, // ocupa todo el espacio disponible antes del ícono
}}
>
<FormControl
sx={{ minWidth: 160, flexBasis: 'calc(40% - 8px)', flexGrow: 1, minHeight: 0 }}
size="small"
error={!!localErrors[`item_${itemRow.id}_idPublicacion`]}
>
<InputLabel
required={
parseInt(itemRow.cantSalida) > 0 ||
parseInt(itemRow.cantEntrada) > 0 ||
itemRow.observacion.trim() !== ''
}
>
{!isEditing && (
<Box>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5 }}>Movimientos por Publicación:</Typography>
{loadingItems && <Box sx={{ display: 'flex', justifyContent: 'center', my: 1 }}><CircularProgress size={20} /></Box>}
{!loadingItems && items.map((itemRow, index) => (
// ... (Renderizado de la fila de items sin cambios significativos,
// solo asegúrate que el Select de Publicación use `publicaciones` y no `canillitas`)
<Paper key={itemRow.id} elevation={1} sx={{ p: 1.5, mb: 1, }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flexGrow: 1, }}>
<FormControl sx={{ minWidth: 160, flexBasis: 'calc(40% - 8px)', flexGrow: 1, minHeight: 0 }} size="small" error={!!localErrors[`item_${itemRow.id}_idPublicacion`]} >
<InputLabel required={ parseInt(itemRow.cantSalida) > 0 || parseInt(itemRow.cantEntrada) > 0 || itemRow.observacion.trim() !== '' } >
Pub. {index + 1}
</InputLabel>
<Select
value={itemRow.idPublicacion}
label={`Publicación ${index + 1}`}
onChange={(e) =>
handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number)
}
disabled={loading || loadingDropdowns}
sx={{ minWidth: 0 }} // permite que shrink si hace falta
>
<MenuItem value="" disabled>
<em>Seleccione</em>
</MenuItem>
{publicaciones.map((p) => (
<MenuItem key={p.idPublicacion} value={p.idPublicacion}>
{p.nombre}
</MenuItem>
))}
<Select value={itemRow.idPublicacion} label={`Publicación ${index + 1}`}
onChange={(e) => handleItemChange(itemRow.id, 'idPublicacion', e.target.value as number)}
disabled={loading || loadingDropdowns} sx={{ minWidth: 0 }} >
<MenuItem value="" disabled> <em>Seleccione</em> </MenuItem>
{publicaciones.map((p) => ( <MenuItem key={p.idPublicacion} value={p.idPublicacion}> {p.nombre} </MenuItem> ))}
</Select>
{localErrors[`item_${itemRow.id}_idPublicacion`] && (
<FormHelperText>
{localErrors[`item_${itemRow.id}_idPublicacion`]}
</FormHelperText>
)}
{localErrors[`item_${itemRow.id}_idPublicacion`] && ( <FormHelperText> {localErrors[`item_${itemRow.id}_idPublicacion`]} </FormHelperText> )}
</FormControl>
<TextField
label="Llevados"
type="number"
size="small"
value={itemRow.cantSalida}
<TextField label="Llevados" type="number" size="small" value={itemRow.cantSalida}
onChange={(e) => handleItemChange(itemRow.id, 'cantSalida', e.target.value)}
error={!!localErrors[`item_${itemRow.id}_cantSalida`]}
helperText={localErrors[`item_${itemRow.id}_cantSalida`]}
inputProps={{ min: 0 }}
sx={{
flexBasis: 'calc(15% - 8px)',
minWidth: '80px',
minHeight: 0,
}}
error={!!localErrors[`item_${itemRow.id}_cantSalida`]} helperText={localErrors[`item_${itemRow.id}_cantSalida`]}
inputProps={{ min: 0 }} sx={{ flexBasis: 'calc(15% - 8px)', minWidth: '80px', minHeight: 0, }}
/>
<TextField
label="Devueltos"
type="number"
size="small"
value={itemRow.cantEntrada}
<TextField label="Devueltos" type="number" size="small" value={itemRow.cantEntrada}
onChange={(e) => handleItemChange(itemRow.id, 'cantEntrada', e.target.value)}
error={!!localErrors[`item_${itemRow.id}_cantEntrada`]}
helperText={localErrors[`item_${itemRow.id}_cantEntrada`]}
inputProps={{ min: 0 }}
sx={{
flexBasis: 'calc(15% - 8px)',
minWidth: '80px',
minHeight: 0,
}}
error={!!localErrors[`item_${itemRow.id}_cantEntrada`]} helperText={localErrors[`item_${itemRow.id}_cantEntrada`]}
inputProps={{ min: 0 }} sx={{ flexBasis: 'calc(15% - 8px)', minWidth: '80px', minHeight: 0, }}
/>
<TextField
label="Obs."
value={itemRow.observacion}
onChange={(e) => handleItemChange(itemRow.id, 'observacion', e.target.value)}
size="small"
sx={{
flexGrow: 1,
flexBasis: 'calc(25% - 8px)',
minWidth: '120px',
minHeight: 0,
}}
multiline
maxRows={1}
<TextField label="Obs." value={itemRow.observacion} onChange={(e) => handleItemChange(itemRow.id, 'observacion', e.target.value)}
size="small" sx={{ flexGrow: 1, flexBasis: 'calc(25% - 8px)', minWidth: '120px', minHeight: 0, }}
multiline maxRows={1}
/>
</Box>
{/* Ícono de eliminar: siempre en la misma línea */}
{items.length > 1 && (
<IconButton
onClick={() => handleRemoveRow(itemRow.id)}
color="error"
aria-label="Quitar fila"
sx={{
alignSelf: 'center', // mantén centrado verticalmente
// No necesita flexShrink, porque el padre no hace wrap
}}
>
<IconButton onClick={() => handleRemoveRow(itemRow.id)} color="error" aria-label="Quitar fila" sx={{ alignSelf: 'center', }} >
<DeleteIcon fontSize="medium" />
</IconButton>
)}
</Box>
</Paper>
))}
{localErrors.general && <Alert severity="error" sx={{ mt: 1 }}>{localErrors.general}</Alert>}
<Button onClick={handleAddRow} startIcon={<AddIcon />} sx={{ mt: 1, alignSelf: 'flex-start' }} disabled={loading || loadingDropdowns || items.length >= publicaciones.length}>
Agregar Publicación
</Button>
</Box>
)}
</Box>
))}
{localErrors.general && <Alert severity="error" sx={{ mt: 1 }}>{localErrors.general}</Alert>}
<Button onClick={handleAddRow} startIcon={<AddIcon />} sx={{ mt: 1, alignSelf: 'flex-start' }} disabled={loading || loadingDropdowns || items.length >= publicaciones.length}>
Agregar Publicación
</Button>
</Box>
)}
{parentErrorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{parentErrorMessage}</Alert>}
{modalSpecificApiError && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{modalSpecificApiError}</Alert>}

View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert
} from '@mui/material';
import type { NovedadCanillaDto } from '../../../models/dtos/Distribucion/NovedadCanillaDto';
import type { CreateNovedadCanillaDto } from '../../../models/dtos/Distribucion/CreateNovedadCanillaDto';
import type { UpdateNovedadCanillaDto } from '../../../models/dtos/Distribucion/UpdateNovedadCanillaDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 500 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
interface NovedadCanillaFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateNovedadCanillaDto | UpdateNovedadCanillaDto, idNovedad?: number) => Promise<void>;
// Props para pasar datos necesarios:
idCanilla: number | null; // Necesario para crear una nueva novedad
nombreCanilla?: string; // Para mostrar en el título
initialData?: NovedadCanillaDto | null; // Para editar
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const NovedadCanillaFormModal: React.FC<NovedadCanillaFormModalProps> = ({
open,
onClose,
onSubmit,
idCanilla,
nombreCanilla,
initialData,
errorMessage,
clearErrorMessage
}) => {
const [fecha, setFecha] = useState<string>('');
const [detalle, setDetalle] = useState('');
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData && initialData.idNovedad);
useEffect(() => {
if (open) {
setFecha(initialData?.fecha ? initialData.fecha.split('T')[0] : new Date().toISOString().split('T')[0]);
setDetalle(initialData?.detalle || '');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialData, clearErrorMessage]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!fecha.trim()) errors.fecha = 'La fecha es obligatoria.';
else if (!/^\d{4}-\d{2}-\d{2}$/.test(fecha)) errors.fecha = 'Formato de fecha inválido (YYYY-MM-DD).';
if (!detalle.trim()) errors.detalle = 'El detalle es obligatorio.';
else if (detalle.trim().length > 250) errors.detalle = 'El detalle no puede exceder los 250 caracteres.';
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (fieldName: 'fecha' | 'detalle') => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
try {
if (isEditing && initialData) {
const dataToSubmit: UpdateNovedadCanillaDto = { detalle };
await onSubmit(dataToSubmit, initialData.idNovedad);
} else if (idCanilla) { // Asegurarse que idCanilla esté disponible para creación
const dataToSubmit: CreateNovedadCanillaDto = {
idCanilla: idCanilla, // Tomado de props
fecha,
detalle,
};
await onSubmit(dataToSubmit);
} else {
// Esto no debería pasar si la lógica de la página que llama al modal es correcta
setLocalErrors(prev => ({...prev, general: "No se proporcionó ID de Canillita para crear la novedad."}))
setLoading(false);
return;
}
onClose();
} catch (error: any) {
// El error de API es manejado por la página padre a través de 'errorMessage'
console.error("Error en submit de NovedadCanillaFormModal:", error);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
{isEditing ? 'Editar Novedad' : `Agregar Novedad para ${nombreCanilla || 'Canillita'}`}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
{isEditing && initialData ? `Editando Novedad ID: ${initialData.idNovedad}` : `Canillita ID: ${idCanilla || 'N/A'}`}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<TextField
label="Fecha Novedad"
type="date"
value={fecha}
required
onChange={(e) => {setFecha(e.target.value); handleInputChange('fecha');}}
margin="normal"
fullWidth
error={!!localErrors.fecha}
helperText={localErrors.fecha || ''}
disabled={loading || isEditing} // Fecha no se edita
InputLabelProps={{ shrink: true }}
autoFocus={!isEditing}
/>
<TextField
label="Detalle Novedad"
value={detalle}
required
onChange={(e) => {setDetalle(e.target.value); handleInputChange('detalle');}}
margin="normal"
fullWidth
multiline
rows={4}
error={!!localErrors.detalle}
helperText={localErrors.detalle || (detalle ? `${250 - detalle.length} caracteres restantes` : '')}
disabled={loading}
inputProps={{ maxLength: 250 }}
/>
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
{localErrors.general && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.general}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar Novedad')}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default NovedadCanillaFormModal;

View File

@@ -1,67 +1,225 @@
import React from 'react';
import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper } from '@mui/material'; // Quitar Grid
import React, { useState } from 'react';
import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper, Divider, TextField } from '@mui/material';
import type { PermisoAsignadoDto } from '../../../models/dtos/Usuarios/PermisoAsignadoDto';
interface PermisosChecklistProps {
permisosDisponibles: PermisoAsignadoDto[];
permisosSeleccionados: Set<number>;
onPermisoChange: (permisoId: number, asignado: boolean) => void;
onPermisoChange: (permisoId: number, asignado: boolean, esPermisoSeccion?: boolean, moduloHijo?: string) => void;
disabled?: boolean;
}
const SECCION_PERMISSIONS_PREFIX = "SS";
// Mapeo de codAcc de sección a su módulo conceptual
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;
};
// Función para determinar el módulo conceptual de un permiso individual
const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
const moduloLower = permisoModulo.toLowerCase();
if (moduloLower.includes("distribuidores") ||
moduloLower.includes("canillas") || // Cubre "Canillas" y "Movimientos Canillas"
moduloLower.includes("publicaciones distribución") ||
moduloLower.includes("zonas distribuidores") ||
moduloLower.includes("movimientos distribuidores") ||
moduloLower.includes("empresas") || // Módulo "Empresas"
moduloLower.includes("otros destinos") || // Cubre "Otros Destinos" y "Salidas Otros Destinos"
moduloLower.includes("ctrl. devoluciones")) {
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") || // Cubre "Impresión Bobinas" y "Tipos Bobinas"
moduloLower.includes("impresión plantas") ||
moduloLower.includes("tipos bobinas")) { // Añadido explícitamente
return "Impresión";
}
if (moduloLower.includes("radios")) { // Asumiendo que los permisos de radios tendrán "Radios" en su módulo
return "Radios";
}
if (moduloLower.includes("usuarios") || // Cubre "Usuarios" y "Perfiles"
moduloLower.includes("perfiles")) {
return "Usuarios";
}
if (moduloLower.includes("reportes")) { // Para los permisos RRxxx
return "Reportes";
}
if (moduloLower.includes("permisos")) { // Para "Permisos (Definición)"
return "Permisos (Definición)";
}
return permisoModulo; // Fallback al nombre original si no coincide
};
const PermisosChecklist: React.FC<PermisosChecklistProps> = ({
permisosDisponibles,
permisosSeleccionados,
onPermisoChange,
disabled = false,
}) => {
const permisosAgrupados = permisosDisponibles.reduce((acc, permiso) => {
const modulo = permiso.modulo || 'Otros';
if (!acc[modulo]) {
acc[modulo] = [];
const [filtrosModulo, setFiltrosModulo] = useState<Record<string, string>>({});
const handleFiltroChange = (moduloConceptual: string, texto: string) => {
setFiltrosModulo(prev => ({ ...prev, [moduloConceptual]: texto.toLowerCase() }));
};
const permisosDeSeccion = permisosDisponibles.filter(p => p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX));
const permisosNormales = permisosDisponibles.filter(p => !p.codAcc.startsWith(SECCION_PERMISSIONS_PREFIX));
const permisosAgrupadosConceptualmente = permisosNormales.reduce((acc, permiso) => {
const moduloConceptual = getModuloConceptualDelPermiso(permiso.modulo);
if (!acc[moduloConceptual]) {
acc[moduloConceptual] = [];
}
acc[modulo].push(permiso);
acc[moduloConceptual].push(permiso);
return acc;
}, {} as Record<string, PermisoAsignadoDto[]>);
const ordenModulosPrincipales = ["Distribución", "Contables", "Impresión", "Radios", "Usuarios", "Reportes", "Permisos (Definición)"];
// Añadir módulos que solo tienen permiso de sección (como Radios) pero no hijos (aún)
permisosDeSeccion.forEach(ps => {
const moduloConceptual = getModuloFromSeccionCodAcc(ps.codAcc);
if (moduloConceptual && !ordenModulosPrincipales.includes(moduloConceptual)) {
// Insertar después de un módulo conocido o al final si no hay un orden específico para él
const indexReportes = ordenModulosPrincipales.indexOf("Reportes");
if (indexReportes !== -1) {
ordenModulosPrincipales.splice(indexReportes, 0, moduloConceptual);
} else {
ordenModulosPrincipales.push(moduloConceptual);
}
}
if (moduloConceptual && !permisosAgrupadosConceptualmente[moduloConceptual]) {
permisosAgrupadosConceptualmente[moduloConceptual] = []; // Asegurar que el grupo exista para Radios
}
});
// Eliminar duplicados del orden por si acaso
const ordenFinalModulos = [...new Set(ordenModulosPrincipales)];
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> {/* Contenedor Flexbox */}
{Object.entries(permisosAgrupados).map(([modulo, permisosDelModulo]) => (
<Box
key={modulo}
sx={{
flexGrow: 1, // Para que las columnas crezcan
flexBasis: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(33.333% - 16px)' }, // Simula xs, sm, md
// El '-16px' es por el gap (si el gap es 2 = 16px). Ajustar si el gap es diferente.
// Alternativamente, usar porcentajes más simples y dejar que el flexWrap maneje el layout.
// flexBasis: '300px', // Un ancho base y dejar que flexWrap haga el resto
minWidth: '280px', // Ancho mínimo para cada columna
maxWidth: { xs: '100%', sm: '50%', md: '33.333%' }, // Máximo ancho
}}
>
<Paper elevation={2} sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb:1 }}>
{modulo}
</Typography>
<FormGroup sx={{ flexGrow: 1}}> {/* Para que ocupe el espacio vertical */}
{permisosDelModulo.map((permiso) => (
<FormControlLabel
key={permiso.id}
control={
<Checkbox
checked={permisosSeleccionados.has(permiso.id)}
onChange={(e) => onPermisoChange(permiso.id, e.target.checked)}
disabled={disabled}
size="small"
/>
}
label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, justifyContent: 'center' }}>
{ordenFinalModulos.map(moduloConceptual => { // Usar ordenFinalModulos
const permisoSeccionAsociado = permisosDeSeccion.find(
ps => getModuloFromSeccionCodAcc(ps.codAcc) === moduloConceptual
);
const permisosDelModuloHijosOriginales = permisosAgrupadosConceptualmente[moduloConceptual] || [];
// Condición para renderizar la sección
if (!permisoSeccionAsociado && permisosDelModuloHijosOriginales.length === 0 && moduloConceptual !== "Permisos (Definición)") {
// No renderizar si no hay permiso de sección Y no hay hijos, EXCEPTO para "Permisos (Definición)" que es especial
return null;
}
if (moduloConceptual === "Permisos (Definición)" && permisosDelModuloHijosOriginales.length === 0) {
// No renderizar "Permisos (Definición)" si no tiene hijos
return null;
}
const esSeccionSeleccionada = permisoSeccionAsociado ? permisosSeleccionados.has(permisoSeccionAsociado.id) : false;
const todosHijosSeleccionados = permisosDelModuloHijosOriginales.length > 0 && permisosDelModuloHijosOriginales.every(p => permisosSeleccionados.has(p.id));
const ningunHijoSeleccionado = permisosDelModuloHijosOriginales.every(p => !permisosSeleccionados.has(p.id));
const algunosHijosSeleccionados = !todosHijosSeleccionados && !ningunHijoSeleccionado && permisosDelModuloHijosOriginales.length > 0;
const textoFiltro = filtrosModulo[moduloConceptual] || '';
const permisosDelModuloHijosFiltrados = textoFiltro
? permisosDelModuloHijosOriginales.filter(permiso =>
permiso.descPermiso.toLowerCase().includes(textoFiltro) ||
permiso.codAcc.toLowerCase().includes(textoFiltro)
)
: permisosDelModuloHijosOriginales;
return (
<Box key={moduloConceptual} sx={{ /* ... estilos del Box ... */
flexGrow: 1,
flexBasis: { xs: '100%', sm: 'calc(50% - 20px)', md: 'calc(33.333% - 20px)' },
minWidth: '320px', // Aumentar un poco para el filtro
maxWidth: { xs: '100%', sm: 'calc(50% - 10px)', md: 'calc(33.333% - 10px)'},
}}>
<Paper elevation={2} sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 1 }}>
{moduloConceptual}
</Typography>
{permisosDelModuloHijosOriginales.length > 3 && ( // Mostrar filtro si hay más de 3 permisos
<TextField
label={`Buscar en ${moduloConceptual}...`}
variant="standard"
size="small"
fullWidth
value={filtrosModulo[moduloConceptual] || ''}
onChange={(e) => handleFiltroChange(moduloConceptual, e.target.value)}
sx={{ mb: 1, mt: 0.5 }}
disabled={disabled}
/>
))}
</FormGroup>
</Paper>
</Box>
))}
)}
{permisoSeccionAsociado && (
<>
<FormControlLabel
label={`Acceso a Sección ${moduloConceptual}`} // Cambiado el Label
labelPlacement="end"
sx={{mb:1, '& .MuiFormControlLabel-label': { fontWeight: 'medium'}}}
control={
<Checkbox
checked={esSeccionSeleccionada && (permisosDelModuloHijosOriginales.length === 0 || todosHijosSeleccionados)}
indeterminate={esSeccionSeleccionada && (algunosHijosSeleccionados || (ningunHijoSeleccionado && permisosDelModuloHijosOriginales.length > 0))}
onChange={() => onPermisoChange(permisoSeccionAsociado.id, false, true, moduloConceptual)}
disabled={disabled}
size="small"
/>
}
/>
{/* Mostrar Divider solo si hay hijos o es la sección Radios (que queremos que aparezca aunque esté vacía) */}
{(permisosDelModuloHijosOriginales.length > 0 || moduloConceptual === "Radios") && <Divider sx={{mb:1.5}}/> }
</>
)}
<Box sx={{ maxHeight: '280px', overflowY: 'auto', flexGrow: 1 }}> {/* Aumentar un poco maxHeight */}
<FormGroup sx={{ pl: permisoSeccionAsociado ? 2 : 0 }}>
{permisosDelModuloHijosFiltrados.map((permiso) => (
<FormControlLabel
key={permiso.id}
control={
<Checkbox
checked={permisosSeleccionados.has(permiso.id)}
onChange={(e) => onPermisoChange(permiso.id, e.target.checked, false, moduloConceptual)}
disabled={disabled || (permisoSeccionAsociado && !esSeccionSeleccionada)}
size="small"
/>
}
label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>}
/>
))}
{textoFiltro && permisosDelModuloHijosFiltrados.length === 0 && permisosDelModuloHijosOriginales.length > 0 && (
<Typography variant="caption" sx={{p:1, fontStyle: 'italic', textAlign: 'center'}}>
No hay permisos que coincidan con "{textoFiltro}".
</Typography>
)}
{/* Mensaje si no hay hijos en general (y no es por filtro) */}
{permisosDelModuloHijosOriginales.length === 0 && !textoFiltro && moduloConceptual !== "Permisos (Definición)" && (
<Typography variant="caption" sx={{p:1, fontStyle: 'italic', textAlign: 'center'}}>
No hay permisos específicos en esta sección.
</Typography>
)}
</FormGroup>
</Box>
</Paper>
</Box>
);
})}
</Box>
);
};

View File

@@ -1,32 +1,37 @@
import React, { type ReactNode, useState, useEffect } from 'react';
// src/layouts/MainLayout.tsx
import React, { type ReactNode, useState, useEffect, useMemo } // << AÑADIR useMemo
from 'react';
import {
Box, AppBar, Toolbar, Typography, Tabs, Tab, Paper,
IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider // Nuevas importaciones
IconButton, Menu, MenuItem, ListItemIcon, ListItemText, Divider,
Button
} from '@mui/material';
import AccountCircle from '@mui/icons-material/AccountCircle'; // Icono de usuario
import LockResetIcon from '@mui/icons-material/LockReset'; // Icono para cambiar contraseña
import LogoutIcon from '@mui/icons-material/Logout'; // Icono para cerrar sesión
import AccountCircle from '@mui/icons-material/AccountCircle';
import LockResetIcon from '@mui/icons-material/LockReset';
import LogoutIcon from '@mui/icons-material/Logout';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordModal from '../components/Modals/Usuarios/ChangePasswordModal';
import { useNavigate, useLocation } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions'; // <<--- AÑADIR ESTA LÍNEA
interface MainLayoutProps {
children: ReactNode;
}
const modules = [
{ label: 'Inicio', path: '/' },
{ label: 'Distribución', path: '/distribucion' },
{ label: 'Contables', path: '/contables' },
{ label: 'Impresión', path: '/impresion' },
{ label: 'Reportes', path: '/reportes' },
{ label: 'Radios', path: '/radios' },
{ label: 'Usuarios', path: '/usuarios' },
// Definición original de módulos
const allAppModules = [
{ label: 'Inicio', path: '/', requiredPermission: null }, // Inicio siempre visible
{ label: 'Distribución', path: '/distribucion', requiredPermission: 'SS001' },
{ label: 'Contables', path: '/contables', requiredPermission: 'SS002' },
{ label: 'Impresión', path: '/impresion', requiredPermission: 'SS003' },
{ label: 'Reportes', path: '/reportes', requiredPermission: 'SS004' },
{ label: 'Radios', path: '/radios', requiredPermission: 'SS005' },
{ label: 'Usuarios', path: '/usuarios', requiredPermission: 'SS006' },
];
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const {
user,
user, // user ya está disponible aquí
logout,
isAuthenticated,
isPasswordChangeForced,
@@ -35,24 +40,40 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
passwordChangeCompleted
} = useAuth();
const { tienePermiso, isSuperAdmin } = usePermissions(); // <<--- OBTENER HOOK DE PERMISOS
const navigate = useNavigate();
const location = useLocation();
const [selectedTab, setSelectedTab] = useState<number | false>(false);
const [anchorElUserMenu, setAnchorElUserMenu] = useState<null | HTMLElement>(null); // Estado para el menú de usuario
const [anchorElUserMenu, setAnchorElUserMenu] = useState<null | HTMLElement>(null);
// --- INICIO DE CAMBIO: Filtrar módulos basados en permisos ---
const accessibleModules = useMemo(() => {
if (!isAuthenticated) return []; // Si no está autenticado, ningún módulo excepto quizás login (que no está aquí)
return allAppModules.filter(module => {
if (module.requiredPermission === null) return true; // Inicio siempre accesible
return isSuperAdmin || tienePermiso(module.requiredPermission);
});
}, [isAuthenticated, isSuperAdmin, tienePermiso]);
// --- FIN DE CAMBIO ---
useEffect(() => {
const currentModulePath = modules.findIndex(module =>
// --- INICIO DE CAMBIO: Usar accessibleModules para encontrar el tab ---
const currentModulePath = accessibleModules.findIndex(module =>
location.pathname === module.path || (module.path !== '/' && location.pathname.startsWith(module.path + '/'))
);
if (currentModulePath !== -1) {
setSelectedTab(currentModulePath);
} else if (location.pathname === '/') {
setSelectedTab(0); // Asegurar que la pestaña de Inicio se seleccione para la ruta raíz
// Asegurar que Inicio se seleccione si es accesible
const inicioIndex = accessibleModules.findIndex(m => m.path === '/');
if (inicioIndex !== -1) setSelectedTab(inicioIndex);
else setSelectedTab(false);
} else {
setSelectedTab(false); // Ninguna pestaña seleccionada si no coincide
setSelectedTab(false);
}
}, [location.pathname]);
// --- FIN DE CAMBIO ---
}, [location.pathname, accessibleModules]); // << CAMBIO: dependencia a accessibleModules
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUserMenu(event.currentTarget);
@@ -69,7 +90,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const handleLogoutClick = () => {
logout();
handleCloseUserMenu(); // Cierra el menú antes de desloguear completamente
handleCloseUserMenu();
};
const handleModalClose = (passwordChangedSuccessfully: boolean) => {
@@ -77,23 +98,27 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
passwordChangeCompleted();
} else {
if (isPasswordChangeForced) {
logout();
logout(); // Si es forzado y cancela/falla, desloguear
} else {
setShowForcedPasswordChangeModal(false);
setShowForcedPasswordChangeModal(false); // Si no es forzado, solo cerrar modal
}
}
};
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setSelectedTab(newValue);
navigate(modules[newValue].path);
// --- INICIO DE CAMBIO: Navegar usando accessibleModules ---
if (accessibleModules[newValue]) {
setSelectedTab(newValue);
navigate(accessibleModules[newValue].path);
}
// --- FIN DE CAMBIO ---
};
// Determinar si el módulo actual es el de Reportes
const isReportesModule = location.pathname.startsWith('/reportes');
if (showForcedPasswordChangeModal && isPasswordChangeForced) {
return (
// ... (sin cambios)
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<ChangePasswordModal
open={showForcedPasswordChangeModal}
@@ -104,17 +129,31 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
);
}
// Si no hay módulos accesibles después del login (y no es el cambio de clave forzado)
// Esto podría pasar si un usuario no tiene permiso para NINGUNA sección, ni siquiera Inicio.
// Deberías redirigir a login o mostrar un mensaje de "Sin acceso".
if (isAuthenticated && !isPasswordChangeForced && accessibleModules.length === 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<Typography variant="h6">No tiene acceso a ninguna sección del sistema.</Typography>
<Button onClick={logout} sx={{ mt: 2 }}>Cerrar Sesión</Button>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="sticky" elevation={1} /* Elevation sutil para AppBar */>
<AppBar position="sticky" elevation={1}>
<Toolbar sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6" component="div" noWrap sx={{ cursor: 'pointer' }} onClick={() => navigate('/')}>
Sistema de Gestión - El Día
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{user && (
<Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} /* Ocultar en pantallas muy pequeñas */>
{/* ... (Menú de usuario sin cambios) ... */}
{user && (
<Typography sx={{ mr: 2, display: { xs: 'none', sm: 'block' } }} >
Hola, {user.nombreCompleto}
</Typography>
)}
@@ -125,9 +164,7 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
aria-label="Cuenta del usuario"
aria-controls="menu-appbar"
aria-haspopup="true"
sx={{
padding: '15px',
}}
sx={{ padding: '15px' }}
onClick={handleOpenUserMenu}
color="inherit"
>
@@ -143,15 +180,14 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
onClose={handleCloseUserMenu}
sx={{ '& .MuiPaper-root': { minWidth: 220, marginTop: '8px' } }}
>
{user && ( // Mostrar info del usuario en el menú
<Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' /* Para que no sea clickeable */ }}>
{user && (
<Box sx={{ px: 2, py: 1.5, pointerEvents: 'none' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>{user.nombreCompleto}</Typography>
<Typography variant="body2" color="text.secondary">{user.username}</Typography>
</Box>
)}
{user && <Divider sx={{ mb: 1 }} />}
{!isPasswordChangeForced && ( // No mostrar si ya está forzado a cambiarla
{!isPasswordChangeForced && (
<MenuItem onClick={handleChangePasswordClick}>
<ListItemIcon><LockResetIcon fontSize="small" /></ListItemIcon>
<ListItemText>Cambiar Contraseña</ListItemText>
@@ -166,48 +202,45 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
)}
</Box>
</Toolbar>
<Paper square elevation={0} >
<Tabs
value={selectedTab}
onChange={handleTabChange}
indicatorColor="secondary" // O 'primary' si prefieres el mismo color que el fondo
textColor="inherit" // El texto de la pestaña hereda el color (blanco sobre fondo oscuro)
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="módulos principales"
sx={{
backgroundColor: 'primary.main', // Color de fondo de las pestañas
color: 'white', // Color del texto de las pestañas
'& .MuiTabs-indicator': {
height: 3, // Un indicador un poco más grueso
},
'& .MuiTab-root': { // Estilo para cada pestaña
minWidth: 100, // Ancho mínimo para cada pestaña
textTransform: 'none', // Evitar MAYÚSCULAS por defecto
fontWeight: 'normal',
opacity: 0.85, // Ligeramente transparentes si no están seleccionadas
'&.Mui-selected': {
fontWeight: 'bold',
opacity: 1,
// color: 'secondary.main' // Opcional: color diferente para la pestaña seleccionada
},
}
}}
>
{modules.map((module) => (
<Tab key={module.path} label={module.label} />
))}
</Tabs>
</Paper>
{/* --- INICIO DE CAMBIO: Renderizar Tabs solo si hay módulos accesibles y está autenticado --- */}
{isAuthenticated && accessibleModules.length > 0 && (
<Paper square elevation={0} >
<Tabs
value={selectedTab}
onChange={handleTabChange}
indicatorColor="secondary"
textColor="inherit"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="módulos principales"
sx={{
backgroundColor: 'primary.main',
color: 'white',
'& .MuiTabs-indicator': { height: 3 },
'& .MuiTab-root': {
minWidth: 100, textTransform: 'none',
fontWeight: 'normal', opacity: 0.85,
'&.Mui-selected': { fontWeight: 'bold', opacity: 1 },
}
}}
>
{/* Mapear sobre accessibleModules en lugar de allAppModules */}
{accessibleModules.map((module) => (
<Tab key={module.path} label={module.label} />
))}
</Tabs>
</Paper>
)}
{/* --- FIN DE CAMBIO --- */}
</AppBar>
<Box
component="main"
sx={{
sx={{ /* ... (estilos sin cambios) ... */
flexGrow: 1,
py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding vertical responsivo
px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 }, // Padding horizontal responsivo
py: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 },
px: isReportesModule ? 0 : { xs: 1.5, sm: 2, md: 2.5 },
display: 'flex',
flexDirection: 'column'
}}
@@ -215,17 +248,19 @@ const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
{children}
</Box>
<Box component="footer" sx={{ p: 1, backgroundColor: 'grey.200' /* Un gris más claro */, color: 'text.secondary', textAlign: 'left', borderTop: (theme) => `1px solid ${theme.palette.divider}` }}>
<Box component="footer" sx={{ /* ... (estilos sin cambios) ... */
p: 1, backgroundColor: 'grey.200', color: 'text.secondary',
textAlign: 'left', borderTop: (theme) => `1px solid ${theme.palette.divider}`
}}>
<Typography variant="caption">
{/* Puedes usar caption para un texto más pequeño en el footer */}
Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Administrador' : (user?.perfil || `ID ${user?.idPerfil}`)}
</Typography>
</Box>
<ChangePasswordModal
open={showForcedPasswordChangeModal && !isPasswordChangeForced} // Solo mostrar si no es el forzado inicial
onClose={() => handleModalClose(false)} // Asumir que si se cierra sin cambiar, no fue exitoso
isFirstLogin={false} // Este modal no es para el primer login forzado
open={showForcedPasswordChangeModal && !isPasswordChangeForced}
onClose={() => handleModalClose(false)}
isFirstLogin={false}
/>
</Box>
);

View File

@@ -0,0 +1,7 @@
export interface AjusteSaldoRequestDto {
destino: 'Distribuidores' | 'Canillas';
idDestino: number;
idEmpresa: number;
montoAjuste: number;
justificacion: string;
}

View File

@@ -0,0 +1,10 @@
export interface SaldoGestionDto {
idSaldo: number;
destino: string; // "Distribuidores" o "Canillas"
idDestino: number;
nombreDestinatario: string;
idEmpresa: number;
nombreEmpresa: string;
monto: number;
fechaUltimaModificacion: string; // "yyyy-MM-ddTHH:mm:ss" o similar
}

View File

@@ -0,0 +1,5 @@
export interface CreateNovedadCanillaDto {
idCanilla: number;
fecha: string; // string dd/MM/yyyy
detalle?: string | null;
}

View File

@@ -0,0 +1,4 @@
export interface DistribuidorDropdownDto {
idDistribuidor: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface DistribuidorLookupDto {
idDistribuidor: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface EmpresaDropdownDto {
idEmpresa: number;
nombre: string;
}

View File

@@ -0,0 +1,4 @@
export interface EmpresaLookupDto {
idEmpresa: number;
nombre: string;
}

View File

@@ -0,0 +1,7 @@
export interface NovedadCanillaDto {
idNovedad: number;
idCanilla: number;
nombreCanilla: string;
fecha: string; // string dd/MM/yyyy
detalle?: string | null;
}

View File

@@ -0,0 +1,3 @@
export interface UpdateNovedadCanillaDto {
detalle?: string | null;
}

View File

@@ -0,0 +1,8 @@
export interface CanillaGananciaReporteDto {
canilla: string; // NomApe del canillita
legajo?: number | null;
francos?: number | null;
faltas?: number | null;
totalRendir?: number | null;
id?: string; // Para el DataGrid
}

View File

@@ -0,0 +1,10 @@
export interface ListadoDistCanMensualDiariosDto {
canilla: string;
elDia: number | null;
elPlata: number | null;
vendidos: number | null;
importeElDia: number | null;
importeElPlata: number | null;
importeTotal: number | null;
id?: string; // Para DataGrid
}

View File

@@ -0,0 +1,8 @@
export interface ListadoDistCanMensualPubDto {
publicacion: string;
canilla: string;
totalCantSalida: number | null;
totalCantEntrada: number | null;
totalRendir: number | null;
id?: string; // Para DataGrid
}

View File

@@ -0,0 +1,6 @@
export interface NovedadesCanillasReporteDto {
nomApe: string;
fecha: string;
detalle?: string | null;
id?: string; // Para el DataGrid
}

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;

View File

@@ -6,6 +6,7 @@ import HomePage from '../pages/HomePage';
import { useAuth } from '../contexts/AuthContext';
import MainLayout from '../layouts/MainLayout';
import { Typography } from '@mui/material';
import SectionProtectedRoute from './SectionProtectedRoute';
// Distribución
import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage';
@@ -38,6 +39,7 @@ import ContablesIndexPage from '../pages/Contables/ContablesIndexPage';
import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage';
import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage';
import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage';
import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage';
// Usuarios
import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente
@@ -69,10 +71,14 @@ import ReporteComparativaConsumoBobinasPage from '../pages/Reportes/ReporteCompa
import ReporteCuentasDistribuidoresPage from '../pages/Reportes/ReporteCuentasDistribuidoresPage';
import ReporteListadoDistribucionPage from '../pages/Reportes/ReporteListadoDistribucionPage';
import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevolucionesPage';
import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage';
import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage';
import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage';
// Auditorias
import GestionarAuditoriaUsuariosPage from '../pages/Usuarios/Auditoria/GestionarAuditoriaUsuariosPage';
// --- ProtectedRoute y PublicRoute SIN CAMBIOS ---
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
@@ -107,7 +113,7 @@ const MainLayoutWrapper: React.FC = () => (
const AppRoutes = () => {
return (
<BrowserRouter>
<Routes> {/* Un solo <Routes> de nivel superior */}
<Routes>
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
{/* Rutas Protegidas que usan el MainLayout */}
@@ -123,13 +129,21 @@ const AppRoutes = () => {
<Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */}
{/* Módulo de Distribución (anidado) */}
<Route path="distribucion" element={<DistribucionIndexPage />}>
<Route
path="distribucion"
element={
<SectionProtectedRoute requiredPermission="SS001" sectionName="Distribución">
<DistribucionIndexPage />
</SectionProtectedRoute>
}
>
<Route index element={<Navigate to="es-canillas" replace />} />
<Route path="es-canillas" element={<GestionarEntradasSalidasCanillaPage />} />
<Route path="control-devoluciones" element={<GestionarControlDevolucionesPage />} />
<Route path="es-distribuidores" element={<GestionarEntradasSalidasDistPage />} />
<Route path="salidas-otros-destinos" element={<GestionarSalidasOtrosDestinosPage />} />
<Route path="canillas" element={<GestionarCanillitasPage />} />
<Route path="canillas/:idCanilla/novedades" element={<GestionarNovedadesCanillaPage />} />
<Route path="distribuidores" element={<GestionarDistribuidoresPage />} />
<Route path="otros-destinos" element={<GestionarOtrosDestinosPage />} />
<Route path="zonas" element={<GestionarZonasPage />} />
@@ -146,15 +160,28 @@ const AppRoutes = () => {
</Route>
{/* Módulo Contable (anidado) */}
<Route path="contables" element={<ContablesIndexPage />}>
<Route
path="contables"
element={
<SectionProtectedRoute requiredPermission="SS002" sectionName="Contables">
<ContablesIndexPage />
</SectionProtectedRoute>
}
>
<Route index element={<Navigate to="tipos-pago" replace />} />
<Route path="tipos-pago" element={<GestionarTiposPagoPage />} />
<Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} />
<Route path="notas-cd" element={<GestionarNotasCDPage />} />
<Route path="gestion-saldos" element={<GestionarSaldosPage />} />
</Route>
{/* Módulo de Impresión (anidado) */}
<Route path="impresion" element={<ImpresionIndexPage />}>
<Route path="impresion"
element={
<SectionProtectedRoute requiredPermission="SS003" sectionName="Impresión">
<ImpresionIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Navigate to="plantas" replace />} />
<Route path="plantas" element={<GestionarPlantasPage />} />
<Route path="tipos-bobina" element={<GestionarTiposBobinaPage />} />
@@ -164,8 +191,13 @@ const AppRoutes = () => {
</Route>
{/* Módulo de Reportes */}
<Route path="reportes" element={<ReportesIndexPage />}> {/* Página principal del módulo */}
<Route index element={<Typography sx={{p:2}}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="reportes"
element={
<SectionProtectedRoute requiredPermission="SS004" sectionName="Reportes">
<ReportesIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} />
<Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} />
<Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} />
@@ -181,10 +213,17 @@ const AppRoutes = () => {
<Route path="cuentas-distribuidores" element={<ReporteCuentasDistribuidoresPage />} />
<Route path="listado-distribucion-distribuidores" element={<ReporteListadoDistribucionPage />} />
<Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} />
<Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} />
<Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} />
</Route>
{/* Módulo de Radios (anidado) */}
<Route path="radios" element={<RadiosIndexPage />}>
<Route path="radios"
element={
<SectionProtectedRoute requiredPermission="SS005" sectionName="Radios">
<RadiosIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Navigate to="ritmos" replace />} />
<Route path="ritmos" element={<GestionarRitmosPage />} />
<Route path="canciones" element={<GestionarCancionesPage />} />
@@ -192,7 +231,12 @@ const AppRoutes = () => {
</Route>
{/* Módulo de Usuarios (anidado) */}
<Route path="usuarios" element={<UsuariosIndexPage />}>
<Route path="usuarios"
element={
<SectionProtectedRoute requiredPermission="SS006" sectionName="Usuarios">
<UsuariosIndexPage />
</SectionProtectedRoute>}
>
<Route index element={<Navigate to="perfiles" replace />} /> {/* Redirigir a la primera subpestaña */}
<Route path="perfiles" element={<GestionarPerfilesPage />} />
<Route path="permisos" element={<GestionarPermisosPage />} />

View File

@@ -0,0 +1,52 @@
// src/routes/SectionProtectedRoute.tsx
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePermissions } from '../hooks/usePermissions';
import { Box, CircularProgress } from '@mui/material';
interface SectionProtectedRouteProps {
requiredPermission: string;
sectionName: string;
children?: React.ReactNode;
}
const SectionProtectedRoute: React.FC<SectionProtectedRouteProps> = ({ requiredPermission, sectionName, children }) => {
const { isAuthenticated, isLoading: authIsLoading } = useAuth(); // isLoading de AuthContext
const { tienePermiso, isSuperAdmin, currentUser } = usePermissions();
if (authIsLoading) { // Esperar a que el AuthContext termine su carga inicial
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}>
<CircularProgress />
</Box>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// En este punto, si está autenticado, currentUser debería estar disponible.
// Si currentUser pudiera ser null aun estando autenticado (poco probable con tu AuthContext),
// se necesitaría un manejo adicional o un spinner aquí.
if (!currentUser) {
// Esto sería un estado inesperado si isAuthenticated es true.
// Podrías redirigir a login o mostrar un error genérico.
console.error("SectionProtectedRoute: Usuario autenticado pero currentUser es null.");
return <Navigate to="/login" replace />; // O un error más específico
}
const canAccessSection = isSuperAdmin || tienePermiso(requiredPermission);
if (!canAccessSection) {
console.error('SectionProtectedRoute: Usuario autenticado pero sin acceso a sección ', sectionName);
return <Navigate to="/" replace />;
}
// Si children se proporciona (como <SectionProtectedRoute><IndexPage/></SectionProtectedRoute>), renderiza children.
// Si no (como <Route element={<SectionProtectedRoute ... />} > <Route .../> </Route>), renderiza Outlet.
return children ? <>{children}</> : <Outlet />;
};
export default SectionProtectedRoute;

View File

@@ -0,0 +1,31 @@
import apiClient from '../apiClient';
import type { SaldoGestionDto } from '../../models/dtos/Contables/SaldoGestionDto';
import type { AjusteSaldoRequestDto } from '../../models/dtos/Contables/AjusteSaldoRequestDto';
interface GetSaldosParams {
destino?: 'Distribuidores' | 'Canillas' | '';
idDestino?: number | string; // Puede ser string si viene de un input antes de convertir
idEmpresa?: number | string;
}
const getAllSaldosGestion = async (filters?: GetSaldosParams): Promise<SaldoGestionDto[]> => {
const params: Record<string, string | number> = {};
if (filters?.destino) params.destino = filters.destino;
if (filters?.idDestino) params.idDestino = Number(filters.idDestino); // Asegurar número
if (filters?.idEmpresa) params.idEmpresa = Number(filters.idEmpresa); // Asegurar número
const response = await apiClient.get<SaldoGestionDto[]>('/saldos', { params });
return response.data;
};
const ajustarSaldo = async (data: AjusteSaldoRequestDto): Promise<SaldoGestionDto> => { // Esperamos el saldo actualizado
const response = await apiClient.post<SaldoGestionDto>('/saldos/ajustar', data);
return response.data;
};
const saldoService = {
getAllSaldosGestion,
ajustarSaldo,
};
export default saldoService;

View File

@@ -5,11 +5,17 @@ import type { UpdateCanillaDto } from '../../models/dtos/Distribucion/UpdateCani
import type { ToggleBajaCanillaDto } from '../../models/dtos/Distribucion/ToggleBajaCanillaDto';
const getAllCanillas = async (nomApeFilter?: string, legajoFilter?: number, soloActivos?: boolean): Promise<CanillaDto[]> => {
const getAllCanillas = async (
nomApeFilter?: string,
legajoFilter?: number,
soloActivos?: boolean,
esAccionistaFilter?: boolean // Asegúrate que esté aquí
): Promise<CanillaDto[]> => {
const params: Record<string, string | number | boolean> = {};
if (nomApeFilter) params.nomApe = nomApeFilter;
if (legajoFilter !== undefined && legajoFilter !== null) params.legajo = legajoFilter;
if (soloActivos !== undefined) params.soloActivos = soloActivos;
if (esAccionistaFilter !== undefined) params.esAccionista = esAccionistaFilter; // <<-- ¡CLAVE! Verifica esto.
const response = await apiClient.get<CanillaDto[]>('/canillas', { params });
return response.data;

View File

@@ -2,6 +2,8 @@ import apiClient from '../apiClient';
import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { CreateDistribuidorDto } from '../../models/dtos/Distribucion/CreateDistribuidorDto';
import type { UpdateDistribuidorDto } from '../../models/dtos/Distribucion/UpdateDistribuidorDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { DistribuidorLookupDto } from '../../models/dtos/Distribucion/DistribuidorLookupDto';
const getAllDistribuidores = async (nombreFilter?: string, nroDocFilter?: string): Promise<DistribuidorDto[]> => {
const params: Record<string, string> = {};
@@ -17,6 +19,11 @@ const getDistribuidorById = async (id: number): Promise<DistribuidorDto> => {
return response.data;
};
const getDistribuidorLookupById = async (id: number): Promise<DistribuidorLookupDto> => {
const response = await apiClient.get<DistribuidorLookupDto>(`/distribuidores/${id}/lookup`);
return response.data;
};
const createDistribuidor = async (data: CreateDistribuidorDto): Promise<DistribuidorDto> => {
const response = await apiClient.post<DistribuidorDto>('/distribuidores', data);
return response.data;
@@ -30,12 +37,19 @@ const deleteDistribuidor = async (id: number): Promise<void> => {
await apiClient.delete(`/distribuidores/${id}`);
};
const getAllDistribuidoresDropdown = async (): Promise<DistribuidorDropdownDto[]> => {
const response = await apiClient.get<DistribuidorDropdownDto[]>('/distribuidores/dropdown');
return response.data;
};
const distribuidorService = {
getAllDistribuidores,
getDistribuidorById,
createDistribuidor,
updateDistribuidor,
deleteDistribuidor,
getAllDistribuidoresDropdown,
getDistribuidorLookupById,
};
export default distribuidorService;

View File

@@ -2,6 +2,8 @@ import apiClient from '../apiClient';
import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import type { CreateEmpresaDto } from '../../models/dtos/Distribucion/CreateEmpresaDto';
import type { UpdateEmpresaDto } from '../../models/dtos/Distribucion/UpdateEmpresaDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import type { EmpresaLookupDto } from '../../models/dtos/Distribucion/EmpresaLookupDto';
const getAllEmpresas = async (nombreFilter?: string, detalleFilter?: string): Promise<EmpresaDto[]> => {
const params: Record<string, string> = {};
@@ -19,6 +21,12 @@ const getEmpresaById = async (id: number): Promise<EmpresaDto> => {
return response.data;
};
const getEmpresaLookupById = async (id: number): Promise<EmpresaLookupDto> => {
// Llama a GET /api/empresas/{id}/lookup
const response = await apiClient.get<EmpresaLookupDto>(`/empresas/${id}/lookup`);
return response.data;
};
const createEmpresa = async (data: CreateEmpresaDto): Promise<EmpresaDto> => {
// Llama a POST /api/empresas
const response = await apiClient.post<EmpresaDto>('/empresas', data);
@@ -35,12 +43,20 @@ const deleteEmpresa = async (id: number): Promise<void> => {
await apiClient.delete(`/empresas/${id}`);
};
const getEmpresasDropdown = async (): Promise<EmpresaDropdownDto[]> => {
// Llama a GET /api/empresas
const response = await apiClient.get<EmpresaDropdownDto[]>('/empresas/dropdown');
return response.data;
};
const empresaService = {
getAllEmpresas,
getEmpresaById,
createEmpresa,
updateEmpresa,
deleteEmpresa,
getEmpresasDropdown,
getEmpresaLookupById,
};
export default empresaService;

View File

@@ -0,0 +1,56 @@
import apiClient from '../apiClient';
import type { NovedadCanillaDto } from '../../models/dtos/Distribucion/NovedadCanillaDto';
import type { CreateNovedadCanillaDto } from '../../models/dtos/Distribucion/CreateNovedadCanillaDto';
import type { UpdateNovedadCanillaDto } from '../../models/dtos/Distribucion/UpdateNovedadCanillaDto';
interface GetNovedadesParams {
fechaDesde?: string | null; // "yyyy-MM-dd"
fechaHasta?: string | null; // "yyyy-MM-dd"
}
// Obtiene las novedades para un canillita específico, opcionalmente filtradas por fecha.
// Corresponde a GET api/novedadescanilla/porcanilla/{idCanilla}
const getNovedadesPorCanilla = async (idCanilla: number, params?: GetNovedadesParams): Promise<NovedadCanillaDto[]> => {
const queryParams: Record<string, string> = {};
if (params?.fechaDesde) queryParams.fechaDesde = params.fechaDesde;
if (params?.fechaHasta) queryParams.fechaHasta = params.fechaHasta;
const response = await apiClient.get<NovedadCanillaDto[]>(`/novedadescanilla/porcanilla/${idCanilla}`, { params: queryParams });
return response.data;
};
// Obtiene una novedad específica por su ID.
// Corresponde a GET api/novedadescanilla/{idNovedad}
const getNovedadById = async (idNovedad: number): Promise<NovedadCanillaDto> => {
const response = await apiClient.get<NovedadCanillaDto>(`/novedadescanilla/${idNovedad}`);
return response.data;
};
// Crea una nueva novedad. El IdCanilla está en el DTO.
// Corresponde a POST api/novedadescanilla
const createNovedad = async (data: CreateNovedadCanillaDto): Promise<NovedadCanillaDto> => {
const response = await apiClient.post<NovedadCanillaDto>('/novedadescanilla', data);
return response.data;
};
// Actualiza una novedad existente.
// Corresponde a PUT api/novedadescanilla/{idNovedad}
const updateNovedad = async (idNovedad: number, data: UpdateNovedadCanillaDto): Promise<void> => {
await apiClient.put(`/novedadescanilla/${idNovedad}`, data);
};
// Elimina una novedad.
// Corresponde a DELETE api/novedadescanilla/{idNovedad}
const deleteNovedad = async (idNovedad: number): Promise<void> => {
await apiClient.delete(`/novedadescanilla/${idNovedad}`);
};
const novedadCanillaService = {
getNovedadesPorCanilla,
getNovedadById,
createNovedad,
updateNovedad,
deleteNovedad,
};
export default novedadCanillaService;

View File

@@ -16,6 +16,10 @@ import type { ComparativaConsumoBobinasDto } from '../../models/dtos/Reportes/Co
import type { ReporteCuentasDistribuidorResponseDto } from '../../models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto';
import type { ListadoDistribucionDistribuidoresResponseDto } from '../../models/dtos/Reportes/ListadoDistribucionDistribuidoresResponseDto';
import type { ControlDevolucionesDataResponseDto } from '../../models/dtos/Reportes/ControlDevolucionesDataResponseDto';
import type { NovedadesCanillasReporteDto } from '../../models/dtos/Reportes/NovedadesCanillasReporteDto';
import type { CanillaGananciaReporteDto } from '../../models/dtos/Reportes/CanillaGananciaReporteDto';
import type { ListadoDistCanMensualDiariosDto } from '../../models/dtos/Reportes/ListadoDistCanMensualDiariosDto';
import type { ListadoDistCanMensualPubDto } from '../../models/dtos/Reportes/ListadoDistCanMensualPubDto';
interface GetExistenciaPapelParams {
fechaDesde: string; // yyyy-MM-dd
@@ -24,6 +28,18 @@ interface GetExistenciaPapelParams {
consolidado: boolean;
}
interface GetNovedadesCanillasParams {
idEmpresa: number;
fechaDesde: string; // yyyy-MM-dd
fechaHasta: string; // yyyy-MM-dd
}
interface GetListadoDistMensualParams {
fechaDesde: string; // yyyy-MM-dd
fechaHasta: string; // yyyy-MM-dd
esAccionista: boolean;
}
const getExistenciaPapelPdf = async (params: GetExistenciaPapelParams): Promise<Blob> => {
const queryParams: Record<string, string | number | boolean> = {
fechaDesde: params.fechaDesde,
@@ -385,6 +401,50 @@ const getTicketLiquidacionCanillaPdf = async (params: {
return response.data;
};
const getNovedadesCanillasReporte = async (params: GetNovedadesCanillasParams): Promise<NovedadesCanillasReporteDto[]> => {
const response = await apiClient.get<NovedadesCanillasReporteDto[]>('/reportes/novedades-canillas', { params });
return response.data;
};
const getNovedadesCanillasReportePdf = async (params: GetNovedadesCanillasParams): Promise<Blob> => {
const response = await apiClient.get('/reportes/novedades-canillas/pdf', {
params,
responseType: 'blob',
});
return response.data;
};
const getCanillasGananciasReporte = async (params: GetNovedadesCanillasParams): Promise<CanillaGananciaReporteDto[]> => {
const response = await apiClient.get<CanillaGananciaReporteDto[]>('/reportes/novedades-canillas-ganancias', { params });
return response.data;
};
const getListadoDistMensualDiarios = async (params: GetListadoDistMensualParams): Promise<ListadoDistCanMensualDiariosDto[]> => {
const response = await apiClient.get<ListadoDistCanMensualDiariosDto[]>('/reportes/listado-distribucion-mensual/diarios', { params });
return response.data;
};
const getListadoDistMensualDiariosPdf = async (params: GetListadoDistMensualParams): Promise<Blob> => {
const response = await apiClient.get('/reportes/listado-distribucion-mensual/diarios/pdf', {
params,
responseType: 'blob',
});
return response.data;
};
const getListadoDistMensualPorPublicacion = async (params: GetListadoDistMensualParams): Promise<ListadoDistCanMensualPubDto[]> => {
const response = await apiClient.get<ListadoDistCanMensualPubDto[]>('/reportes/listado-distribucion-mensual/publicaciones', { params });
return response.data;
};
const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMensualParams): Promise<Blob> => {
const response = await apiClient.get('/reportes/listado-distribucion-mensual/publicaciones/pdf', {
params,
responseType: 'blob',
});
return response.data;
};
const reportesService = {
getExistenciaPapel,
getExistenciaPapelPdf,
@@ -421,6 +481,13 @@ const reportesService = {
getControlDevolucionesData,
getControlDevolucionesPdf,
getTicketLiquidacionCanillaPdf,
getNovedadesCanillasReporte,
getNovedadesCanillasReportePdf,
getCanillasGananciasReporte,
getListadoDistMensualDiarios,
getListadoDistMensualDiariosPdf,
getListadoDistMensualPorPublicacion,
getListadoDistMensualPorPublicacionPdf,
};
export default reportesService;