feat: Implementación CRUD Canillitas, Distribuidores y Precios de Publicación
Backend API:
- Canillitas (`dist_dtCanillas`):
  - Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Lógica para manejo de `Accionista`, `Baja`, `FechaBaja`.
  - Auditoría en `dist_dtCanillas_H`.
  - Validación de legajo único y lógica de empresa vs accionista.
- Distribuidores (`dist_dtDistribuidores`):
  - Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Auditoría en `dist_dtDistribuidores_H`.
  - Creación de saldos iniciales para el nuevo distribuidor en todas las empresas.
  - Verificación de NroDoc único y Nombre opcionalmente único.
- Precios de Publicación (`dist_Precios`):
  - Implementado CRUD básico (Modelos, DTOs, Repositorio, Servicio, Controlador).
  - Endpoints anidados bajo `/publicaciones/{idPublicacion}/precios`.
  - Lógica de negocio para cerrar período de precio anterior al crear uno nuevo.
  - Lógica de negocio para reabrir período de precio anterior al eliminar el último.
  - Auditoría en `dist_Precios_H`.
- Auditoría en Eliminación de Publicaciones:
  - Extendido `PublicacionService.EliminarAsync` para eliminar en cascada registros de precios, recargos, porcentajes de pago (distribuidores y canillitas) y secciones de publicación.
  - Repositorios correspondientes (`PrecioRepository`, `RecargoZonaRepository`, `PorcPagoRepository`, `PorcMonCanillaRepository`, `PubliSeccionRepository`) actualizados con métodos `DeleteByPublicacionIdAsync` que registran en sus respectivas tablas `_H` (si existen y se implementó la lógica).
  - Asegurada la correcta propagación del `idUsuario` para la auditoría en cascada.
- Correcciones de Nulabilidad:
  - Ajustados los métodos `MapToDto` y su uso en `CanillaService` y `PublicacionService` para manejar correctamente tipos anulables.
Frontend React:
- Canillitas:
  - `canillaService.ts`.
  - `CanillaFormModal.tsx` con selectores para Zona y Empresa, y lógica de Accionista.
  - `GestionarCanillitasPage.tsx` con filtros, paginación, y acciones (editar, toggle baja).
- Distribuidores:
  - `distribuidorService.ts`.
  - `DistribuidorFormModal.tsx` con múltiples campos y selector de Zona.
  - `GestionarDistribuidoresPage.tsx` con filtros, paginación, y acciones (editar, eliminar).
- Precios de Publicación:
  - `precioService.ts`.
  - `PrecioFormModal.tsx` para crear/editar períodos de precios (VigenciaD, VigenciaH opcional, precios por día).
  - `GestionarPreciosPublicacionPage.tsx` accesible desde la gestión de publicaciones, para listar y gestionar los períodos de precios de una publicación específica.
- Layout:
  - Reemplazado el uso de `Grid` por `Box` con Flexbox en `CanillaFormModal`, `GestionarCanillitasPage` (filtros), `DistribuidorFormModal` y `PrecioFormModal` para resolver problemas de tipos y mejorar la consistencia del layout de formularios.
- Navegación:
  - Actualizadas las rutas y pestañas para los nuevos módulos y sub-módulos.
			
			
This commit is contained in:
		| @@ -1,7 +1,7 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { TipoPago } from '../../models/Entities/TipoPago'; | ||||
| import type { CreateTipoPagoDto } from '../../models/dtos/tiposPago/CreateTipoPagoDto'; | ||||
| import type { TipoPago } from '../../../models/Entities/TipoPago'; | ||||
| import type { CreateTipoPagoDto } from '../../../models/dtos/tiposPago/CreateTipoPagoDto'; | ||||
| 
 | ||||
| const modalStyle = { | ||||
|   position: 'absolute' as 'absolute', | ||||
							
								
								
									
										239
									
								
								Frontend/src/components/Modals/Distribucion/CanillaFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								Frontend/src/components/Modals/Distribucion/CanillaFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,239 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||
| } from '@mui/material'; | ||||
| import type { CanillaDto } from '../../../models/dtos/Distribucion/CanillaDto'; | ||||
| import type { CreateCanillaDto } from '../../../models/dtos/Distribucion/CreateCanillaDto'; | ||||
| import type { UpdateCanillaDto } from '../../../models/dtos/Distribucion/UpdateCanillaDto'; | ||||
| import type { ZonaDto } from '../../../models/dtos/Zonas/ZonaDto'; // Para el dropdown de zonas | ||||
| import type { EmpresaDto } from '../../../models/dtos/Distribucion/EmpresaDto'; // Para el dropdown de empresas | ||||
| import zonaService from '../../../services/Distribucion/zonaService'; | ||||
| import empresaService from '../../../services/Distribucion/empresaService'; | ||||
|  | ||||
| const modalStyle = { | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '90%', sm: 600 }, // Un poco más ancho | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| interface CanillaFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateCanillaDto | UpdateCanillaDto, id?: number) => Promise<void>; | ||||
|   initialData?: CanillaDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const CanillaFormModal: React.FC<CanillaFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [legajo, setLegajo] = useState<string>(''); // Manejar como string para el TextField | ||||
|   const [nomApe, setNomApe] = useState(''); | ||||
|   const [parada, setParada] = useState(''); | ||||
|   const [idZona, setIdZona] = useState<number | string>(''); | ||||
|   const [accionista, setAccionista] = useState(false); | ||||
|   const [obs, setObs] = useState(''); | ||||
|   const [empresa, setEmpresa] = useState<number | string>(0); // 0 para N/A (accionista) | ||||
|  | ||||
|   const [zonas, setZonas] = useState<ZonaDto[]>([]); | ||||
|   const [empresas, setEmpresas] = useState<EmpresaDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingDropdowns, setLoadingDropdowns] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchDropdownData = async () => { | ||||
|         setLoadingDropdowns(true); | ||||
|         try { | ||||
|             const [zonasData, empresasData] = await Promise.all([ | ||||
|                 zonaService.getAllZonas(), // Asume que este servicio devuelve zonas activas | ||||
|                 empresaService.getAllEmpresas() | ||||
|             ]); | ||||
|             setZonas(zonasData); | ||||
|             setEmpresas(empresasData); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar datos para dropdowns", error); | ||||
|             setLocalErrors(prev => ({...prev, dropdowns: 'Error al cargar zonas/empresas.'})); | ||||
|         } finally { | ||||
|             setLoadingDropdowns(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open) { | ||||
|         fetchDropdownData(); | ||||
|         setLegajo(initialData?.legajo?.toString() || ''); | ||||
|         setNomApe(initialData?.nomApe || ''); | ||||
|         setParada(initialData?.parada || ''); | ||||
|         setIdZona(initialData?.idZona || ''); | ||||
|         setAccionista(initialData ? initialData.accionista : false); | ||||
|         setObs(initialData?.obs || ''); | ||||
|         setEmpresa(initialData ? initialData.empresa : 0); // Si es accionista, empresa es 0 | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!nomApe.trim()) errors.nomApe = 'Nombre y Apellido son obligatorios.'; | ||||
|     if (!idZona) errors.idZona = 'Debe seleccionar una zona.'; | ||||
|  | ||||
|     const legajoNum = legajo ? parseInt(legajo, 10) : null; | ||||
|     if (legajo.trim() && (isNaN(legajoNum!) || legajoNum! < 0)) { | ||||
|         errors.legajo = 'Legajo debe ser un número positivo o vacío.'; | ||||
|     } | ||||
|  | ||||
|     // Lógica de empresa y accionista | ||||
|     if (accionista && empresa !== 0 && empresa !== '') { | ||||
|         errors.empresa = 'Si es Accionista, la Empresa debe ser N/A (0).'; | ||||
|     } | ||||
|     if (!accionista && (empresa === 0 || empresa === '')) { | ||||
|         errors.empresa = 'Si no es Accionista, debe seleccionar una Empresa.'; | ||||
|     } | ||||
|  | ||||
|  | ||||
|     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 legajoParsed = legajo.trim() ? parseInt(legajo, 10) : null; | ||||
|  | ||||
|       const dataToSubmit = { | ||||
|           legajo: legajoParsed, | ||||
|           nomApe, | ||||
|           parada: parada || undefined, | ||||
|           idZona: Number(idZona), | ||||
|           accionista, | ||||
|           obs: obs || undefined, | ||||
|           empresa: Number(empresa) | ||||
|       }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit(dataToSubmit as UpdateCanillaDto, initialData.idCanilla); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreateCanillaDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de CanillaFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {isEditing ? 'Editar Canillita' : 'Agregar Nuevo Canillita'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             {/* SECCIÓN DE CAMPOS CON BOX Y FLEXBOX */} | ||||
|             <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start' }}> | ||||
|                     <TextField label="Legajo" value={legajo} type="number" | ||||
|                         onChange={(e) => {setLegajo(e.target.value); handleInputChange('legajo');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.legajo} helperText={localErrors.legajo || ''} | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                     <TextField label="Nombre y Apellido" value={nomApe} required | ||||
|                         onChange={(e) => {setNomApe(e.target.value); handleInputChange('nomApe');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.nomApe} helperText={localErrors.nomApe || ''} | ||||
|                         disabled={loading} autoFocus={!isEditing} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <TextField label="Parada (Dirección)" value={parada} | ||||
|                     onChange={(e) => setParada(e.target.value)} | ||||
|                     margin="dense" fullWidth multiline rows={2} disabled={loading} | ||||
|                 /> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'flex-start', mt: 1 }}> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.idZona} sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }}> | ||||
|                         <InputLabel id="zona-select-label" required>Zona</InputLabel> | ||||
|                         <Select labelId="zona-select-label" label="Zona" value={idZona} | ||||
|                             onChange={(e) => {setIdZona(e.target.value as number); handleInputChange('idZona');}} | ||||
|                             disabled={loading || loadingDropdowns} | ||||
|                         > | ||||
|                             <MenuItem value="" disabled><em>Seleccione una zona</em></MenuItem> | ||||
|                             {zonas.map((z) => (<MenuItem key={z.idZona} value={z.idZona}>{z.nombre}</MenuItem>))} | ||||
|                         </Select> | ||||
|                         {localErrors.idZona && <Typography color="error" variant="caption">{localErrors.idZona}</Typography>} | ||||
|                     </FormControl> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.empresa} sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }}> | ||||
|                         <InputLabel id="empresa-select-label">Empresa</InputLabel> | ||||
|                         <Select labelId="empresa-select-label" label="Empresa" value={empresa} | ||||
|                             onChange={(e) => {setEmpresa(e.target.value as number); handleInputChange('empresa');}} | ||||
|                             disabled={loading || loadingDropdowns || accionista} | ||||
|                         > | ||||
|                             <MenuItem value={0}><em>N/A (Accionista)</em></MenuItem> | ||||
|                             {empresas.map((e) => (<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>))} | ||||
|                         </Select> | ||||
|                         {localErrors.empresa && <Typography color="error" variant="caption">{localErrors.empresa}</Typography>} | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|                 <TextField label="Observaciones" value={obs} | ||||
|                     onChange={(e) => setObs(e.target.value)} | ||||
|                     margin="dense" fullWidth multiline rows={2} disabled={loading} sx={{mt:1}} | ||||
|                 /> | ||||
|                 <Box sx={{mt:1}}> | ||||
|                     <FormControlLabel | ||||
|                         control={<Checkbox checked={accionista} onChange={(e) => { | ||||
|                             setAccionista(e.target.checked); | ||||
|                             if (e.target.checked) setEmpresa(0); | ||||
|                             handleInputChange('accionista'); | ||||
|                             handleInputChange('empresa'); | ||||
|                         }} disabled={loading}/>} | ||||
|                         label="Es Accionista" | ||||
|                     /> | ||||
|                 </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} /> : (isEditing ? 'Guardar Cambios' : 'Crear Canillita')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default CanillaFormModal; | ||||
| @@ -0,0 +1,239 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem | ||||
| } from '@mui/material'; | ||||
| import type { DistribuidorDto } from '../../../models/dtos/Distribucion/DistribuidorDto'; | ||||
| import type { CreateDistribuidorDto } from '../../../models/dtos/Distribucion/CreateDistribuidorDto'; | ||||
| import type { UpdateDistribuidorDto } from '../../../models/dtos/Distribucion/UpdateDistribuidorDto'; | ||||
| import type { ZonaDto } from '../../../models/dtos/Zonas/ZonaDto'; | ||||
| import zonaService from '../../../services/Distribucion/zonaService'; | ||||
|  | ||||
| const modalStyle = { | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '95%', sm: '80%', md: '600px' }, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| interface DistribuidorFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateDistribuidorDto | UpdateDistribuidorDto, id?: number) => Promise<void>; | ||||
|   initialData?: DistribuidorDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const DistribuidorFormModal: React.FC<DistribuidorFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [nombre, setNombre] = useState(''); | ||||
|   const [contacto, setContacto] = useState(''); | ||||
|   const [nroDoc, setNroDoc] = useState(''); | ||||
|   const [idZona, setIdZona] = useState<number | string>(''); // Puede ser string vacío | ||||
|   const [calle, setCalle] = useState(''); | ||||
|   const [numero, setNumero] = useState(''); | ||||
|   const [piso, setPiso] = useState(''); | ||||
|   const [depto, setDepto] = useState(''); | ||||
|   const [telefono, setTelefono] = useState(''); | ||||
|   const [email, setEmail] = useState(''); | ||||
|   const [localidad, setLocalidad] = useState(''); | ||||
|  | ||||
|   const [zonas, setZonas] = useState<ZonaDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingZonas, setLoadingZonas] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchZonas = async () => { | ||||
|         setLoadingZonas(true); | ||||
|         try { | ||||
|             const data = await zonaService.getAllZonas(); // Solo activas por defecto | ||||
|             setZonas(data); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar zonas", error); | ||||
|             setLocalErrors(prev => ({...prev, zonas: 'Error al cargar zonas.'})); | ||||
|         } finally { | ||||
|             setLoadingZonas(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open) { | ||||
|         fetchZonas(); | ||||
|         setNombre(initialData?.nombre || ''); | ||||
|         setContacto(initialData?.contacto || ''); | ||||
|         setNroDoc(initialData?.nroDoc || ''); | ||||
|         setIdZona(initialData?.idZona || ''); | ||||
|         setCalle(initialData?.calle || ''); | ||||
|         setNumero(initialData?.numero || ''); | ||||
|         setPiso(initialData?.piso || ''); | ||||
|         setDepto(initialData?.depto || ''); | ||||
|         setTelefono(initialData?.telefono || ''); | ||||
|         setEmail(initialData?.email || ''); | ||||
|         setLocalidad(initialData?.localidad || ''); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!nombre.trim()) errors.nombre = 'El nombre es obligatorio.'; | ||||
|     if (!nroDoc.trim()) errors.nroDoc = 'El Nro. Documento es obligatorio.'; | ||||
|     else if (nroDoc.trim().length > 11) errors.nroDoc = 'El Nro. Documento no debe exceder los 11 caracteres.'; | ||||
|  | ||||
|     if (email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { | ||||
|         errors.email = 'Formato de email inválido.'; | ||||
|     } | ||||
|     // No es obligatorio que IdZona tenga valor | ||||
|     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 = { | ||||
|           nombre, | ||||
|           contacto: contacto || undefined, | ||||
|           nroDoc, | ||||
|           idZona: idZona ? Number(idZona) : null, // Enviar null si está vacío | ||||
|           calle: calle || undefined, | ||||
|           numero: numero || undefined, | ||||
|           piso: piso || undefined, | ||||
|           depto: depto || undefined, | ||||
|           telefono: telefono || undefined, | ||||
|           email: email || undefined, | ||||
|           localidad: localidad || undefined, | ||||
|       }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit(dataToSubmit as UpdateDistribuidorDto, initialData.idDistribuidor); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreateDistribuidorDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de DistribuidorFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {isEditing ? 'Editar Distribuidor' : 'Agregar Nuevo Distribuidor'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             {/* Usando Box con Flexbox para layout de dos columnas responsivo */} | ||||
|             <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> | ||||
|                     <TextField label="Nombre Distribuidor" value={nombre} required | ||||
|                         onChange={(e) => {setNombre(e.target.value); handleInputChange('nombre');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.nombre} helperText={localErrors.nombre || ''} | ||||
|                         disabled={loading} autoFocus={!isEditing} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} // -8px es la mitad del gap | ||||
|                     /> | ||||
|                     <TextField label="Nro. Documento (CUIT/CUIL)" value={nroDoc} required | ||||
|                         onChange={(e) => {setNroDoc(e.target.value); handleInputChange('nroDoc');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.nroDoc} helperText={localErrors.nroDoc || ''} | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt:1 }}> | ||||
|                      <TextField label="Contacto" value={contacto} | ||||
|                         onChange={(e) => setContacto(e.target.value)} | ||||
|                         margin="dense" fullWidth disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.idZona} sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }}> | ||||
|                         <InputLabel id="zona-dist-select-label">Zona (Opcional)</InputLabel> | ||||
|                         <Select labelId="zona-dist-select-label" label="Zona (Opcional)" value={idZona} | ||||
|                             onChange={(e) => {setIdZona(e.target.value as number); handleInputChange('idZona');}} | ||||
|                             disabled={loading || loadingZonas} | ||||
|                         > | ||||
|                             <MenuItem value=""><em>Ninguna</em></MenuItem> | ||||
|                             {zonas.map((z) => (<MenuItem key={z.idZona} value={z.idZona}>{z.nombre}</MenuItem>))} | ||||
|                         </Select> | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt:1 }}> | ||||
|                     <TextField label="Calle" value={calle} onChange={(e) => setCalle(e.target.value)} margin="dense" | ||||
|                         sx={{ flex: 3, minWidth: 'calc(60% - 8px)'}} // Más ancho para la calle | ||||
|                         disabled={loading} | ||||
|                     /> | ||||
|                     <TextField label="Número" value={numero} onChange={(e) => setNumero(e.target.value)} margin="dense" | ||||
|                         sx={{ flex: 1, minWidth: 'calc(40% - 8px)'}} | ||||
|                         disabled={loading} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt:1 }}> | ||||
|                      <TextField label="Piso" value={piso} onChange={(e) => setPiso(e.target.value)} margin="dense" | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)'}} | ||||
|                         disabled={loading} | ||||
|                     /> | ||||
|                     <TextField label="Depto." value={depto} onChange={(e) => setDepto(e.target.value)} margin="dense" | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)'}} | ||||
|                         disabled={loading} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                  <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt:1 }}> | ||||
|                     <TextField label="Teléfono" value={telefono} onChange={(e) => setTelefono(e.target.value)} margin="dense" | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)'}} | ||||
|                         disabled={loading} | ||||
|                     /> | ||||
|                     <TextField label="Email" value={email} type="email" | ||||
|                         onChange={(e) => {setEmail(e.target.value); handleInputChange('email');}} | ||||
|                         margin="dense" fullWidth error={!!localErrors.email} helperText={localErrors.email || ''} | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|                 <TextField label="Localidad" value={localidad} onChange={(e) => setLocalidad(e.target.value)} margin="dense" fullWidth disabled={loading} sx={{mt:1}}/> | ||||
|             </Box> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.zonas && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.zonas}</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 || loadingZonas}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Crear Distribuidor')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DistribuidorFormModal; | ||||
| @@ -1,8 +1,8 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { EmpresaDto } from '../../models/dtos/Empresas/EmpresaDto'; | ||||
| import type { CreateEmpresaDto } from '../../models/dtos/Empresas/CreateEmpresaDto'; | ||||
| import type { UpdateEmpresaDto } from '../../models/dtos/Empresas/UpdateEmpresaDto'; // Necesitamos Update DTO también
 | ||||
| import type { EmpresaDto } from '../../../models/dtos/Distribucion/EmpresaDto'; | ||||
| import type { CreateEmpresaDto } from '../../../models/dtos/Distribucion/CreateEmpresaDto'; | ||||
| import type { UpdateEmpresaDto } from '../../../models/dtos/Distribucion/UpdateEmpresaDto'; // Necesitamos Update DTO también
 | ||||
| 
 | ||||
| const modalStyle = { | ||||
|   position: 'absolute' as 'absolute', | ||||
| @@ -0,0 +1,128 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { OtroDestinoDto } from '../../models/dtos/Distribucion/OtroDestinoDto'; | ||||
| import type { CreateOtroDestinoDto } from '../../models/dtos/Distribucion/CreateOtroDestinoDto'; | ||||
| import type { UpdateOtroDestinoDto } from '../../models/dtos/Distribucion/UpdateOtroDestinoDto'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: 400, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
| }; | ||||
|  | ||||
| interface OtroDestinoFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateOtroDestinoDto | (UpdateOtroDestinoDto & { idDestino: number })) => Promise<void>; | ||||
|   initialData?: OtroDestinoDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const OtroDestinoFormModal: React.FC<OtroDestinoFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [nombre, setNombre] = useState(''); | ||||
|   const [obs, setObs] = useState(''); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [localErrorNombre, setLocalErrorNombre] = useState<string | null>(null); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         setNombre(initialData?.nombre || ''); | ||||
|         setObs(initialData?.obs || ''); | ||||
|         setLocalErrorNombre(null); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|    const handleInputChange = () => { | ||||
|       if (localErrorNombre) setLocalErrorNombre(null); | ||||
|       if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     setLocalErrorNombre(null); | ||||
|     clearErrorMessage(); | ||||
|  | ||||
|     if (!nombre.trim()) { | ||||
|         setLocalErrorNombre('El nombre es obligatorio.'); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit = { nombre, obs: obs || undefined }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit({ ...dataToSubmit, idDestino: initialData.idDestino }); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreateOtroDestinoDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de OtroDestinoFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2"> | ||||
|           {isEditing ? 'Editar Otro Destino' : 'Agregar Nuevo Destino'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|           <TextField | ||||
|             label="Nombre" | ||||
|             fullWidth | ||||
|             required | ||||
|             value={nombre} | ||||
|             onChange={(e) => { setNombre(e.target.value); handleInputChange(); }} | ||||
|             margin="normal" | ||||
|             error={!!localErrorNombre} | ||||
|             helperText={localErrorNombre || ''} | ||||
|             disabled={loading} | ||||
|             autoFocus | ||||
|           /> | ||||
|           <TextField | ||||
|             label="Observación (Opcional)" | ||||
|             fullWidth | ||||
|             value={obs} | ||||
|             onChange={(e) => setObs(e.target.value)} | ||||
|             margin="normal" | ||||
|             multiline | ||||
|             rows={3} | ||||
|             disabled={loading} | ||||
|           /> | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{errorMessage}</Alert>} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}> | ||||
|               Cancelar | ||||
|             </Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default OtroDestinoFormModal; | ||||
							
								
								
									
										225
									
								
								Frontend/src/components/Modals/Distribucion/PrecioFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								Frontend/src/components/Modals/Distribucion/PrecioFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     InputAdornment | ||||
|     // Quitar Grid si no se usa | ||||
| } from '@mui/material'; | ||||
| import type { PrecioDto } from '../../../models/dtos/Distribucion/PrecioDto'; | ||||
| import type { CreatePrecioDto } from '../../../models/dtos/Distribucion/CreatePrecioDto'; | ||||
| import type { UpdatePrecioDto } from '../../../models/dtos/Distribucion/UpdatePrecioDto'; | ||||
|  | ||||
| const modalStyle = { | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '95%', sm: '80%', md: '700px' }, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', | ||||
|     overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| const diasSemana = ["Lunes", "Martes", "Miercoles", "Jueves", "Viernes", "Sabado", "Domingo"] as const; | ||||
| type DiaSemana = typeof diasSemana[number]; | ||||
|  | ||||
| interface PrecioFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePrecioDto | UpdatePrecioDto, idPrecio?: number) => Promise<void>; | ||||
|   idPublicacion: number; | ||||
|   initialData?: PrecioDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PrecioFormModal: React.FC<PrecioFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   idPublicacion, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [vigenciaD, setVigenciaD] = useState(''); | ||||
|   const [vigenciaH, setVigenciaH] = useState(''); | ||||
|   const [preciosDia, setPreciosDia] = useState<Record<DiaSemana, string>>({ | ||||
|     Lunes: '', Martes: '', Miercoles: '', Jueves: '', Viernes: '', Sabado: '', Domingo: '' | ||||
|   }); | ||||
|  | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         setVigenciaD(initialData?.vigenciaD || ''); | ||||
|         setVigenciaH(initialData?.vigenciaH || ''); | ||||
|         const initialPrecios: Record<DiaSemana, string> = { Lunes: '', Martes: '', Miercoles: '', Jueves: '', Viernes: '', Sabado: '', Domingo: '' }; | ||||
|         if (initialData) { | ||||
|             diasSemana.forEach(dia => { | ||||
|                 const key = dia.toLowerCase() as keyof Omit<PrecioDto, 'idPrecio' | 'idPublicacion' | 'vigenciaD' | 'vigenciaH'>; | ||||
|                 initialPrecios[dia] = initialData[key]?.toString() || ''; | ||||
|             }); | ||||
|         } | ||||
|         setPreciosDia(initialPrecios); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!isEditing && !vigenciaD.trim()) { | ||||
|         errors.vigenciaD = 'La Vigencia Desde es obligatoria.'; | ||||
|     } else if (vigenciaD.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaD)) { | ||||
|         errors.vigenciaD = 'Formato de fecha inválido (YYYY-MM-DD).'; | ||||
|     } | ||||
|  | ||||
|     if (vigenciaH.trim() && !/^\d{4}-\d{2}-\d{2}$/.test(vigenciaH)) { | ||||
|         errors.vigenciaH = 'Formato de fecha inválido (YYYY-MM-DD).'; | ||||
|     } else if (vigenciaH.trim() && vigenciaD.trim() && new Date(vigenciaH) < new Date(vigenciaD)) { | ||||
|         errors.vigenciaH = 'Vigencia Hasta no puede ser anterior a Vigencia Desde.'; | ||||
|     } | ||||
|  | ||||
|     let alMenosUnPrecio = false; | ||||
|     diasSemana.forEach(dia => { | ||||
|         const valor = preciosDia[dia]; | ||||
|         if (valor.trim()) { | ||||
|             alMenosUnPrecio = true; | ||||
|             if (isNaN(parseFloat(valor)) || parseFloat(valor) < 0) { | ||||
|                 errors[dia.toLowerCase()] = `Precio de ${dia} inválido.`; | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|     if (!isEditing && !alMenosUnPrecio) { | ||||
|         errors.dias = 'Debe ingresar al menos un precio para un día de la semana.'; | ||||
|     } | ||||
|  | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handlePrecioDiaChange = (dia: DiaSemana, value: string) => { | ||||
|     setPreciosDia(prev => ({ ...prev, [dia]: value })); | ||||
|     if (localErrors[dia.toLowerCase()] || localErrors.dias) { | ||||
|         setLocalErrors(prev => ({ ...prev, [dia.toLowerCase()]: null, dias: null })); | ||||
|     } | ||||
|     if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|   const handleDateChange = (setter: React.Dispatch<React.SetStateAction<string>>, fieldName: string) => (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setter(e.target.value); | ||||
|     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 preciosNumericos: Partial<Record<keyof Omit<PrecioDto, 'idPrecio'|'idPublicacion'|'vigenciaD'|'vigenciaH'>, number | null>> = {}; | ||||
|         diasSemana.forEach(dia => { | ||||
|             const valor = preciosDia[dia].trim(); | ||||
|             // Convertir el nombre del día a la clave correcta del DTO (ej. "Lunes" -> "lunes") | ||||
|             const key = dia.toLowerCase() as keyof typeof preciosNumericos; | ||||
|             preciosNumericos[key] = valor ? parseFloat(valor) : null; | ||||
|         }); | ||||
|  | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         const dataToSubmit: UpdatePrecioDto = { | ||||
|             vigenciaH: vigenciaH.trim() ? vigenciaH : null, | ||||
|             ...preciosNumericos // Spread de los precios de los días | ||||
|         }; | ||||
|         await onSubmit(dataToSubmit, initialData.idPrecio); | ||||
|       } else { | ||||
|         const dataToSubmit: CreatePrecioDto = { | ||||
|             idPublicacion, | ||||
|             vigenciaD, | ||||
|             ...preciosNumericos | ||||
|         }; | ||||
|         await onSubmit(dataToSubmit); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de PrecioFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {isEditing ? 'Editar Período de Precio' : 'Agregar Nuevo Período de Precio'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             {/* Sección de Vigencias */} | ||||
|             <Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}> | ||||
|                 <TextField label="Vigencia Desde" type="date" value={vigenciaD} required={!isEditing} | ||||
|                     onChange={handleDateChange(setVigenciaD, 'vigenciaD')} margin="dense" | ||||
|                     error={!!localErrors.vigenciaD} helperText={localErrors.vigenciaD || ''} | ||||
|                     disabled={loading || isEditing} | ||||
|                     InputLabelProps={{ shrink: true }} | ||||
|                     sx={{ flex: 1, minWidth: isEditing ? 'calc(50% - 8px)' : '100%' }} | ||||
|                     autoFocus={!isEditing} | ||||
|                 /> | ||||
|                 {isEditing && ( | ||||
|                     <TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaH} | ||||
|                         onChange={handleDateChange(setVigenciaH, 'vigenciaH')} margin="dense" | ||||
|                         error={!!localErrors.vigenciaH} helperText={localErrors.vigenciaH || ''} | ||||
|                         disabled={loading} | ||||
|                         InputLabelProps={{ shrink: true }} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                 )} | ||||
|             </Box> | ||||
|  | ||||
|             {/* Sección de Precios por Día con Flexbox */} | ||||
|             <Typography variant="subtitle1" sx={{mt: 2, mb: 1}}>Precios por Día:</Typography> | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> | ||||
|                 {diasSemana.map(dia => ( | ||||
|                     <Box key={dia} sx={{ flexBasis: { xs: 'calc(50% - 8px)', sm: 'calc(33.33% - 11px)', md: 'calc(25% - 12px)' }, minWidth: '120px' }}> | ||||
|                          {/* El ajuste de -Xpx en flexBasis es aproximado para compensar el gap.  | ||||
|                              Para 3 columnas (33.33%): gap es 16px, se distribuye entre 2 espacios -> 16/2 * 2/3 = ~11px | ||||
|                              Para 4 columnas (25%): gap es 16px, se distribuye entre 3 espacios -> 16/3 * 3/4 = 12px | ||||
|                          */} | ||||
|                         <TextField label={dia} type="number" | ||||
|                             value={preciosDia[dia]} | ||||
|                             onChange={(e) => handlePrecioDiaChange(dia, e.target.value)} | ||||
|                             margin="dense" fullWidth | ||||
|                             error={!!localErrors[dia.toLowerCase()]} helperText={localErrors[dia.toLowerCase()] || ''} | ||||
|                             disabled={loading} | ||||
|                             InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} | ||||
|                             inputProps={{ step: "0.01", lang:"es-AR" }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                 ))} | ||||
|             </Box> | ||||
|             {localErrors.dias && <Alert severity="error" sx={{width: '100%', mt:1}}>{localErrors.dias}</Alert>} | ||||
|  | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|  | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar Período')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PrecioFormModal; | ||||
| @@ -0,0 +1,180 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||
| } from '@mui/material'; | ||||
| import type { PublicacionDto } from '../../../models/dtos/Distribucion/PublicacionDto'; | ||||
| import type { CreatePublicacionDto } from '../../../models/dtos/Distribucion/CreatePublicacionDto'; | ||||
| import type { UpdatePublicacionDto } from '../../../models/dtos/Distribucion/UpdatePublicacionDto'; | ||||
| import type { EmpresaDto } from '../../../models/dtos/Distribucion/EmpresaDto'; | ||||
| import empresaService from '../../../services/Distribucion/empresaService'; // Para cargar empresas | ||||
|  | ||||
| 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' | ||||
| }; | ||||
|  | ||||
| interface PublicacionFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePublicacionDto | UpdatePublicacionDto, id?: number) => Promise<void>; | ||||
|   initialData?: PublicacionDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PublicacionFormModal: React.FC<PublicacionFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [nombre, setNombre] = useState(''); | ||||
|   const [observacion, setObservacion] = useState(''); | ||||
|   const [idEmpresa, setIdEmpresa] = useState<number | string>(''); | ||||
|   const [ctrlDevoluciones, setCtrlDevoluciones] = useState(false); | ||||
|   const [habilitada, setHabilitada] = useState(true); | ||||
|  | ||||
|   const [empresas, setEmpresas] = useState<EmpresaDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingEmpresas, setLoadingEmpresas] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchEmpresas = async () => { | ||||
|         setLoadingEmpresas(true); | ||||
|         try { | ||||
|             const data = await empresaService.getAllEmpresas(); | ||||
|             setEmpresas(data); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar empresas", error); | ||||
|             setLocalErrors(prev => ({...prev, empresas: 'Error al cargar empresas.'})); | ||||
|         } finally { | ||||
|             setLoadingEmpresas(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open) { | ||||
|         fetchEmpresas(); | ||||
|         setNombre(initialData?.nombre || ''); | ||||
|         setObservacion(initialData?.observacion || ''); | ||||
|         setIdEmpresa(initialData?.idEmpresa || ''); | ||||
|         setCtrlDevoluciones(initialData ? initialData.ctrlDevoluciones : false); | ||||
|         setHabilitada(initialData ? initialData.habilitada : true); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!nombre.trim()) errors.nombre = 'El nombre es obligatorio.'; | ||||
|     if (!idEmpresa) errors.idEmpresa = 'Debe seleccionar una empresa.'; | ||||
|     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 = { | ||||
|           nombre, | ||||
|           observacion: observacion || undefined, | ||||
|           idEmpresa: Number(idEmpresa), | ||||
|           ctrlDevoluciones, | ||||
|           habilitada | ||||
|       }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit(dataToSubmit as UpdatePublicacionDto, initialData.idPublicacion); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreatePublicacionDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de PublicacionFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {isEditing ? 'Editar Publicación' : 'Agregar Nueva Publicación'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|              <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | ||||
|                 <TextField label="Nombre Publicación" value={nombre} required | ||||
|                     onChange={(e) => {setNombre(e.target.value); handleInputChange('nombre');}} | ||||
|                     margin="dense" fullWidth error={!!localErrors.nombre} helperText={localErrors.nombre || ''} | ||||
|                     disabled={loading} autoFocus={!isEditing} | ||||
|                 /> | ||||
|                 <FormControl fullWidth margin="dense" error={!!localErrors.idEmpresa}> | ||||
|                     <InputLabel id="empresa-pub-select-label" required>Empresa</InputLabel> | ||||
|                     <Select labelId="empresa-pub-select-label" label="Empresa" value={idEmpresa} | ||||
|                         onChange={(e) => {setIdEmpresa(e.target.value as number); handleInputChange('idEmpresa');}} | ||||
|                         disabled={loading || loadingEmpresas} | ||||
|                     > | ||||
|                         <MenuItem value="" disabled><em>Seleccione una empresa</em></MenuItem> | ||||
|                         {empresas.map((e) => (<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>))} | ||||
|                     </Select> | ||||
|                     {localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>} | ||||
|                 </FormControl> | ||||
|                 <TextField label="Observación (Opcional)" value={observacion} | ||||
|                     onChange={(e) => setObservacion(e.target.value)} | ||||
|                     margin="dense" fullWidth multiline rows={3} disabled={loading} | ||||
|                 /> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-around', mt: 1, flexWrap: 'wrap' }}> | ||||
|                     <FormControlLabel | ||||
|                         control={<Checkbox checked={ctrlDevoluciones} onChange={(e) => setCtrlDevoluciones(e.target.checked)} disabled={loading}/>} | ||||
|                         label="Controla Devoluciones" | ||||
|                     /> | ||||
|                     <FormControlLabel | ||||
|                         control={<Checkbox checked={habilitada} onChange={(e) => setHabilitada(e.target.checked)} disabled={loading}/>} | ||||
|                         label="Habilitada" | ||||
|                     /> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           {localErrors.empresas && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.empresas}</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 || loadingEmpresas}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Crear Publicación')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PublicacionFormModal; | ||||
| @@ -1,7 +1,7 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { ZonaDto } from '../../models/dtos/Zonas/ZonaDto'; // Usamos el DTO de la API para listar
 | ||||
| import type { CreateZonaDto } from '../../models/dtos/Zonas/CreateZonaDto'; | ||||
| import type { ZonaDto } from '../../../models/dtos/Zonas/ZonaDto'; // Usamos el DTO de la API para listar
 | ||||
| import type { CreateZonaDto } from '../../../models/dtos/Zonas/CreateZonaDto'; | ||||
| 
 | ||||
| const modalStyle = { | ||||
|   position: 'absolute' as 'absolute', | ||||
| @@ -1,8 +1,8 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { EstadoBobinaDto } from '../../models/dtos/Impresion/EstadoBobinaDto'; | ||||
| import type { CreateEstadoBobinaDto } from '../../models/dtos/Impresion/CreateEstadoBobinaDto'; | ||||
| import type { UpdateEstadoBobinaDto } from '../../models/dtos/Impresion/UpdateEstadoBobinaDto'; | ||||
| import type { EstadoBobinaDto } from '../../../models/dtos/Impresion/EstadoBobinaDto'; | ||||
| import type { CreateEstadoBobinaDto } from '../../../models/dtos/Impresion/CreateEstadoBobinaDto'; | ||||
| import type { UpdateEstadoBobinaDto } from '../../../models/dtos/Impresion/UpdateEstadoBobinaDto'; | ||||
| 
 | ||||
| const modalStyle = { | ||||
|     position: 'absolute' as 'absolute', | ||||
| @@ -1,8 +1,8 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { PlantaDto } from '../../models/dtos/Impresion/PlantaDto'; | ||||
| import type { CreatePlantaDto } from '../../models/dtos/Impresion/CreatePlantaDto'; | ||||
| import type { UpdatePlantaDto } from '../../models/dtos/Impresion/UpdatePlantaDto'; | ||||
| import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto'; | ||||
| import type { CreatePlantaDto } from '../../../models/dtos/Impresion/CreatePlantaDto'; | ||||
| import type { UpdatePlantaDto } from '../../../models/dtos/Impresion/UpdatePlantaDto'; | ||||
| 
 | ||||
| const modalStyle = { /* ... (mismo estilo que otros modales) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
| @@ -1,8 +1,8 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { TipoBobinaDto } from '../../models/dtos/Impresion/TipoBobinaDto'; | ||||
| import type { CreateTipoBobinaDto } from '../../models/dtos/Impresion/CreateTipoBobinaDto'; | ||||
| import type { UpdateTipoBobinaDto } from '../../models/dtos/Impresion/UpdateTipoBobinaDto'; | ||||
| import type { TipoBobinaDto } from '../../../models/dtos/Impresion/TipoBobinaDto'; | ||||
| import type { CreateTipoBobinaDto } from '../../../models/dtos/Impresion/CreateTipoBobinaDto'; | ||||
| import type { UpdateTipoBobinaDto } from '../../../models/dtos/Impresion/UpdateTipoBobinaDto'; | ||||
| 
 | ||||
| const modalStyle = { /* ... (mismo estilo que otros modales) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
							
								
								
									
										205
									
								
								Frontend/src/components/Modals/Usuarios/ChangePasswordModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								Frontend/src/components/Modals/Usuarios/ChangePasswordModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,205 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { useAuth } from '../../../contexts/AuthContext'; | ||||
| import authService from '../../../services/Usuarios/authService'; | ||||
| import type { ChangePasswordRequestDto } from '../../../models/dtos/Usuarios/ChangePasswordRequestDto'; | ||||
| import axios from 'axios'; | ||||
|  | ||||
| import { Modal, Box, Typography, TextField, Button, Alert, CircularProgress, Backdrop } from '@mui/material'; | ||||
|  | ||||
| const style = { | ||||
|   position: 'absolute' as 'absolute', | ||||
|   top: '50%', | ||||
|   left: '50%', | ||||
|   transform: 'translate(-50%, -50%)', | ||||
|   width: 400, | ||||
|   bgcolor: 'background.paper', | ||||
|   border: '2px solid #000', | ||||
|   boxShadow: 24, | ||||
|   p: 4, | ||||
|   display: 'flex', | ||||
|   flexDirection: 'column', | ||||
|   alignItems: 'center', | ||||
| }; | ||||
|  | ||||
| interface ChangePasswordModalProps { | ||||
|   open: boolean; | ||||
|   onClose: (success: boolean) => void; | ||||
|   isFirstLogin?: boolean; | ||||
| } | ||||
|  | ||||
| const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ open, onClose, isFirstLogin }) => { | ||||
|   const [currentPassword, setCurrentPassword] = useState(''); | ||||
|   const [newPassword, setNewPassword] = useState(''); | ||||
|   const [confirmNewPassword, setConfirmNewPassword] = useState(''); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [success, setSuccess] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   // Ya no necesitamos passwordChangeCompleted ni contextIsFirstLogin aquí | ||||
|   const { user, logout } = useAuth(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|       setCurrentPassword(''); | ||||
|       setNewPassword(''); | ||||
|       setConfirmNewPassword(''); | ||||
|       setError(null); | ||||
|       setSuccess(null); | ||||
|       setLoading(false); // Asegurarse de resetear loading también | ||||
|     } | ||||
|   }, [open]); | ||||
|  | ||||
|   // Esta función se llama al hacer clic en el botón Cancelar | ||||
|   const handleCancelClick = () => { | ||||
|     onClose(false); // Notifica al padre (MainLayout) que se canceló | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     setError(null); | ||||
|     setSuccess(null); | ||||
|  | ||||
|     if (newPassword !== confirmNewPassword) { | ||||
|       setError('La nueva contraseña y la confirmación no coinciden.'); | ||||
|       return; | ||||
|     } | ||||
|     if (newPassword.length < 6) { | ||||
|       setError('La nueva contraseña debe tener al menos 6 caracteres.'); | ||||
|       return; | ||||
|     } | ||||
|     if (user && user.username === newPassword) { | ||||
|       setError('La nueva contraseña no puede ser igual al nombre de usuario.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setLoading(true); | ||||
|     const changePasswordData: ChangePasswordRequestDto = { | ||||
|       currentPassword, | ||||
|       newPassword, | ||||
|       confirmNewPassword, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       await authService.changePassword(changePasswordData); | ||||
|       setSuccess('Contraseña cambiada exitosamente.'); | ||||
|       setTimeout(() => { | ||||
|         onClose(true); // Notifica al padre (MainLayout) que fue exitoso | ||||
|       }, 1500); | ||||
|     } catch (err: any) { | ||||
|       console.error("Change password error:", err); | ||||
|       let errorMessage = 'Ocurrió un error inesperado al cambiar la contraseña.'; | ||||
|       if (axios.isAxiosError(err) && err.response) { | ||||
|         errorMessage = err.response.data?.message || errorMessage; | ||||
|         if (err.response.status === 401) { | ||||
|           logout(); // Desloguear si el token es inválido | ||||
|           onClose(false); // Notificar cierre sin éxito | ||||
|         } | ||||
|       } | ||||
|       setError(errorMessage); | ||||
|       setLoading(false); // Asegurarse de quitar loading en caso de error | ||||
|     } | ||||
|     // No poner setLoading(false) en el finally si quieres que el botón siga deshabilitado durante el success | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal | ||||
|       open={open} | ||||
|       onClose={(_event, reason) => { // onClose del Modal de MUI (para backdrop y Escape) | ||||
|           if (reason === "backdropClick" && isFirstLogin) { | ||||
|               return; // No permitir cerrar con backdrop si es el primer login | ||||
|           } | ||||
|           onClose(false); // Llamar a la prop onClose (que va a handleModalClose en MainLayout) | ||||
|       }} | ||||
|       disableEscapeKeyDown={isFirstLogin} // Deshabilitar Escape si es primer login | ||||
|       aria-labelledby="change-password-modal-title" | ||||
|       aria-describedby="change-password-modal-description" | ||||
|       closeAfterTransition | ||||
|       slots={{ backdrop: Backdrop }} | ||||
|       slotProps={{ | ||||
|         backdrop: { | ||||
|           timeout: 500, | ||||
|           sx: { backdropFilter: 'blur(3px)' } | ||||
|         }, | ||||
|       }} | ||||
|     > | ||||
|       <Box sx={style}> | ||||
|         <Typography id="change-password-modal-title" variant="h6" component="h2"> | ||||
|           Cambiar Contraseña | ||||
|         </Typography> | ||||
|         {isFirstLogin && ( | ||||
|           <Alert severity="warning" sx={{ mt: 2, width: '100%' }}> | ||||
|             Por seguridad, debes cambiar tu contraseña inicial. | ||||
|           </Alert> | ||||
|         )} | ||||
|         <Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, width: '100%' }}> | ||||
|           {/* ... TextFields ... */} | ||||
|           <TextField | ||||
|             margin="normal" | ||||
|             required | ||||
|             fullWidth | ||||
|             name="currentPassword" | ||||
|             label="Contraseña Actual" | ||||
|             type="password" | ||||
|             id="currentPasswordModal" | ||||
|             value={currentPassword} | ||||
|             onChange={(e) => setCurrentPassword(e.target.value)} | ||||
|             disabled={loading || !!success} | ||||
|             autoFocus | ||||
|           /> | ||||
|           <TextField | ||||
|             margin="normal" | ||||
|             required | ||||
|             fullWidth | ||||
|             name="newPassword" | ||||
|             label="Nueva Contraseña" | ||||
|             type="password" | ||||
|             id="newPasswordModal" | ||||
|             value={newPassword} | ||||
|             onChange={(e) => setNewPassword(e.target.value)} | ||||
|             disabled={loading || !!success} | ||||
|           /> | ||||
|           <TextField | ||||
|             margin="normal" | ||||
|             required | ||||
|             fullWidth | ||||
|             name="confirmNewPassword" | ||||
|             label="Confirmar Nueva Contraseña" | ||||
|             type="password" | ||||
|             id="confirmNewPasswordModal" | ||||
|             value={confirmNewPassword} | ||||
|             onChange={(e) => setConfirmNewPassword(e.target.value)} | ||||
|             disabled={loading || !!success} | ||||
|             error={newPassword !== confirmNewPassword && confirmNewPassword !== ''} | ||||
|             helperText={newPassword !== confirmNewPassword && confirmNewPassword !== '' ? 'Las contraseñas no coinciden' : ''} | ||||
|           /> | ||||
|  | ||||
|           {error && ( | ||||
|             <Alert severity="error" sx={{ mt: 2, width: '100%' }}> | ||||
|               {error} | ||||
|             </Alert> | ||||
|           )} | ||||
|            {success && ( | ||||
|             <Alert severity="success" sx={{ mt: 2, width: '100%' }}> | ||||
|               {success} | ||||
|             </Alert> | ||||
|           )} | ||||
|  | ||||
|           {/* Un solo grupo de botones */} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             {/* El botón de cancelar llama a handleCancelClick */} | ||||
|             {/* Se podría ocultar si isFirstLogin es true y no queremos que el usuario cancele */} | ||||
|             {/* {!isFirstLogin && ( */} | ||||
|                 <Button onClick={handleCancelClick} disabled={loading || !!success} color="secondary"> | ||||
|                     {isFirstLogin ? "Cancelar y Salir" : "Cancelar"} | ||||
|                 </Button> | ||||
|             {/* )} */} | ||||
|             <Button type="submit" variant="contained" disabled={loading || !!success}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Cambiar Contraseña'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ChangePasswordModal; | ||||
							
								
								
									
										128
									
								
								Frontend/src/components/Modals/Usuarios/PerfilFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								Frontend/src/components/Modals/Usuarios/PerfilFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { PerfilDto } from '../../../models/dtos/Usuarios/PerfilDto'; | ||||
| import type { CreatePerfilDto } from '../../../models/dtos/Usuarios/CreatePerfilDto'; | ||||
| import type { UpdatePerfilDto } from '../../../models/dtos/Usuarios/UpdatePerfilDto'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: 400, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
| }; | ||||
|  | ||||
| interface PerfilFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePerfilDto | (UpdatePerfilDto & { id: number })) => Promise<void>; | ||||
|   initialData?: PerfilDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PerfilFormModal: React.FC<PerfilFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [nombrePerfil, setNombrePerfil] = useState(''); | ||||
|   const [descripcion, setDescripcion] = useState(''); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [localErrorNombre, setLocalErrorNombre] = useState<string | null>(null); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         setNombrePerfil(initialData?.nombrePerfil || ''); | ||||
|         setDescripcion(initialData?.descripcion || ''); | ||||
|         setLocalErrorNombre(null); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|    const handleInputChange = () => { | ||||
|       if (localErrorNombre) setLocalErrorNombre(null); | ||||
|       if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | ||||
|     event.preventDefault(); | ||||
|     setLocalErrorNombre(null); | ||||
|     clearErrorMessage(); | ||||
|  | ||||
|     if (!nombrePerfil.trim()) { | ||||
|         setLocalErrorNombre('El nombre del perfil es obligatorio.'); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit = { nombrePerfil, descripcion: descripcion || undefined }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit({ ...dataToSubmit, id: initialData.id }); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreatePerfilDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de PerfilFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2"> | ||||
|           {isEditing ? 'Editar Perfil' : 'Agregar Nuevo Perfil'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|           <TextField | ||||
|             label="Nombre del Perfil" | ||||
|             fullWidth | ||||
|             required | ||||
|             value={nombrePerfil} | ||||
|             onChange={(e) => { setNombrePerfil(e.target.value); handleInputChange(); }} | ||||
|             margin="normal" | ||||
|             error={!!localErrorNombre} | ||||
|             helperText={localErrorNombre || ''} | ||||
|             disabled={loading} | ||||
|             autoFocus | ||||
|           /> | ||||
|           <TextField | ||||
|             label="Descripción (Opcional)" | ||||
|             fullWidth | ||||
|             value={descripcion} | ||||
|             onChange={(e) => setDescripcion(e.target.value)} | ||||
|             margin="normal" | ||||
|             multiline | ||||
|             rows={3} | ||||
|             disabled={loading} | ||||
|           /> | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{errorMessage}</Alert>} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}> | ||||
|               Cancelar | ||||
|             </Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PerfilFormModal; | ||||
							
								
								
									
										135
									
								
								Frontend/src/components/Modals/Usuarios/PermisoFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								Frontend/src/components/Modals/Usuarios/PermisoFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { PermisoDto } from '../../../models/dtos/Usuarios/PermisoDto'; // Usamos el DTO de la API para listar | ||||
| import type { CreatePermisoDto } from '../../../models/dtos/Usuarios/CreatePermisoDto'; | ||||
| import type { UpdatePermisoDto } from '../../../models/dtos/Usuarios/UpdatePermisoDto'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: 450, // Un poco más ancho para los campos | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
| }; | ||||
|  | ||||
| interface PermisoFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePermisoDto | (UpdatePermisoDto & { id: number })) => Promise<void>; | ||||
|   initialData?: PermisoDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PermisoFormModal: React.FC<PermisoFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [modulo, setModulo] = useState(''); | ||||
|   const [descPermiso, setDescPermiso] = useState(''); | ||||
|   const [codAcc, setCodAcc] = useState(''); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         setModulo(initialData?.modulo || ''); | ||||
|         setDescPermiso(initialData?.descPermiso || ''); | ||||
|         setCodAcc(initialData?.codAcc || ''); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!modulo.trim()) errors.modulo = 'El módulo es obligatorio.'; | ||||
|     if (!descPermiso.trim()) errors.descPermiso = 'La descripción es obligatoria.'; | ||||
|     if (!codAcc.trim()) errors.codAcc = 'El código de acceso es obligatorio.'; | ||||
|     else if (codAcc.length > 10) errors.codAcc = 'El código de acceso no debe exceder los 10 caracteres.'; | ||||
|     // Aquí podrías añadir validación de formato para codAcc si es necesario. | ||||
|  | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = (setter: React.Dispatch<React.SetStateAction<string>>, fieldName: string) => (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setter(e.target.value); | ||||
|     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 = { modulo, descPermiso, codAcc }; | ||||
|  | ||||
|       if (isEditing && initialData) { | ||||
|         await onSubmit({ ...dataToSubmit, id: initialData.id }); | ||||
|       } else { | ||||
|         await onSubmit(dataToSubmit as CreatePermisoDto); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de PermisoFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2"> | ||||
|           {isEditing ? 'Editar Permiso' : 'Agregar Nuevo Permiso'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|           <TextField | ||||
|             label="Módulo" fullWidth required value={modulo} | ||||
|             onChange={handleInputChange(setModulo, 'modulo')} margin="normal" | ||||
|             error={!!localErrors.modulo} helperText={localErrors.modulo || ''} | ||||
|             disabled={loading} autoFocus | ||||
|           /> | ||||
|           <TextField | ||||
|             label="Descripción del Permiso" fullWidth required value={descPermiso} | ||||
|             onChange={handleInputChange(setDescPermiso, 'descPermiso')} margin="normal" | ||||
|             error={!!localErrors.descPermiso} helperText={localErrors.descPermiso || ''} | ||||
|             disabled={loading} | ||||
|           /> | ||||
|           <TextField | ||||
|             label="Código de Acceso (CodAcc)" fullWidth required value={codAcc} | ||||
|             onChange={handleInputChange(setCodAcc, 'codAcc')} margin="normal" | ||||
|             error={!!localErrors.codAcc} helperText={localErrors.codAcc || ''} | ||||
|             disabled={loading} inputProps={{ maxLength: 10 }} | ||||
|           /> | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{errorMessage}</Alert>} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Agregar')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PermisoFormModal; | ||||
| @@ -0,0 +1,69 @@ | ||||
| import React from 'react'; | ||||
| import { Box, Checkbox, FormControlLabel, FormGroup, Typography, Paper } from '@mui/material'; // Quitar Grid | ||||
| import type { PermisoAsignadoDto } from '../../../models/dtos/Usuarios/PermisoAsignadoDto'; | ||||
|  | ||||
| interface PermisosChecklistProps { | ||||
|   permisosDisponibles: PermisoAsignadoDto[]; | ||||
|   permisosSeleccionados: Set<number>; | ||||
|   onPermisoChange: (permisoId: number, asignado: boolean) => void; | ||||
|   disabled?: boolean; | ||||
| } | ||||
|  | ||||
| const PermisosChecklist: React.FC<PermisosChecklistProps> = ({ | ||||
|   permisosDisponibles, | ||||
|   permisosSeleccionados, | ||||
|   onPermisoChange, | ||||
|   disabled = false, | ||||
| }) => { | ||||
|   const permisosAgrupados = permisosDisponibles.reduce((acc, permiso) => { | ||||
|     const modulo = permiso.modulo || 'Otros'; | ||||
|     if (!acc[modulo]) { | ||||
|       acc[modulo] = []; | ||||
|     } | ||||
|     acc[modulo].push(permiso); | ||||
|     return acc; | ||||
|   }, {} as Record<string, PermisoAsignadoDto[]>); | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> {/* Contenedor Flexbox */} | ||||
|       {Object.entries(permisosAgrupados).map(([modulo, permisosDelModulo]) => ( | ||||
|         <Box | ||||
|           key={modulo} | ||||
|           sx={{ | ||||
|             flexGrow: 1, // Para que las columnas crezcan | ||||
|             flexBasis: { xs: '100%', sm: 'calc(50% - 16px)', md: 'calc(33.333% - 16px)' }, // Simula xs, sm, md | ||||
|             // El '-16px' es por el gap (si el gap es 2 = 16px). Ajustar si el gap es diferente. | ||||
|             // Alternativamente, usar porcentajes más simples y dejar que el flexWrap maneje el layout. | ||||
|             // flexBasis: '300px', // Un ancho base y dejar que flexWrap haga el resto | ||||
|             minWidth: '280px', // Ancho mínimo para cada columna | ||||
|             maxWidth: { xs: '100%', sm: '50%', md: '33.333%' }, // Máximo ancho | ||||
|           }} | ||||
|         > | ||||
|           <Paper elevation={2} sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}> | ||||
|             <Typography variant="subtitle1" gutterBottom component="div" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb:1 }}> | ||||
|               {modulo} | ||||
|             </Typography> | ||||
|             <FormGroup sx={{ flexGrow: 1}}> {/* Para que ocupe el espacio vertical */} | ||||
|               {permisosDelModulo.map((permiso) => ( | ||||
|                 <FormControlLabel | ||||
|                   key={permiso.id} | ||||
|                   control={ | ||||
|                     <Checkbox | ||||
|                       checked={permisosSeleccionados.has(permiso.id)} | ||||
|                       onChange={(e) => onPermisoChange(permiso.id, e.target.checked)} | ||||
|                       disabled={disabled} | ||||
|                       size="small" | ||||
|                     /> | ||||
|                   } | ||||
|                   label={<Typography variant="body2">{`${permiso.descPermiso} (${permiso.codAcc})`}</Typography>} | ||||
|                 /> | ||||
|               ))} | ||||
|             </FormGroup> | ||||
|           </Paper> | ||||
|         </Box> | ||||
|       ))} | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PermisosChecklist; | ||||
							
								
								
									
										122
									
								
								Frontend/src/components/Modals/Usuarios/SetPasswordModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								Frontend/src/components/Modals/Usuarios/SetPasswordModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControlLabel, Checkbox } from '@mui/material'; | ||||
| import type { SetPasswordRequestDto } from '../../../models/dtos/Usuarios/SetPasswordRequestDto'; | ||||
| import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | ||||
|  | ||||
| const modalStyle = { /* ... (mismo estilo) ... */ | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: 400, | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
| }; | ||||
|  | ||||
| interface SetPasswordModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (userId: number, data: SetPasswordRequestDto) => Promise<void>; | ||||
|   usuario: UsuarioDto | null; // Usuario para el cual se setea la clave | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const SetPasswordModal: React.FC<SetPasswordModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   usuario, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [newPassword, setNewPassword] = useState(''); | ||||
|   const [confirmNewPassword, setConfirmNewPassword] = useState(''); | ||||
|   const [forceChange, setForceChange] = useState(true); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (open) { | ||||
|         setNewPassword(''); | ||||
|         setConfirmNewPassword(''); | ||||
|         setForceChange(true); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, clearErrorMessage]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!newPassword) errors.newPassword = 'La nueva contraseña es obligatoria.'; | ||||
|     else if (newPassword.length < 6) errors.newPassword = 'La contraseña debe tener al menos 6 caracteres.'; | ||||
|     if (newPassword !== confirmNewPassword) errors.confirmNewPassword = 'Las contraseñas no coinciden.'; | ||||
|     if (usuario && usuario.user.toLowerCase() === newPassword.toLowerCase()) errors.newPassword = 'La contraseña no puede ser igual al nombre de usuario.' | ||||
|  | ||||
|     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() || !usuario) return; | ||||
|  | ||||
|     setLoading(true); | ||||
|     try { | ||||
|       const dataToSubmit: SetPasswordRequestDto = { newPassword, forceChangeOnNextLogin: forceChange }; | ||||
|       await onSubmit(usuario.id, dataToSubmit); | ||||
|       onClose(); | ||||
|     } catch (error) { | ||||
|       console.error("Error en submit de SetPasswordModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           Establecer Contraseña para {usuario?.user || ''} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|           <TextField label="Nueva Contraseña" type="password" fullWidth required value={newPassword} | ||||
|             onChange={(e)=>{setNewPassword(e.target.value); handleInputChange('newPassword');}} margin="dense" | ||||
|             error={!!localErrors.newPassword} helperText={localErrors.newPassword || ''} | ||||
|             disabled={loading} autoFocus | ||||
|           /> | ||||
|           <TextField label="Confirmar Nueva Contraseña" type="password" fullWidth required value={confirmNewPassword} | ||||
|             onChange={(e)=>{setConfirmNewPassword(e.target.value); handleInputChange('confirmNewPassword');}} margin="dense" | ||||
|             error={!!localErrors.confirmNewPassword} helperText={localErrors.confirmNewPassword || ''} | ||||
|             disabled={loading} | ||||
|           /> | ||||
|           <FormControlLabel | ||||
|             control={<Checkbox checked={forceChange} onChange={(e) => setForceChange(e.target.checked)} disabled={loading}/>} | ||||
|             label="Forzar cambio en próximo inicio de sesión" | ||||
|             sx={{ mt: 1 }} | ||||
|           /> | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : 'Establecer Contraseña'} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SetPasswordModal; | ||||
							
								
								
									
										255
									
								
								Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								Frontend/src/components/Modals/Usuarios/UsuarioFormModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { | ||||
|     Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox | ||||
| } from '@mui/material'; | ||||
| import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; | ||||
| import type { CreateUsuarioRequestDto } from '../../../models/dtos/Usuarios/CreateUsuarioRequestDto'; | ||||
| import type { UpdateUsuarioRequestDto } from '../../../models/dtos/Usuarios/UpdateUsuarioRequestDto'; | ||||
| import type { PerfilDto } from '../../../models/dtos/Usuarios/PerfilDto'; // Para el dropdown de perfiles | ||||
| import perfilService from '../../../services/Usuarios/perfilService'; // Para obtener la lista de perfiles | ||||
|  | ||||
| const modalStyle = { | ||||
|     position: 'absolute' as 'absolute', | ||||
|     top: '50%', | ||||
|     left: '50%', | ||||
|     transform: 'translate(-50%, -50%)', | ||||
|     width: { xs: '90%', sm: 500 }, // Responsive width | ||||
|     bgcolor: 'background.paper', | ||||
|     border: '2px solid #000', | ||||
|     boxShadow: 24, | ||||
|     p: 4, | ||||
|     maxHeight: '90vh', // Para permitir scroll si el contenido es mucho | ||||
|     overflowY: 'auto'   // Habilitar scroll vertical | ||||
| }; | ||||
|  | ||||
| interface UsuarioFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreateUsuarioRequestDto | UpdateUsuarioRequestDto, id?: number) => Promise<void>; | ||||
|   initialData?: UsuarioDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const UsuarioFormModal: React.FC<UsuarioFormModalProps> = ({ | ||||
|   open, | ||||
|   onClose, | ||||
|   onSubmit, | ||||
|   initialData, | ||||
|   errorMessage, | ||||
|   clearErrorMessage | ||||
| }) => { | ||||
|   const [user, setUser] = useState(''); | ||||
|   const [password, setPassword] = useState(''); | ||||
|   const [confirmPassword, setConfirmPassword] = useState(''); | ||||
|   const [nombre, setNombre] = useState(''); | ||||
|   const [apellido, setApellido] = useState(''); | ||||
|   const [idPerfil, setIdPerfil] = useState<number | string>(''); // Puede ser string vacío inicialmente | ||||
|   const [habilitada, setHabilitada] = useState(true); | ||||
|   const [supAdmin, setSupAdmin] = useState(false); | ||||
|   const [debeCambiarClave, setDebeCambiarClave] = useState(true); | ||||
|   const [verLog, setVerLog] = useState('1.0.0.0'); | ||||
|  | ||||
|   const [perfiles, setPerfiles] = useState<PerfilDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingPerfiles, setLoadingPerfiles] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const isEditing = Boolean(initialData); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchPerfiles = async () => { | ||||
|         setLoadingPerfiles(true); | ||||
|         try { | ||||
|             const data = await perfilService.getAllPerfiles(); | ||||
|             setPerfiles(data); | ||||
|         } catch (error) { | ||||
|             console.error("Error al cargar perfiles", error); | ||||
|             setLocalErrors(prev => ({...prev, perfiles: 'Error al cargar perfiles.'})); | ||||
|         } finally { | ||||
|             setLoadingPerfiles(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (open) { | ||||
|         fetchPerfiles(); | ||||
|         setUser(initialData?.user || ''); | ||||
|         // No pre-rellenar contraseña en edición | ||||
|         setPassword(''); | ||||
|         setConfirmPassword(''); | ||||
|         setNombre(initialData?.nombre || ''); | ||||
|         setApellido(initialData?.apellido || ''); | ||||
|         setIdPerfil(initialData?.idPerfil || ''); | ||||
|         setHabilitada(initialData ? initialData.habilitada : true); | ||||
|         setSupAdmin(initialData ? initialData.supAdmin : false); | ||||
|         setDebeCambiarClave(initialData ? initialData.debeCambiarClave : !isEditing); // true para creación | ||||
|         setVerLog(initialData?.verLog || '1.0.0.0'); | ||||
|         setLocalErrors({}); | ||||
|         clearErrorMessage(); | ||||
|     } | ||||
|   }, [open, initialData, clearErrorMessage, isEditing]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!user.trim()) errors.user = 'El nombre de usuario es obligatorio.'; | ||||
|     else if (user.length < 3) errors.user = 'El usuario debe tener al menos 3 caracteres.'; | ||||
|  | ||||
|     if (!isEditing || (isEditing && password)) { // Validar contraseña solo si se está creando o si se ingresó algo en edición | ||||
|         if (!password) errors.password = 'La contraseña es obligatoria.'; | ||||
|         else if (password.length < 6) errors.password = 'La contraseña debe tener al menos 6 caracteres.'; | ||||
|         if (password !== confirmPassword) errors.confirmPassword = 'Las contraseñas no coinciden.'; | ||||
|         if (user.trim().toLowerCase() === password.toLowerCase()) errors.password = 'La contraseña no puede ser igual al nombre de usuario.' | ||||
|     } | ||||
|  | ||||
|     if (!nombre.trim()) errors.nombre = 'El nombre es obligatorio.'; | ||||
|     if (!apellido.trim()) errors.apellido = 'El apellido es obligatorio.'; | ||||
|     if (!idPerfil) errors.idPerfil = 'Debe seleccionar un perfil.'; | ||||
|  | ||||
|     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 { | ||||
|       if (isEditing && initialData) { | ||||
|         const dataToSubmit: UpdateUsuarioRequestDto = { | ||||
|             nombre, apellido, idPerfil: Number(idPerfil), habilitada, supAdmin, debeCambiarClave, verLog | ||||
|         }; | ||||
|         await onSubmit(dataToSubmit, initialData.id); | ||||
|         // Si se ingresó una nueva contraseña, llamar a un endpoint separado para cambiarla | ||||
|         if (password) { | ||||
|             // Esto requeriría un endpoint adicional en el backend para que un admin cambie la clave de otro user | ||||
|             // o adaptar el flujo de `AuthService.ChangePasswordAsync` si es posible, | ||||
|             // o un nuevo endpoint en UsuarioController para setear clave. | ||||
|             // Por ahora, lo dejamos así, la clave se cambia por el propio usuario o por un reset del admin. | ||||
|             console.warn("El cambio de contraseña en edición de usuario no está implementado en este modal directamente. Usar la opción de 'Resetear Contraseña'."); | ||||
|         } | ||||
|  | ||||
|       } else { | ||||
|         const dataToSubmit: CreateUsuarioRequestDto = { | ||||
|             user, password, nombre, apellido, idPerfil: Number(idPerfil), habilitada, supAdmin, debeCambiarClave, verLog | ||||
|         }; | ||||
|         await onSubmit(dataToSubmit); | ||||
|       } | ||||
|       onClose(); | ||||
|     } catch (error: any) { | ||||
|       console.error("Error en submit de UsuarioFormModal:", error); | ||||
|     } finally { | ||||
|        setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {isEditing ? 'Editar Usuario' : 'Agregar Nuevo Usuario'} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}> | ||||
|             {/* SECCIÓN DE CAMPOS CON BOX Y FLEXBOX */} | ||||
|             <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> {/* Contenedor principal de campos */} | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> {/* Fila 1 */} | ||||
|                     <TextField label="Nombre de Usuario" fullWidth required value={user} | ||||
|                         onChange={(e) => {setUser(e.target.value); handleInputChange('user');}} margin="dense" | ||||
|                         error={!!localErrors.user} helperText={localErrors.user || ''} | ||||
|                         disabled={loading || isEditing} autoFocus={!isEditing} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} // 8px es la mitad del gap | ||||
|                     /> | ||||
|                     <FormControl fullWidth margin="dense" error={!!localErrors.idPerfil} sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }}> | ||||
|                         <InputLabel id="perfil-select-label" required>Perfil</InputLabel> | ||||
|                         <Select | ||||
|                             labelId="perfil-select-label" | ||||
|                             label="Perfil" | ||||
|                             value={idPerfil} | ||||
|                             onChange={(e) => { setIdPerfil(e.target.value as number); handleInputChange('idPerfil');}} | ||||
|                             disabled={loading || loadingPerfiles} | ||||
|                         > | ||||
|                             <MenuItem value="" disabled><em>Seleccione un perfil</em></MenuItem> | ||||
|                             {perfiles.map((p) => ( <MenuItem key={p.id} value={p.id}>{p.nombrePerfil}</MenuItem> ))} | ||||
|                         </Select> | ||||
|                         {localErrors.idPerfil && <Typography color="error" variant="caption">{localErrors.idPerfil}</Typography>} | ||||
|                         {loadingPerfiles && <CircularProgress size={20} sx={{position: 'absolute', right: 30, top: '50%', marginTop: '-10px'}}/>} | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|  | ||||
|                 {!isEditing && ( | ||||
|                     <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 1 }}> {/* Fila 2 (Contraseñas) */} | ||||
|                         <TextField label="Contraseña" type="password" fullWidth required={!isEditing} value={password} | ||||
|                             onChange={(e) => {setPassword(e.target.value); handleInputChange('password');}} margin="dense" | ||||
|                             error={!!localErrors.password} helperText={localErrors.password || ''} | ||||
|                             disabled={loading} | ||||
|                             sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                         /> | ||||
|                         <TextField label="Confirmar Contraseña" type="password" fullWidth required={!isEditing} value={confirmPassword} | ||||
|                             onChange={(e) => {setConfirmPassword(e.target.value); handleInputChange('confirmPassword');}} margin="dense" | ||||
|                             error={!!localErrors.confirmPassword} helperText={localErrors.confirmPassword || ''} | ||||
|                             disabled={loading} | ||||
|                             sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                 )} | ||||
|  | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 1 }}> {/* Fila 3 (Nombre y Apellido) */} | ||||
|                     <TextField label="Nombre" fullWidth required value={nombre} | ||||
|                         onChange={(e) => {setNombre(e.target.value); handleInputChange('nombre');}} margin="dense" | ||||
|                         error={!!localErrors.nombre} helperText={localErrors.nombre || ''} | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                     <TextField label="Apellido" fullWidth required value={apellido} | ||||
|                         onChange={(e) => {setApellido(e.target.value); handleInputChange('apellido');}} margin="dense" | ||||
|                         error={!!localErrors.apellido} helperText={localErrors.apellido || ''} | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                 </Box> | ||||
|  | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 1, alignItems: 'center' }}> {/* Fila 4 (VerLog y Habilitado) */} | ||||
|                     <TextField label="Versión Log" fullWidth value={verLog} | ||||
|                         onChange={(e) => setVerLog(e.target.value)} margin="dense" | ||||
|                         disabled={loading} | ||||
|                         sx={{ flex: 1, minWidth: 'calc(50% - 8px)' }} | ||||
|                     /> | ||||
|                      <Box sx={{ flex: 1, minWidth: 'calc(50% - 8px)', display: 'flex', justifyContent: 'flex-start', pl:1 /* o pt si es vertical */ }}> | ||||
|                         <FormControlLabel control={<Checkbox checked={habilitada} onChange={(e) => setHabilitada(e.target.checked)} disabled={loading}/>} label="Habilitado" /> | ||||
|                      </Box> | ||||
|                 </Box> | ||||
|  | ||||
|                 <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 0.5 }}> {/* Fila 5 (Checkboxes) */} | ||||
|                      <Box sx={{ flex: 1, minWidth: 'calc(50% - 8px)'}}> | ||||
|                         <FormControlLabel control={<Checkbox checked={supAdmin} onChange={(e) => setSupAdmin(e.target.checked)} disabled={loading}/>} label="Super Administrador" /> | ||||
|                      </Box> | ||||
|                      <Box sx={{ flex: 1, minWidth: 'calc(50% - 8px)'}}> | ||||
|                         <FormControlLabel control={<Checkbox checked={debeCambiarClave} onChange={(e) => setDebeCambiarClave(e.target.checked)} disabled={loading}/>} label="Debe Cambiar Clave" /> | ||||
|                     </Box> | ||||
|                 </Box> | ||||
|             </Box> {/* Fin contenedor principal de campos */} | ||||
|  | ||||
|  | ||||
|           {errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>} | ||||
|           <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|             <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|             <Button type="submit" variant="contained" disabled={loading}> | ||||
|               {loading ? <CircularProgress size={24} /> : (isEditing ? 'Guardar Cambios' : 'Crear Usuario')} | ||||
|             </Button> | ||||
|           </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default UsuarioFormModal; | ||||
		Reference in New Issue
	
	Block a user