All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m13s
Se introduce una nueva funcionalidad para el ingreso masivo de bobinas a partir de un único remito. Esto agiliza significativamente la carga de datos y reduce errores al evitar la repetición de la planta, número y fecha de remito. La implementación incluye: - Un modal maestro-detalle de dos pasos que primero verifica el remito y luego permite la carga de las bobinas. - Lógica de autocompletado de fecha y feedback al usuario si el remito ya existe. - Un nuevo endpoint en el backend para procesar el lote de forma transaccional.
374 lines
16 KiB
TypeScript
374 lines
16 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import {
|
|
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
|
FormControl, InputLabel, Select, MenuItem, Stepper, Step, StepLabel,
|
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, IconButton, Divider,
|
|
InputAdornment, Tooltip
|
|
} from '@mui/material';
|
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
|
|
import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto';
|
|
import type { CreateStockBobinaLoteDto } from '../../../models/dtos/Impresion/CreateStockBobinaLoteDto';
|
|
import type { BobinaLoteDetalleDto } from '../../../models/dtos/Impresion/BobinaLoteDetalleDto';
|
|
import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto';
|
|
import type { TipoBobinaDto } from '../../../models/dtos/Impresion/TipoBobinaDto';
|
|
import stockBobinaService from '../../../services/Impresion/stockBobinaService';
|
|
import plantaService from '../../../services/Impresion/plantaService';
|
|
import tipoBobinaService from '../../../services/Impresion/tipoBobinaService';
|
|
|
|
|
|
const modalStyle = {
|
|
position: 'absolute' as 'absolute',
|
|
top: '50%', left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: { xs: '95%', sm: '80%', md: '900px' },
|
|
bgcolor: 'background.paper',
|
|
border: '2px solid #000', boxShadow: 24, p: 3,
|
|
maxHeight: '90vh', overflowY: 'auto', display: 'flex', flexDirection: 'column'
|
|
};
|
|
|
|
interface NuevaBobinaState extends BobinaLoteDetalleDto {
|
|
idTemporal: string;
|
|
}
|
|
|
|
interface StockBobinaLoteFormModalProps {
|
|
open: boolean;
|
|
onClose: (refrescar: boolean) => void;
|
|
}
|
|
|
|
const steps = ['Datos del Remito', 'Ingreso de Bobinas'];
|
|
|
|
const StockBobinaLoteFormModal: React.FC<StockBobinaLoteFormModalProps> = ({ open, onClose }) => {
|
|
const [activeStep, setActiveStep] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [apiError, setApiError] = useState<string | null>(null);
|
|
|
|
// Step 1 State
|
|
const [idPlanta, setIdPlanta] = useState<number | ''>('');
|
|
const [remito, setRemito] = useState('');
|
|
const [fechaRemito, setFechaRemito] = useState(new Date().toISOString().split('T')[0]);
|
|
const [headerErrors, setHeaderErrors] = useState<{ [key: string]: string }>({});
|
|
const [isVerifying, setIsVerifying] = useState(false);
|
|
const [remitoStatusMessage, setRemitoStatusMessage] = useState<string | null>(null);
|
|
const [remitoStatusSeverity, setRemitoStatusSeverity] = useState<'success' | 'info'>('info');
|
|
const [isDateAutocompleted, setIsDateAutocompleted] = useState(false);
|
|
|
|
// Step 2 State
|
|
const [bobinasExistentes, setBobinasExistentes] = useState<StockBobinaDto[]>([]);
|
|
const [nuevasBobinas, setNuevasBobinas] = useState<NuevaBobinaState[]>([]);
|
|
const [detalleErrors, setDetalleErrors] = useState<{ [key: string]: string }>({});
|
|
|
|
// Dropdowns data
|
|
const [plantas, setPlantas] = useState<PlantaDto[]>([]);
|
|
const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]);
|
|
const [loadingDropdowns, setLoadingDropdowns] = useState(true);
|
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const resetState = useCallback(() => {
|
|
setActiveStep(0); setLoading(false); setApiError(null);
|
|
setIdPlanta(''); setRemito(''); setFechaRemito(new Date().toISOString().split('T')[0]);
|
|
setHeaderErrors({}); setBobinasExistentes([]); setNuevasBobinas([]); setDetalleErrors({});
|
|
setRemitoStatusMessage(null);
|
|
setIsDateAutocompleted(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const fetchDropdowns = async () => {
|
|
setLoadingDropdowns(true);
|
|
try {
|
|
const [plantasData, tiposData] = await Promise.all([
|
|
plantaService.getAllPlantas(),
|
|
tipoBobinaService.getAllTiposBobina()
|
|
]);
|
|
setPlantas(plantasData);
|
|
setTiposBobina(tiposData);
|
|
} catch (error) {
|
|
setApiError("Error al cargar datos necesarios (plantas, tipos).");
|
|
} finally {
|
|
setLoadingDropdowns(false);
|
|
}
|
|
};
|
|
if (open) {
|
|
fetchDropdowns();
|
|
} else {
|
|
resetState();
|
|
}
|
|
}, [open, resetState]);
|
|
|
|
useEffect(() => {
|
|
const verificarRemitoParaAutocompletar = async () => {
|
|
setRemitoStatusMessage(null);
|
|
if (remito.trim() && idPlanta) {
|
|
setIsVerifying(true);
|
|
try {
|
|
const existentes = await stockBobinaService.verificarRemitoExistente(Number(idPlanta), remito.trim());
|
|
if (existentes.length > 0) {
|
|
setFechaRemito(existentes[0].fechaRemito.split('T')[0]);
|
|
setRemitoStatusMessage("Remito existente. Se autocompletó la fecha.");
|
|
setRemitoStatusSeverity('info');
|
|
setIsDateAutocompleted(true);
|
|
} else {
|
|
setRemitoStatusMessage("Este es un remito nuevo.");
|
|
setRemitoStatusSeverity('success');
|
|
setIsDateAutocompleted(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Fallo la verificación automática de remito: ", error);
|
|
setRemitoStatusMessage("No se pudo verificar el remito.");
|
|
setRemitoStatusSeverity('info');
|
|
} finally {
|
|
setIsVerifying(false);
|
|
}
|
|
}
|
|
};
|
|
const handler = setTimeout(() => {
|
|
verificarRemitoParaAutocompletar();
|
|
}, 500);
|
|
|
|
return () => {
|
|
clearTimeout(handler);
|
|
};
|
|
}, [idPlanta, remito]);
|
|
|
|
const handleClose = () => onClose(false);
|
|
|
|
const handleNext = async () => {
|
|
const errors: { [key: string]: string } = {};
|
|
if (!remito.trim()) errors.remito = "El número de remito es obligatorio.";
|
|
if (!idPlanta) errors.idPlanta = "Seleccione una planta.";
|
|
if (!fechaRemito) errors.fechaRemito = "La fecha es obligatoria.";
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
setHeaderErrors(errors);
|
|
return;
|
|
}
|
|
|
|
setLoading(true); setApiError(null);
|
|
try {
|
|
const existentes = await stockBobinaService.verificarRemitoExistente(Number(idPlanta), remito, fechaRemito);
|
|
setBobinasExistentes(existentes);
|
|
setActiveStep(1);
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message || "Error al verificar el remito.";
|
|
setApiError(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => setActiveStep(0);
|
|
|
|
const handleAddBobina = () => {
|
|
setNuevasBobinas(prev => [...prev, {
|
|
idTemporal: crypto.randomUUID(), idTipoBobina: 0, nroBobina: '', peso: 0
|
|
}]);
|
|
setTimeout(() => {
|
|
tableContainerRef.current?.scrollTo({ top: tableContainerRef.current.scrollHeight, behavior: 'smooth' });
|
|
}, 100);
|
|
};
|
|
|
|
const handleRemoveBobina = (idTemporal: string) => {
|
|
setNuevasBobinas(prev => prev.filter(b => b.idTemporal !== idTemporal));
|
|
};
|
|
|
|
const handleBobinaChange = (idTemporal: string, field: keyof NuevaBobinaState, value: any) => {
|
|
setNuevasBobinas(prev => prev.map(b => b.idTemporal === idTemporal ? { ...b, [field]: value } : b));
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
const errors: { [key: string]: string } = {};
|
|
if (nuevasBobinas.length === 0) {
|
|
setApiError("Debe agregar al menos una nueva bobina para guardar.");
|
|
return;
|
|
}
|
|
|
|
const todosNrosBobina = new Set(bobinasExistentes.map(b => b.nroBobina));
|
|
nuevasBobinas.forEach(b => {
|
|
if (!b.idTipoBobina) errors[b.idTemporal + '_tipo'] = "Requerido";
|
|
if (!b.nroBobina.trim()) errors[b.idTemporal + '_nro'] = "Requerido";
|
|
if ((b.peso || 0) <= 0) errors[b.idTemporal + '_peso'] = "Inválido";
|
|
if (todosNrosBobina.has(b.nroBobina.trim())) errors[b.idTemporal + '_nro'] = "Duplicado";
|
|
todosNrosBobina.add(b.nroBobina.trim());
|
|
});
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
setDetalleErrors(errors);
|
|
return;
|
|
}
|
|
|
|
setLoading(true); setApiError(null);
|
|
try {
|
|
const lote: CreateStockBobinaLoteDto = {
|
|
idPlanta: Number(idPlanta),
|
|
remito: remito.trim(),
|
|
fechaRemito,
|
|
bobinas: nuevasBobinas.map(({ idTipoBobina, nroBobina, peso }) => ({
|
|
idTipoBobina: Number(idTipoBobina), nroBobina: nroBobina.trim(), peso: Number(peso)
|
|
}))
|
|
};
|
|
await stockBobinaService.ingresarLoteBobinas(lote);
|
|
onClose(true);
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message || "Error al guardar el lote de bobinas.";
|
|
setApiError(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
|
|
const renderStepContent = (step: number) => {
|
|
switch (step) {
|
|
case 0:
|
|
return (
|
|
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<Typography variant="h6">Datos de Cabecera</Typography>
|
|
<TextField label="Número de Remito" value={remito}
|
|
onChange={e => {
|
|
setRemito(e.target.value);
|
|
setIsDateAutocompleted(false);
|
|
}}
|
|
required error={!!headerErrors.remito} helperText={headerErrors.remito} disabled={loading} autoFocus
|
|
/>
|
|
<FormControl fullWidth error={!!headerErrors.idPlanta}>
|
|
<InputLabel id="planta-label" required>Planta de Destino</InputLabel>
|
|
<Select labelId="planta-label" value={idPlanta} label="Planta de Destino"
|
|
onChange={e => {
|
|
setIdPlanta(e.target.value as number);
|
|
setIsDateAutocompleted(false);
|
|
}}
|
|
disabled={loading || loadingDropdowns}
|
|
endAdornment={isVerifying && (<InputAdornment position="end" sx={{ mr: 2 }}><CircularProgress size={20} /></InputAdornment>)}
|
|
>
|
|
{plantas.map(p => <MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>)}
|
|
</Select>
|
|
{headerErrors.idPlanta && <Typography color="error" variant="caption">{headerErrors.idPlanta}</Typography>}
|
|
</FormControl>
|
|
<TextField
|
|
label="Fecha de Remito"
|
|
type="date"
|
|
value={fechaRemito}
|
|
onChange={e => setFechaRemito(e.target.value)}
|
|
InputLabelProps={{ shrink: true }}
|
|
required
|
|
error={!!headerErrors.fechaRemito}
|
|
helperText={headerErrors.fechaRemito}
|
|
disabled={loading || isDateAutocompleted}
|
|
InputProps={{
|
|
endAdornment: (
|
|
isDateAutocompleted && (
|
|
<InputAdornment position="end">
|
|
<Tooltip title="Editar fecha">
|
|
<IconButton onClick={() => setIsDateAutocompleted(false)} edge="end">
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</InputAdornment>
|
|
)
|
|
)
|
|
}}
|
|
/>
|
|
|
|
<Box sx={{ minHeight: 48, mt: 1 }}>
|
|
{remitoStatusMessage && !isVerifying && (
|
|
<Alert severity={remitoStatusSeverity} icon={false} variant="outlined">
|
|
{remitoStatusMessage}
|
|
</Alert>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
case 1:
|
|
return (
|
|
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
{bobinasExistentes.length > 0 && (
|
|
<>
|
|
<Typography variant="subtitle1" gutterBottom>Bobinas ya ingresadas para este remito:</Typography>
|
|
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: '150px', mb: 2 }}>
|
|
<Table size="small" stickyHeader>
|
|
<TableHead><TableRow><TableCell>Nro. Bobina</TableCell><TableCell>Tipo</TableCell><TableCell align="right">Peso (Kg)</TableCell></TableRow></TableHead>
|
|
<TableBody>
|
|
{bobinasExistentes.map(b => (
|
|
<TableRow key={b.idBobina}><TableCell>{b.nroBobina}</TableCell><TableCell>{b.nombreTipoBobina}</TableCell><TableCell align="right">{b.peso}</TableCell></TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</>
|
|
)}
|
|
<Typography variant="h6">Nuevas Bobinas a Ingresar</Typography>
|
|
<TableContainer component={Paper} variant="outlined" sx={{ flexGrow: 1, my: 1, minHeight: '150px' }} ref={tableContainerRef}>
|
|
<Table size="small" stickyHeader>
|
|
<TableHead><TableRow><TableCell sx={{ minWidth: 200 }}>Tipo Bobina</TableCell><TableCell>Nro. Bobina</TableCell><TableCell>Peso (Kg)</TableCell><TableCell></TableCell></TableRow></TableHead>
|
|
<TableBody>
|
|
{nuevasBobinas.map(bobina => (
|
|
<TableRow key={bobina.idTemporal}>
|
|
<TableCell><FormControl fullWidth size="small" error={!!detalleErrors[bobina.idTemporal + '_tipo']}><Select value={bobina.idTipoBobina} onChange={e => handleBobinaChange(bobina.idTemporal, 'idTipoBobina', e.target.value)} disabled={loadingDropdowns}><MenuItem value={0} disabled>Seleccione</MenuItem>{tiposBobina.map(t => <MenuItem key={t.idTipoBobina} value={t.idTipoBobina}>{t.denominacion}</MenuItem>)}</Select></FormControl></TableCell>
|
|
<TableCell><TextField fullWidth size="small" value={bobina.nroBobina} onChange={e => handleBobinaChange(bobina.idTemporal, 'nroBobina', e.target.value)} error={!!detalleErrors[bobina.idTemporal + '_nro']} helperText={detalleErrors[bobina.idTemporal + '_nro']} /></TableCell>
|
|
<TableCell><TextField fullWidth size="small" type="number" value={bobina.peso || ''} onChange={e => handleBobinaChange(bobina.idTemporal, 'peso', e.target.value)} error={!!detalleErrors[bobina.idTemporal + '_peso']} helperText={detalleErrors[bobina.idTemporal + '_peso']} /></TableCell>
|
|
<TableCell><IconButton size="small" color="error" onClick={() => handleRemoveBobina(bobina.idTemporal)}><DeleteOutlineIcon /></IconButton></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
<Button startIcon={<AddCircleOutlineIcon />} onClick={handleAddBobina} sx={{ mt: 1 }}>Agregar Bobina</Button>
|
|
</Box>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal open={open} onClose={handleClose}>
|
|
<Box sx={modalStyle}>
|
|
<IconButton
|
|
aria-label="close"
|
|
onClick={handleClose}
|
|
sx={{
|
|
position: 'absolute',
|
|
right: 8,
|
|
top: 8,
|
|
color: (theme) => theme.palette.grey[500],
|
|
}}
|
|
>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
|
|
<Typography variant="h5" component="h2" gutterBottom>Ingreso de Bobinas por Lote</Typography>
|
|
|
|
<Stepper activeStep={activeStep} sx={{ mb: 2 }}>
|
|
{steps.map((label) => (
|
|
<Step key={label}>
|
|
<StepLabel>{label}</StepLabel>
|
|
</Step>
|
|
))}
|
|
</Stepper>
|
|
|
|
<Box sx={{ flexGrow: 1, overflowY: 'auto' }}>
|
|
{loadingDropdowns && activeStep === 0 ? <Box sx={{ display: 'flex', justifyContent: 'center', my: 5 }}><CircularProgress /></Box> : renderStepContent(activeStep)}
|
|
</Box>
|
|
|
|
{apiError && <Alert severity="error" sx={{ mt: 2, flexShrink: 0 }}>{apiError}</Alert>}
|
|
|
|
<Divider sx={{ my: 2, flexShrink: 0 }} />
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', pt: 1, flexShrink: 0 }}>
|
|
<Button color="inherit" disabled={activeStep === 0 || loading} onClick={handleBack}>
|
|
Atrás
|
|
</Button>
|
|
<Box>
|
|
{activeStep === 0 && <Button onClick={handleNext} variant="contained" disabled={loading || loadingDropdowns}>{loading ? <CircularProgress size={24} /> : 'Verificar y Continuar'}</Button>}
|
|
{activeStep === 1 && <Button onClick={handleSubmit} variant="contained" color="success" disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Guardar Lote'}</Button>}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default StockBobinaLoteFormModal; |