feat: Implementar ingreso de bobinas por lote
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m13s
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.
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user