feat: Implementación de Secciones, Recargos, Porc. Pago Dist. y backend E/S Dist.
Backend API:
- Recargos por Zona (`dist_RecargoZona`):
  - CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Endpoints anidados bajo `/publicaciones/{idPublicacion}/recargos`.
  - Lógica de negocio para vigencias (cierre/reapertura de períodos).
  - Auditoría en `dist_RecargoZona_H`.
- Porcentajes de Pago Distribuidores (`dist_PorcPago`):
  - CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Endpoints anidados bajo `/publicaciones/{idPublicacion}/porcentajespago`.
  - Lógica de negocio para vigencias.
  - Auditoría en `dist_PorcPago_H`.
- Porcentajes/Montos Pago Canillitas (`dist_PorcMonPagoCanilla`):
  - CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Endpoints anidados bajo `/publicaciones/{idPublicacion}/porcentajesmoncanilla`.
  - Lógica de negocio para vigencias.
  - Auditoría en `dist_PorcMonPagoCanilla_H`.
- Secciones de Publicación (`dist_dtPubliSecciones`):
  - CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Endpoints anidados bajo `/publicaciones/{idPublicacion}/secciones`.
  - Auditoría en `dist_dtPubliSecciones_H`.
- Entradas/Salidas Distribuidores (`dist_EntradasSalidas`):
  - Implementado backend (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Lógica para determinar precios/recargos/porcentajes aplicables.
  - Cálculo de monto y afectación de saldos de distribuidores en `cue_Saldos`.
  - Auditoría en `dist_EntradasSalidas_H`.
- Correcciones de Mapeo Dapper:
  - Aplicados alias explícitos en repositorios de RecargoZona, PorcPago, PorcMonCanilla, PubliSeccion,
    Canilla, Distribuidor y Precio para asegurar mapeo correcto de IDs y columnas.
Frontend React:
- Recargos por Zona:
  - `recargoZonaService.ts`.
  - `RecargoZonaFormModal.tsx` para crear/editar períodos de recargos.
  - `GestionarRecargosPublicacionPage.tsx` para listar y gestionar recargos por publicación.
- Porcentajes de Pago Distribuidores:
  - `porcPagoService.ts`.
  - `PorcPagoFormModal.tsx`.
  - `GestionarPorcentajesPagoPage.tsx`.
- Porcentajes/Montos Pago Canillitas:
  - `porcMonCanillaService.ts`.
  - `PorcMonCanillaFormModal.tsx`.
  - `GestionarPorcMonCanillaPage.tsx`.
- Secciones de Publicación:
  - `publiSeccionService.ts`.
  - `PubliSeccionFormModal.tsx`.
  - `GestionarSeccionesPublicacionPage.tsx`.
- Navegación:
  - Actualizadas rutas y menús para acceder a la gestión de recargos, porcentajes (dist. y canillita) y secciones desde la vista de una publicación.
- Layout:
  - Uso consistente de `Box` con Flexbox en lugar de `Grid` en nuevos modales y páginas para evitar errores de tipo.
			
			
This commit is contained in:
		| @@ -0,0 +1,261 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem | ||||
| } from '@mui/material'; | ||||
| import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto'; | ||||
| import type { CambiarEstadoBobinaDto } from '../../../models/dtos/Impresion/CambiarEstadoBobinaDto'; | ||||
| import type { EstadoBobinaDto } from '../../../models/dtos/Impresion/EstadoBobinaDto'; | ||||
| import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto'; | ||||
| import type { PubliSeccionDto } from '../../../models/dtos/Distribucion/PubliSeccionDto'; // Asumiendo que tienes este DTO | ||||
| import estadoBobinaService from '../../../services/Impresion/estadoBobinaService'; // Para cargar estados | ||||
| import publicacionService from '../../../services/Distribucion/publicacionService'; // Para cargar publicaciones | ||||
| import publiSeccionService from '../../../services/Distribucion/publiSeccionService'; // Para cargar secciones | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     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' | ||||
| }; | ||||
|  | ||||
| // IDs de estados conocidos (ajusta según tu BD) | ||||
| const ID_ESTADO_EN_USO = 2; | ||||
| const ID_ESTADO_DANADA = 3; | ||||
| // const ID_ESTADO_DISPONIBLE = 1; // No se cambia a Disponible desde este modal | ||||
|  | ||||
| interface StockBobinaCambioEstadoModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (idBobina: number, data: CambiarEstadoBobinaDto) => Promise<void>; | ||||
|   bobinaActual: StockBobinaDto | null; // La bobina cuyo estado se va a cambiar | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   bobinaActual, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [nuevoEstadoId, setNuevoEstadoId] = useState<number | string>(''); | ||||
|   const [idPublicacion, setIdPublicacion] = useState<number | string>(''); | ||||
|   const [idSeccion, setIdSeccion] = useState<number | string>(''); | ||||
|   const [obs, setObs] = useState(''); | ||||
|   const [fechaCambioEstado, setFechaCambioEstado] = useState(''); | ||||
|  | ||||
|   const [estadosDisponibles, setEstadosDisponibles] = useState<EstadoBobinaDto[]>([]); | ||||
|   const [publicacionesDisponibles, setPublicacionesDisponibles] = useState<PublicacionDto[]>([]); | ||||
|   const [seccionesDisponibles, setSeccionesDisponibles] = useState<PubliSeccionDto[]>([]); | ||||
|  | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchDropdownData = async () => { | ||||
|         if (!bobinaActual) return; | ||||
|         setLoadingDropdowns(true); | ||||
|         try { | ||||
|             const estadosData = await estadoBobinaService.getAllEstadosBobina(); | ||||
|             // Filtrar estados: no se puede volver a "Disponible" o al mismo estado actual desde aquí. | ||||
|             // Y si está "Dañada", no se puede cambiar. | ||||
|             let estadosFiltrados = estadosData.filter(e => e.idEstadoBobina !== bobinaActual.idEstadoBobina && e.idEstadoBobina !== 1); | ||||
|             if (bobinaActual.idEstadoBobina === ID_ESTADO_DANADA) { // Si ya está dañada, no hay más cambios | ||||
|                 estadosFiltrados = []; | ||||
|             } else if (bobinaActual.idEstadoBobina === ID_ESTADO_EN_USO) { // Si está en uso, solo puede pasar a Dañada | ||||
|                  estadosFiltrados = estadosData.filter(e => e.idEstadoBobina === ID_ESTADO_DANADA); | ||||
|             } | ||||
|  | ||||
|  | ||||
|             setEstadosDisponibles(estadosFiltrados); | ||||
|  | ||||
|             if (estadosFiltrados.some(e => e.idEstadoBobina === ID_ESTADO_EN_USO)) { // Solo cargar publicaciones si "En Uso" es una opción | ||||
|                 const publicacionesData = await publicacionService.getAllPublicaciones(undefined, undefined, true); // Solo habilitadas | ||||
|                 setPublicacionesDisponibles(publicacionesData); | ||||
|             } else { | ||||
|                 setPublicacionesDisponibles([]); | ||||
|             } | ||||
|  | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar datos para dropdowns (Cambio Estado Bobina)", error); | ||||
|             setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar datos necesarios.'})); | ||||
|         } finally { | ||||
|             setLoadingDropdowns(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open && bobinaActual) { | ||||
|         fetchDropdownData(); | ||||
|         setNuevoEstadoId(''); | ||||
|         setIdPublicacion(''); | ||||
|         setIdSeccion(''); | ||||
|         setObs(bobinaActual.obs || ''); // Pre-cargar obs existente | ||||
|         setFechaCambioEstado(new Date().toISOString().split('T')[0]); // Default a hoy | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, bobinaActual, clearErrorMessage]); | ||||
|  | ||||
|  | ||||
|   // Cargar secciones cuando cambia la publicación seleccionada y el estado es "En Uso" | ||||
|   useEffect(() => { | ||||
|     const fetchSecciones = async () => { | ||||
|         if (nuevoEstadoId === ID_ESTADO_EN_USO && idPublicacion) { | ||||
|             setLoadingDropdowns(true); // Podrías tener un loader específico para secciones | ||||
|             try { | ||||
|                 const data = await publiSeccionService.getSeccionesPorPublicacion(Number(idPublicacion), true); // Solo activas | ||||
|                 setSeccionesDisponibles(data); | ||||
|             } catch (error) { | ||||
|                 console.error("Error al cargar secciones:", error); | ||||
|                 setLocalErrors(prev => ({ ...prev, secciones: 'Error al cargar secciones.'})); | ||||
|             } finally { | ||||
|                 setLoadingDropdowns(false); | ||||
|             } | ||||
|         } else { | ||||
|             setSeccionesDisponibles([]); // Limpiar secciones si no aplica | ||||
|         } | ||||
|     }; | ||||
|     fetchSecciones(); | ||||
|   }, [nuevoEstadoId, idPublicacion]); | ||||
|  | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!nuevoEstadoId) errors.nuevoEstadoId = 'Seleccione un nuevo estado.'; | ||||
|     if (!fechaCambioEstado.trim()) errors.fechaCambioEstado = 'La fecha de cambio es obligatoria.'; | ||||
|     else if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaCambioEstado)) errors.fechaCambioEstado = 'Formato de fecha inválido.'; | ||||
|  | ||||
|     if (Number(nuevoEstadoId) === ID_ESTADO_EN_USO) { | ||||
|         if (!idPublicacion) errors.idPublicacion = 'Seleccione una publicación.'; | ||||
|         if (!idSeccion) errors.idSeccion = 'Seleccione una sección.'; | ||||
|     } | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = (fieldName: string) => { | ||||
|     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); | ||||
|     if (errorMessage) clearErrorMessage(); | ||||
|     if (fieldName === 'nuevoEstadoId') { // Si cambia el estado, resetear pub/secc | ||||
|         setIdPublicacion(''); | ||||
|         setIdSeccion(''); | ||||
|     } | ||||
|      if (fieldName === 'idPublicacion') { // Si cambia la publicación, resetear seccion | ||||
|         setIdSeccion(''); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     clearErrorMessage(); | ||||
|     if (!validate() || !bobinaActual) return; | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit: CambiarEstadoBobinaDto = { | ||||
|           nuevoEstadoId: Number(nuevoEstadoId), | ||||
|           idPublicacion: Number(nuevoEstadoId) === ID_ESTADO_EN_USO ? Number(idPublicacion) : null, | ||||
|           idSeccion: Number(nuevoEstadoId) === ID_ESTADO_EN_USO ? Number(idSeccion) : null, | ||||
|           obs: obs || undefined, | ||||
|           fechaCambioEstado, | ||||
|       }; | ||||
|       await onSubmit(bobinaActual.idBobina, dataToSubmit); | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de StockBobinaCambioEstadoModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!bobinaActual) return null; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|             Cambiar Estado de Bobina: {bobinaActual.nroBobina} | ||||
|         </Typography> | ||||
|         <Typography variant="body2" gutterBottom> | ||||
|             Estado Actual: <strong>{bobinaActual.nombreEstadoBobina}</strong> | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> | ||||
|                 <FormControl fullWidth margin="dense" error={!!localErrors.nuevoEstadoId}> | ||||
|                     <InputLabel id="nuevo-estado-select-label" required>Nuevo Estado</InputLabel> | ||||
|                     <Select labelId="nuevo-estado-select-label" label="Nuevo Estado" value={nuevoEstadoId} | ||||
|                         onChange={(e) => {setNuevoEstadoId(e.target.value as number); handleInputChange('nuevoEstadoId');}} | ||||
|                         disabled={loading || loadingDropdowns || estadosDisponibles.length === 0} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione un estado</em></MenuItem> | ||||
|                         {estadosDisponibles.map((e) => (<MenuItem key={e.idEstadoBobina} value={e.idEstadoBobina}>{e.denominacion}</MenuItem>))} | ||||
|                     </Select> | ||||
|                     {localErrors.nuevoEstadoId && <Typography color="error" variant="caption">{localErrors.nuevoEstadoId}</Typography>} | ||||
|                 </FormControl> | ||||
|  | ||||
|                 {Number(nuevoEstadoId) === ID_ESTADO_EN_USO && ( | ||||
|                     <> | ||||
|                         <FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}> | ||||
|                             <InputLabel id="publicacion-estado-select-label" required>Publicación</InputLabel> | ||||
|                             <Select labelId="publicacion-estado-select-label" label="Publicación" value={idPublicacion} | ||||
|                                 onChange={(e) => {setIdPublicacion(e.target.value as number); handleInputChange('idPublicacion');}} | ||||
|                                 disabled={loading || loadingDropdowns} | ||||
|                             > | ||||
|                                 <MenuItem value="" disabled><em>Seleccione publicación</em></MenuItem> | ||||
|                                 {publicacionesDisponibles.map((p) => (<MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre}</MenuItem>))} | ||||
|                             </Select> | ||||
|                              {localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>} | ||||
|                         </FormControl> | ||||
|                         <FormControl fullWidth margin="dense" error={!!localErrors.idSeccion}> | ||||
|                             <InputLabel id="seccion-estado-select-label" required>Sección</InputLabel> | ||||
|                             <Select labelId="seccion-estado-select-label" label="Sección" value={idSeccion} | ||||
|                                 onChange={(e) => {setIdSeccion(e.target.value as number); handleInputChange('idSeccion');}} | ||||
|                                 disabled={loading || loadingDropdowns || !idPublicacion || seccionesDisponibles.length === 0} | ||||
|                             > | ||||
|                                 <MenuItem value="" disabled><em>{idPublicacion ? 'Seleccione sección' : 'Seleccione publicación primero'}</em></MenuItem> | ||||
|                                 {seccionesDisponibles.map((s) => (<MenuItem key={s.idSeccion} value={s.idSeccion}>{s.nombre}</MenuItem>))} | ||||
|                             </Select> | ||||
|                              {localErrors.idSeccion && <Typography color="error" variant="caption">{localErrors.idSeccion}</Typography>} | ||||
|                              {localErrors.secciones && <Alert severity="warning" sx={{mt:0.5}}>{localErrors.secciones}</Alert>} | ||||
|                         </FormControl> | ||||
|                     </> | ||||
|                 )} | ||||
|  | ||||
|                 <TextField label="Fecha Cambio de Estado" type="date" value={fechaCambioEstado} required | ||||
|                     onChange={(e) => {setFechaCambioEstado(e.target.value); handleInputChange('fechaCambioEstado');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.fechaCambioEstado} helperText={localErrors.fechaCambioEstado || ''} | ||||
|                     disabled={loading} InputLabelProps={{ shrink: true }} | ||||
|                 /> | ||||
|                 <TextField label="Observaciones (Opcional)" value={obs} | ||||
|                     onChange={(e) => setObs(e.target.value)} | ||||
|                     margin="dense" fullWidth multiline rows={3} disabled={loading} | ||||
|                 /> | ||||
|             </Box> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</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 || loadingDropdowns || estadosDisponibles.length === 0}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Guardar Cambio de Estado'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default StockBobinaCambioEstadoModal; | ||||
| @@ -0,0 +1,206 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem | ||||
| } from '@mui/material'; | ||||
| import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto'; | ||||
| import type { UpdateStockBobinaDto } from '../../../models/dtos/Impresion/UpdateStockBobinaDto'; | ||||
| import type { TipoBobinaDto } from '../../../models/dtos/Impresion/TipoBobinaDto'; | ||||
| import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto'; | ||||
| import tipoBobinaService from '../../../services/Impresion/tipoBobinaService'; | ||||
| import plantaService from '../../../services/Impresion/plantaService'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo que StockBobinaIngresoFormModal) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '90%', sm: 550 }, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| interface StockBobinaEditFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (idBobina: number, data: UpdateStockBobinaDto) => Promise<void>; | ||||
|   initialData: StockBobinaDto | null; // Siempre habrá initialData para editar | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const StockBobinaEditFormModal: React.FC<StockBobinaEditFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [idTipoBobina, setIdTipoBobina] = useState<number | string>(''); | ||||
|   const [nroBobina, setNroBobina] = useState(''); | ||||
|   const [peso, setPeso] = useState<string>(''); | ||||
|   const [idPlanta, setIdPlanta] = useState<number | string>(''); | ||||
|   const [remito, setRemito] = useState(''); | ||||
|   const [fechaRemito, setFechaRemito] = useState(''); // yyyy-MM-dd | ||||
|  | ||||
|   const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]); | ||||
|   const [plantas, setPlantas] = useState<PlantaDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchDropdownData = async () => { | ||||
|         setLoadingDropdowns(true); | ||||
|         try { | ||||
|             const [tiposData, plantasData] = await Promise.all([ | ||||
|                 tipoBobinaService.getAllTiposBobina(), | ||||
|                 plantaService.getAllPlantas() | ||||
|             ]); | ||||
|             setTiposBobina(tiposData); | ||||
|             setPlantas(plantasData); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar datos para dropdowns (StockBobina Edit)", error); | ||||
|             setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar tipos/plantas.'})); | ||||
|         } finally { | ||||
|             setLoadingDropdowns(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open && initialData) { | ||||
|         fetchDropdownData(); | ||||
|         setIdTipoBobina(initialData.idTipoBobina || ''); | ||||
|         setNroBobina(initialData.nroBobina || ''); | ||||
|         setPeso(initialData.peso?.toString() || ''); | ||||
|         setIdPlanta(initialData.idPlanta || ''); | ||||
|         setRemito(initialData.remito || ''); | ||||
|         setFechaRemito(initialData.fechaRemito || ''); // Asume yyyy-MM-dd del DTO | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!idTipoBobina) errors.idTipoBobina = 'Seleccione un tipo.'; | ||||
|     if (!nroBobina.trim()) errors.nroBobina = 'Nro. Bobina es obligatorio.'; | ||||
|     if (!peso.trim() || isNaN(parseInt(peso)) || parseInt(peso) <= 0) errors.peso = 'Peso debe ser un número positivo.'; | ||||
|     if (!idPlanta) errors.idPlanta = 'Seleccione una planta.'; | ||||
|     if (!remito.trim()) errors.remito = 'Remito es obligatorio.'; | ||||
|     if (!fechaRemito.trim()) errors.fechaRemito = 'Fecha de Remito es obligatoria.'; | ||||
|     else if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaRemito)) errors.fechaRemito = 'Formato de fecha inválido.'; | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = (fieldName: string) => { | ||||
|     if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); | ||||
|     if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     clearErrorMessage(); | ||||
|     if (!validate() || !initialData) return; // initialData siempre debería existir aquí | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit: UpdateStockBobinaDto = { | ||||
|           idTipoBobina: Number(idTipoBobina), | ||||
|           nroBobina, | ||||
|           peso: parseInt(peso, 10), | ||||
|           idPlanta: Number(idPlanta), | ||||
|           remito, | ||||
|           fechaRemito, | ||||
|       }; | ||||
|       await onSubmit(initialData.idBobina, dataToSubmit); | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de StockBobinaEditFormModal:", error); | ||||
|       // El error de API lo maneja la página que llama a este modal | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!initialData) return null; // No renderizar si no hay datos iniciales (aunque open lo controla) | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|             Editar Datos de Bobina (ID: {initialData.idBobina}) | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|              <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.idTipoBobina} sx={{flex:1, minWidth: '200px'}}> | ||||
|                         <InputLabel id="edit-tipo-bobina-select-label" required>Tipo Bobina</InputLabel> | ||||
|                         <Select labelId="edit-tipo-bobina-select-label" label="Tipo Bobina" value={idTipoBobina} | ||||
|                             onChange={(e) => {setIdTipoBobina(e.target.value as number); handleInputChange('idTipoBobina');}} | ||||
|                             disabled={loading || loadingDropdowns} | ||||
|                         > | ||||
|                             <MenuItem value="" disabled><em>Seleccione un tipo</em></MenuItem> | ||||
|                             {tiposBobina.map((t) => (<MenuItem key={t.idTipoBobina} value={t.idTipoBobina}>{t.denominacion}</MenuItem>))} | ||||
|                         </Select> | ||||
|                         {localErrors.idTipoBobina && <Typography color="error" variant="caption">{localErrors.idTipoBobina}</Typography>} | ||||
|                     </FormControl> | ||||
|                     <TextField label="Nro. Bobina" value={nroBobina} required | ||||
|                         onChange={(e) => {setNroBobina(e.target.value); handleInputChange('nroBobina');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.nroBobina} helperText={localErrors.nroBobina || ''} | ||||
|                         disabled={loading} sx={{flex:1, minWidth: '200px'}} autoFocus | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                     <TextField label="Peso (Kg)" type="number" value={peso} required | ||||
|                         onChange={(e) => {setPeso(e.target.value); handleInputChange('peso');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.peso} helperText={localErrors.peso || ''} | ||||
|                         disabled={loading} sx={{flex:1, minWidth: '150px'}} | ||||
|                     /> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.idPlanta} sx={{flex:1, minWidth: '200px'}}> | ||||
|                         <InputLabel id="edit-planta-select-label" required>Planta Destino</InputLabel> | ||||
|                         <Select labelId="edit-planta-select-label" label="Planta Destino" value={idPlanta} | ||||
|                             onChange={(e) => {setIdPlanta(e.target.value as number); handleInputChange('idPlanta');}} | ||||
|                             disabled={loading || loadingDropdowns} | ||||
|                         > | ||||
|                             <MenuItem value="" disabled><em>Seleccione una planta</em></MenuItem> | ||||
|                             {plantas.map((p) => (<MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>))} | ||||
|                         </Select> | ||||
|                         {localErrors.idPlanta && <Typography color="error" variant="caption">{localErrors.idPlanta}</Typography>} | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                     <TextField label="Nro. Remito" value={remito} required | ||||
|                         onChange={(e) => {setRemito(e.target.value); handleInputChange('remito');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.remito} helperText={localErrors.remito || ''} | ||||
|                         disabled={loading} sx={{flex:1, minWidth: '200px'}} | ||||
|                     /> | ||||
|                     <TextField label="Fecha Remito" type="date" value={fechaRemito} required | ||||
|                         onChange={(e) => {setFechaRemito(e.target.value); handleInputChange('fechaRemito');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.fechaRemito} helperText={localErrors.fechaRemito || ''} | ||||
|                         disabled={loading} InputLabelProps={{ shrink: true }} sx={{flex:1, minWidth: '200px'}} | ||||
|                     /> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</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 || loadingDropdowns}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Guardar Cambios'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default StockBobinaEditFormModal; | ||||
| @@ -0,0 +1,192 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; | ||||
| import type { CreateStockBobinaDto } from '../../../models/dtos/Impresion/CreateStockBobinaDto'; | ||||
| import type { TipoBobinaDto } from '../../../models/dtos/Impresion/TipoBobinaDto'; | ||||
| import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto'; | ||||
| import tipoBobinaService from '../../../services/Impresion/tipoBobinaService'; | ||||
| import plantaService from '../../../services/Impresion/plantaService'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '90%', sm: 550 }, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| interface StockBobinaIngresoFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateStockBobinaDto) => Promise<void>; // Solo para crear | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
|   // initialData no es necesario para un modal de solo creación | ||||
| } | ||||
|  | ||||
| const StockBobinaIngresoFormModal: React.FC<StockBobinaIngresoFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [idTipoBobina, setIdTipoBobina] = useState<number | string>(''); | ||||
|   const [nroBobina, setNroBobina] = useState(''); | ||||
|   const [peso, setPeso] = useState<string>(''); | ||||
|   const [idPlanta, setIdPlanta] = useState<number | string>(''); | ||||
|   const [remito, setRemito] = useState(''); | ||||
|   const [fechaRemito, setFechaRemito] = useState(''); // yyyy-MM-dd | ||||
|  | ||||
|   const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]); | ||||
|   const [plantas, setPlantas] = useState<PlantaDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchDropdownData = async () => { | ||||
|         setLoadingDropdowns(true); | ||||
|         try { | ||||
|             const [tiposData, plantasData] = await Promise.all([ | ||||
|                 tipoBobinaService.getAllTiposBobina(), | ||||
|                 plantaService.getAllPlantas() | ||||
|             ]); | ||||
|             setTiposBobina(tiposData); | ||||
|             setPlantas(plantasData); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar datos para dropdowns (StockBobina)", error); | ||||
|             setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar tipos/plantas.'})); | ||||
|         } finally { | ||||
|             setLoadingDropdowns(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open) { | ||||
|         fetchDropdownData(); | ||||
|         // Resetear campos | ||||
|         setIdTipoBobina(''); setNroBobina(''); setPeso(''); setIdPlanta(''); | ||||
|         setRemito(''); setFechaRemito(new Date().toISOString().split('T')[0]); // Default a hoy | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!idTipoBobina) errors.idTipoBobina = 'Seleccione un tipo.'; | ||||
|     if (!nroBobina.trim()) errors.nroBobina = 'Nro. Bobina es obligatorio.'; | ||||
|     if (!peso.trim() || isNaN(parseInt(peso)) || parseInt(peso) <= 0) errors.peso = 'Peso debe ser un número positivo.'; | ||||
|     if (!idPlanta) errors.idPlanta = 'Seleccione una planta.'; | ||||
|     if (!remito.trim()) errors.remito = 'Remito es obligatorio.'; | ||||
|     if (!fechaRemito.trim()) errors.fechaRemito = 'Fecha de Remito es obligatoria.'; | ||||
|     else if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaRemito)) errors.fechaRemito = 'Formato de fecha inválido.'; | ||||
|  | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = (fieldName: string) => { | ||||
|     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 { | ||||
|       const dataToSubmit: CreateStockBobinaDto = { | ||||
|           idTipoBobina: Number(idTipoBobina), | ||||
|           nroBobina, | ||||
|           peso: parseInt(peso, 10), | ||||
|           idPlanta: Number(idPlanta), | ||||
|           remito, | ||||
|           fechaRemito, | ||||
|       }; | ||||
|       await onSubmit(dataToSubmit); | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de StockBobinaIngresoFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom>Ingresar Nueva Bobina a Stock</Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                 <FormControl fullWidth margin="dense" error={!!localErrors.idTipoBobina} sx={{flex:1, minWidth: '200px'}}> | ||||
|                     <InputLabel id="tipo-bobina-select-label" required>Tipo Bobina</InputLabel> | ||||
|                     <Select labelId="tipo-bobina-select-label" label="Tipo Bobina" value={idTipoBobina} | ||||
|                         onChange={(e) => {setIdTipoBobina(e.target.value as number); handleInputChange('idTipoBobina');}} | ||||
|                         disabled={loading || loadingDropdowns} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione un tipo</em></MenuItem> | ||||
|                         {tiposBobina.map((t) => (<MenuItem key={t.idTipoBobina} value={t.idTipoBobina}>{t.denominacion}</MenuItem>))} | ||||
|                     </Select> | ||||
|                     {localErrors.idTipoBobina && <Typography color="error" variant="caption">{localErrors.idTipoBobina}</Typography>} | ||||
|                 </FormControl> | ||||
|                 <TextField label="Nro. Bobina" value={nroBobina} required | ||||
|                     onChange={(e) => {setNroBobina(e.target.value); handleInputChange('nroBobina');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.nroBobina} helperText={localErrors.nroBobina || ''} | ||||
|                     disabled={loading} sx={{flex:1, minWidth: '200px'}} | ||||
|                 /> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                 <TextField label="Peso (Kg)" type="number" value={peso} required | ||||
|                     onChange={(e) => {setPeso(e.target.value); handleInputChange('peso');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.peso} helperText={localErrors.peso || ''} | ||||
|                     disabled={loading} sx={{flex:1, minWidth: '150px'}} | ||||
|                 /> | ||||
|                 <FormControl fullWidth margin="dense" error={!!localErrors.idPlanta} sx={{flex:1, minWidth: '200px'}}> | ||||
|                     <InputLabel id="planta-select-label" required>Planta Destino</InputLabel> | ||||
|                     <Select labelId="planta-select-label" label="Planta Destino" value={idPlanta} | ||||
|                         onChange={(e) => {setIdPlanta(e.target.value as number); handleInputChange('idPlanta');}} | ||||
|                         disabled={loading || loadingDropdowns} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione una planta</em></MenuItem> | ||||
|                         {plantas.map((p) => (<MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>))} | ||||
|                     </Select> | ||||
|                     {localErrors.idPlanta && <Typography color="error" variant="caption">{localErrors.idPlanta}</Typography>} | ||||
|                 </FormControl> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', gap: 2, mb: 1, flexWrap: 'wrap' }}> | ||||
|                  <TextField label="Nro. Remito" value={remito} required | ||||
|                     onChange={(e) => {setRemito(e.target.value); handleInputChange('remito');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.remito} helperText={localErrors.remito || ''} | ||||
|                     disabled={loading} sx={{flex:1, minWidth: '200px'}} | ||||
|                 /> | ||||
|                 <TextField label="Fecha Remito" type="date" value={fechaRemito} required | ||||
|                     onChange={(e) => {setFechaRemito(e.target.value); handleInputChange('fechaRemito');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.fechaRemito} helperText={localErrors.fechaRemito || ''} | ||||
|                     disabled={loading} InputLabelProps={{ shrink: true }} sx={{flex:1, minWidth: '200px'}} | ||||
|                 /> | ||||
|             </Box> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</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 || loadingDropdowns}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Ingresar Bobina'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default StockBobinaIngresoFormModal; | ||||
							
								
								
									
										334
									
								
								Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										334
									
								
								Frontend/src/components/Modals/Impresion/TiradaFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,334 @@ | ||||
| // src/components/Modals/TiradaFormModal.tsx | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, IconButton, Paper, | ||||
|     Table, TableHead, TableRow, TableCell, TableBody, | ||||
|     TableContainer | ||||
| } from '@mui/material'; | ||||
| import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; | ||||
| import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; | ||||
| import type { CreateTiradaRequestDto } from '../../../models/dtos/Impresion/CreateTiradaRequestDto'; | ||||
| import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto'; | ||||
| import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto'; | ||||
| import type { PubliSeccionDto } from '../../../models/dtos/Distribucion/PubliSeccionDto'; | ||||
| import publicacionService from '../../../services/Distribucion/publicacionService'; | ||||
| import plantaService from '../../../services/Impresion/plantaService'; | ||||
| import publiSeccionService from '../../../services/Distribucion/publiSeccionService'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '95%', sm: '80%', md: '750px' }, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 3, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| // CORREGIDO: Ajustar el tipo para los inputs. Usaremos string para los inputs, | ||||
| // y convertiremos a number al hacer submit o al validar donde sea necesario. | ||||
| interface DetalleSeccionFormState { | ||||
|     idSeccion: number | ''; // Permitir string vacío para el Select no seleccionado | ||||
|     nombreSeccion?: string; | ||||
|     cantPag: string; // TextField de cantPag siempre es string | ||||
|     idTemporal: string; // Para la key de React | ||||
| } | ||||
|  | ||||
|  | ||||
| interface TiradaFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateTiradaRequestDto) => Promise<void>; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const TiradaFormModal: React.FC<TiradaFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [idPublicacion, setIdPublicacion] = useState<number | string>(''); | ||||
|   const [fecha, setFecha] = useState<string>(new Date().toISOString().split('T')[0]); | ||||
|   const [idPlanta, setIdPlanta] = useState<number | string>(''); | ||||
|   const [ejemplares, setEjemplares] = useState<string>(''); | ||||
|   // CORREGIDO: Usar el nuevo tipo para el estado del formulario de secciones | ||||
|   const [seccionesDeTirada, setSeccionesDeTirada] = useState<DetalleSeccionFormState[]>([]); | ||||
|  | ||||
|   const [publicaciones, setPublicaciones] = useState<PublicacionDto[]>([]); | ||||
|   const [plantas, setPlantas] = useState<PlantaDto[]>([]); | ||||
|   const [seccionesPublicacion, setSeccionesPublicacion] = useState<PubliSeccionDto[]>([]); | ||||
|  | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const resetForm = () => { | ||||
|     setIdPublicacion(''); | ||||
|     setFecha(new Date().toISOString().split('T')[0]); | ||||
|     setIdPlanta(''); | ||||
|     setEjemplares(''); | ||||
|     setSeccionesDeTirada([]); | ||||
|     setSeccionesPublicacion([]); | ||||
|     setLocalErrors({}); | ||||
|     clearErrorMessage(); | ||||
|   }; | ||||
|  | ||||
|   const fetchInitialDropdowns = useCallback(async () => { | ||||
|     setLoadingDropdowns(true); | ||||
|     try { | ||||
|         const [pubsData, plantasData] = await Promise.all([ | ||||
|             publicacionService.getAllPublicaciones(undefined, undefined, true), | ||||
|             plantaService.getAllPlantas() | ||||
|         ]); | ||||
|         setPublicaciones(pubsData); | ||||
|         setPlantas(plantasData); | ||||
|     } catch (error) { | ||||
|         console.error("Error al cargar publicaciones/plantas", error); | ||||
|         setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar datos iniciales.'})); | ||||
|     } finally { | ||||
|         setLoadingDropdowns(false); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         resetForm(); // Llama a resetForm aquí | ||||
|         fetchInitialDropdowns(); | ||||
|     } | ||||
|   }, [open, fetchInitialDropdowns]); // resetForm no necesita estar en las dependencias si su contenido no cambia basado en props/estado que también estén en las dependencias. | ||||
|  | ||||
|   const fetchSeccionesDePublicacion = useCallback(async (pubId: number) => { | ||||
|     if (!pubId) { | ||||
|         setSeccionesPublicacion([]); | ||||
|         setSeccionesDeTirada([]); | ||||
|         return; | ||||
|     } | ||||
|     setLoadingDropdowns(true); | ||||
|     try { | ||||
|         const data = await publiSeccionService.getSeccionesPorPublicacion(pubId, true); | ||||
|         setSeccionesPublicacion(data); | ||||
|         setSeccionesDeTirada([]); | ||||
|     } catch (error) { | ||||
|         console.error("Error al cargar secciones de la publicación", error); | ||||
|         setLocalErrors(prev => ({...prev, secciones: 'Error al cargar secciones.'})); | ||||
|     } finally { | ||||
|         setLoadingDropdowns(false); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (idPublicacion) { | ||||
|         fetchSeccionesDePublicacion(Number(idPublicacion)); | ||||
|     } else { | ||||
|         setSeccionesPublicacion([]); | ||||
|         setSeccionesDeTirada([]); | ||||
|     } | ||||
|   }, [idPublicacion, fetchSeccionesDePublicacion]); | ||||
|  | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!idPublicacion) errors.idPublicacion = 'Seleccione una publicación.'; | ||||
|     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.'; | ||||
|     if (!idPlanta) errors.idPlanta = 'Seleccione una planta.'; | ||||
|     if (!ejemplares.trim() || isNaN(parseInt(ejemplares)) || parseInt(ejemplares) <= 0) errors.ejemplares = 'Ejemplares debe ser un número positivo.'; | ||||
|      | ||||
|     if (seccionesDeTirada.length === 0) { | ||||
|         errors.seccionesArray = 'Debe agregar al menos una sección a la tirada.'; | ||||
|     } else { | ||||
|         seccionesDeTirada.forEach((sec, index) => { | ||||
|             if (sec.idSeccion === '') errors[`seccion_${index}_id`] = `Fila ${index + 1}: Debe seleccionar una sección.`; | ||||
|             if (!sec.cantPag.trim() || isNaN(Number(sec.cantPag)) || Number(sec.cantPag) <= 0) { | ||||
|                  errors[`seccion_${index}_pag`] = `Fila ${index + 1}: Cant. Páginas debe ser un número positivo.`; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleAddSeccion = () => { | ||||
|     setSeccionesDeTirada([...seccionesDeTirada, { idSeccion: '', cantPag: '', nombreSeccion: '', idTemporal: crypto.randomUUID() }]); | ||||
|      if (localErrors.seccionesArray) setLocalErrors(prev => ({ ...prev, seccionesArray: null })); | ||||
|   }; | ||||
|   const handleRemoveSeccion = (index: number) => { | ||||
|     setSeccionesDeTirada(seccionesDeTirada.filter((_, i) => i !== index)); | ||||
|   }; | ||||
|  | ||||
|   const handleSeccionChange = (index: number, field: 'idSeccion' | 'cantPag', value: string | number) => { | ||||
|     const nuevasSecciones = [...seccionesDeTirada]; | ||||
|     const targetSeccion = nuevasSecciones[index]; | ||||
|  | ||||
|     if (field === 'idSeccion') { | ||||
|         const numValue = Number(value); // El valor del Select es string, pero lo guardamos como number | '' | ||||
|         targetSeccion.idSeccion = numValue === 0 ? '' : numValue; // Si es 0 (placeholder), guardar '' | ||||
|         const seccionSeleccionada = seccionesPublicacion.find(s => s.idSeccion === numValue); | ||||
|         targetSeccion.nombreSeccion = seccionSeleccionada?.nombre || ''; | ||||
|     } else { // cantPag | ||||
|         targetSeccion.cantPag = value as string; // Guardar como string, validar como número después | ||||
|     } | ||||
|     setSeccionesDeTirada(nuevasSecciones); | ||||
|     if (localErrors[`seccion_${index}_id`]) setLocalErrors(prev => ({ ...prev, [`seccion_${index}_id`]: null })); | ||||
|     if (localErrors[`seccion_${index}_pag`]) setLocalErrors(prev => ({ ...prev, [`seccion_${index}_pag`]: null })); | ||||
|   }; | ||||
|  | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     clearErrorMessage(); | ||||
|     if (!validate()) return; | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit: CreateTiradaRequestDto = { | ||||
|           idPublicacion: Number(idPublicacion), | ||||
|           fecha, | ||||
|           idPlanta: Number(idPlanta), | ||||
|           ejemplares: parseInt(ejemplares, 10), | ||||
|           // CORREGIDO: Asegurar que los datos de secciones sean números | ||||
|           secciones: seccionesDeTirada.map(s => ({ | ||||
|              idSeccion: Number(s.idSeccion), // Convertir a número aquí | ||||
|              cantPag: Number(s.cantPag)     // Convertir a número aquí | ||||
|             })) | ||||
|       }; | ||||
|       await onSubmit(dataToSubmit); | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de TiradaFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom>Registrar Nueva Tirada</Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             {/* ... (campos de Publicacion, Fecha, Planta, Ejemplares sin cambios) ... */} | ||||
|              <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}> | ||||
|                 <FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion} sx={{flex:1, minWidth: 200}}> | ||||
|                     <InputLabel id="publicacion-tirada-select-label" required>Publicación</InputLabel> | ||||
|                     <Select labelId="publicacion-tirada-select-label" label="Publicación" value={idPublicacion} | ||||
|                         onChange={(e) => { setIdPublicacion(e.target.value as number); setLocalErrors(p => ({...p, idPublicacion: null})); }} | ||||
|                         disabled={loading || loadingDropdowns} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione</em></MenuItem> | ||||
|                         {publicaciones.map((p) => (<MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>))} | ||||
|                     </Select> | ||||
|                     {localErrors.idPublicacion && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idPublicacion}</Typography>} | ||||
|                 </FormControl> | ||||
|                  <TextField label="Fecha Tirada" type="date" value={fecha} required | ||||
|                     onChange={(e) => {setFecha(e.target.value); setLocalErrors(p => ({...p, fecha: null}));}} | ||||
|                     margin="dense" error={!!localErrors.fecha} helperText={localErrors.fecha || ''} | ||||
|                     disabled={loading} InputLabelProps={{ shrink: true }} sx={{flex:1, minWidth: 160}} | ||||
|                 /> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}> | ||||
|                  <FormControl fullWidth margin="dense" error={!!localErrors.idPlanta} sx={{flex:1, minWidth: 200}}> | ||||
|                     <InputLabel id="planta-tirada-select-label" required>Planta</InputLabel> | ||||
|                     <Select labelId="planta-tirada-select-label" label="Planta" value={idPlanta} | ||||
|                         onChange={(e) => {setIdPlanta(e.target.value as number); setLocalErrors(p => ({...p, idPlanta: null}));}} | ||||
|                         disabled={loading || loadingDropdowns} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione</em></MenuItem> | ||||
|                         {plantas.map((p) => (<MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>))} | ||||
|                     </Select> | ||||
|                      {localErrors.idPlanta && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idPlanta}</Typography>} | ||||
|                 </FormControl> | ||||
|                 <TextField label="Total Ejemplares" type="number" value={ejemplares} required | ||||
|                     onChange={(e) => {setEjemplares(e.target.value); setLocalErrors(p => ({...p, ejemplares: null}));}} | ||||
|                     margin="dense" error={!!localErrors.ejemplares} helperText={localErrors.ejemplares || ''} | ||||
|                     disabled={loading} sx={{flex:1, minWidth: 150}} | ||||
|                     inputProps={{min:1}} | ||||
|                 /> | ||||
|             </Box> | ||||
|  | ||||
|  | ||||
|             <Typography variant="subtitle1" sx={{mt: 2, mb:1}}>Detalle de Secciones Impresas:</Typography> | ||||
|             {localErrors.seccionesArray && <Alert severity="error" sx={{mb:1}}>{localErrors.seccionesArray}</Alert>} | ||||
|             <Paper variant="outlined" sx={{p:1, mb:2, maxHeight: '250px', overflowY: 'auto'}}> {/* Permitir scroll en tabla de secciones */} | ||||
|                 <TableContainer> | ||||
|                     <Table size="small" stickyHeader> {/* stickyHeader para que cabecera quede fija */} | ||||
|                         <TableHead> | ||||
|                             <TableRow> | ||||
|                                 <TableCell sx={{fontWeight:'bold', minWidth: 200}}>Sección</TableCell> | ||||
|                                 <TableCell sx={{fontWeight:'bold', width: '150px'}}>Cant. Páginas</TableCell> | ||||
|                                 <TableCell align="right" sx={{width: '50px'}}></TableCell> | ||||
|                             </TableRow> | ||||
|                         </TableHead> | ||||
|                         <TableBody> | ||||
|                             {seccionesDeTirada.map((sec, index) => ( | ||||
|                                 <TableRow key={sec.idTemporal || index}> {/* Usar idTemporal para key */} | ||||
|                                     <TableCell sx={{py:0.5}}> | ||||
|                                         <FormControl fullWidth size="small" error={!!localErrors[`seccion_${index}_id`]}> | ||||
|                                             <Select value={sec.idSeccion} // Ahora idSeccion es number | '' | ||||
|                                                 onChange={(e) => handleSeccionChange(index, 'idSeccion', e.target.value as number | '')} | ||||
|                                                 disabled={loading || loadingDropdowns || seccionesPublicacion.length === 0} | ||||
|                                                 displayEmpty | ||||
|                                             > | ||||
|                                                 <MenuItem value="" disabled><em>Seleccionar</em></MenuItem> | ||||
|                                                 {seccionesPublicacion.map(s => <MenuItem key={s.idSeccion} value={s.idSeccion}>{s.nombre}</MenuItem>)} | ||||
|                                             </Select> | ||||
|                                             {localErrors[`seccion_${index}_id`] && <Typography color="error" variant="caption">{localErrors[`seccion_${index}_id`]}</Typography>} | ||||
|                                         </FormControl> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell sx={{py:0.5}}> | ||||
|                                          <TextField type="number" size="small" fullWidth value={sec.cantPag} | ||||
|                                             onChange={(e) => handleSeccionChange(index, 'cantPag', e.target.value)} | ||||
|                                             error={!!localErrors[`seccion_${index}_pag`]} | ||||
|                                             helperText={localErrors[`seccion_${index}_pag`] || ''} | ||||
|                                             disabled={loading} | ||||
|                                             inputProps={{min:1}} | ||||
|                                         /> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right" sx={{py:0.5}}> | ||||
|                                         <IconButton onClick={() => handleRemoveSeccion(index)} size="small" color="error" disabled={loading}> | ||||
|                                             <DeleteOutlineIcon /> | ||||
|                                         </IconButton> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|                             ))} | ||||
|                              {seccionesDeTirada.length === 0 && ( | ||||
|                                 <TableRow> | ||||
|                                     <TableCell colSpan={3} align="center"> | ||||
|                                         <Typography variant="caption" color="textSecondary"> | ||||
|                                             Agregue secciones a la tirada. | ||||
|                                         </Typography> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|                             )} | ||||
|                         </TableBody> | ||||
|                     </Table> | ||||
|                 </TableContainer> | ||||
|                  <Button startIcon={<AddCircleOutlineIcon />} onClick={handleAddSeccion} sx={{mt:1}} size="small" disabled={loading || !idPublicacion}> | ||||
|                     Agregar Sección | ||||
|                 </Button> | ||||
|             </Paper> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</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 || loadingDropdowns}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Registrar Tirada'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default TiradaFormModal; | ||||
		Reference in New Issue
	
	Block a user