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>
);
};