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:
2025-05-20 12:38:55 -03:00
parent daf84d2708
commit b6ba52f074
228 changed files with 10745 additions and 178 deletions

View File

@@ -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',

View 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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;