Refactor: Mejora la lógica de facturación y la UI

Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.

Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
  agrupar las suscripciones por cliente y empresa, generando una
  factura consolidada para cada combinación. Esto asegura la correcta
  separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
  de un período (ej. Septiembre) aplique únicamente los ajustes
  pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
  `GenerarFacturacionMensual` que impide generar la facturación de un
  período si el anterior no ha sido cerrado, garantizando el orden
  cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
  generar el cierre ahora envía un único email consolidado por
  suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
  para que opere sobre una `idFactura` individual y adjunte el PDF
  correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
  `FacturaRepository` y `AjusteRepository` para soportar los nuevos
  requisitos de filtrado y consulta de datos consolidados.

Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
  - `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
    débito, procesar respuesta).
  - `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
    filtrar y gestionar facturas individuales con una interfaz de doble
    acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
  filtros por nombre de suscriptor, estado de pago y estado de
  facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
  ahora filtra por el mes actual por defecto para mejorar el rendimiento
  y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
  impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
  registrar un monto superior al saldo pendiente de la factura.
This commit is contained in:
2025-08-08 09:48:15 -03:00
parent 9cfe9d012e
commit 899e0a173f
87 changed files with 2947 additions and 1231 deletions

View File

@@ -1,6 +1,10 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
const modalStyle = {
position: 'absolute' as 'absolute',
@@ -12,34 +16,47 @@ const modalStyle = {
boxShadow: 24, p: 4,
};
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
interface AjusteFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateAjusteDto) => Promise<void>;
onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
initialData?: AjusteDto | null;
idSuscriptor: number;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreateAjusteDto>>({});
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData }) => {
const [formData, setFormData] = useState<AjusteFormData>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
useEffect(() => {
if (open) {
// Formatear fecha correctamente: el DTO de Ajuste tiene FechaAlta con hora, pero el input necesita "yyyy-MM-dd"
const fechaParaFormulario = initialData?.fechaAjuste
? initialData.fechaAjuste.split(' ')[0] // Tomar solo la parte de la fecha
: new Date().toISOString().split('T')[0];
setFormData({
idSuscriptor: idSuscriptor,
tipoAjuste: 'Credito', // Por defecto es un crédito (descuento)
monto: 0,
motivo: ''
idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
fechaAjuste: fechaParaFormulario,
tipoAjuste: initialData?.tipoAjuste || 'Credito',
monto: initialData?.monto || undefined, // undefined para que el placeholder se muestre
motivo: initialData?.motivo || ''
});
setLocalErrors({});
}
}, [open, idSuscriptor]);
}, [open, initialData, idSuscriptor]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria.";
if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio.";
@@ -47,16 +64,20 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
return Object.keys(errors).length === 0;
};
// --- HANDLERS CON TIPADO EXPLÍCITO ---
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: name === 'monto' ? parseFloat(value) : value }));
setFormData((prev: AjusteFormData) => ({
...prev,
[name]: name === 'monto' && value !== '' ? parseFloat(value) : value
}));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const handleSelectChange = (e: SelectChangeEvent<string>) => { // Tipado como string
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -68,7 +89,11 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
setLoading(true);
let success = false;
try {
await onSubmit(formData as CreateAjusteDto);
if (isEditing && initialData) {
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
} else {
await onSubmit(formData as CreateAjusteDto);
}
success = true;
} catch (error) {
success = false;
@@ -81,20 +106,22 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Ajuste Manual</Typography>
<Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} />
<FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select>
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
</Select>
</FormControl>
<TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
<TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} />
<Alert severity="info" sx={{ mt: 2 }}>
Nota: Este ajuste se aplicará en la facturación del período correspondiente a la "Fecha del Ajuste".
</Alert>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{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}>

View File

@@ -1,12 +1,26 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material';
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto';
import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto';
import suscripcionService from '../../../services/Suscripciones/suscripcionService';
const modalStyle = { /* ... */ };
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 GestionarPromocionesSuscripcionModalProps {
open: boolean;
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
}
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]);
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
const [vigenciaDesde, setVigenciaDesde] = useState('');
const [vigenciaHasta, setVigenciaHasta] = useState('');
const cargarDatos = useCallback(async () => {
if (!suscripcion) return;
setLoading(true);
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
}, [suscripcion]);
useEffect(() => {
if (open) {
if (open && suscripcion) {
cargarDatos();
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
}
}, [open, cargarDatos]);
}, [open, suscripcion]);
const handleAsignar = async () => {
if (!suscripcion || !selectedPromo) return;
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
setError("Debe seleccionar una promoción y una fecha de inicio.");
return;
}
setError(null);
try {
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo));
const dto: AsignarPromocionDto = {
idPromocion: Number(selectedPromo),
vigenciaDesde: vigenciaDesde,
vigenciaHasta: vigenciaHasta || null
};
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto);
setSelectedPromo('');
setVigenciaDesde(suscripcion.fechaInicio);
setVigenciaHasta('');
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al asignar la promoción.");
@@ -58,14 +89,34 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
const handleQuitar = async (idPromocion: number) => {
if (!suscripcion) return;
try {
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al quitar la promoción.");
setError(null);
if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
try {
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
cargarDatos();
} catch (err: any) {
setError(err.response?.data?.message || "Error al quitar la promoción.");
}
}
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Indefinida';
const parts = dateString.split('-');
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
const formatSecondaryText = (promo: PromocionAsignadaDto): string => {
let text = '';
switch (promo.tipoEfecto) {
case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break;
case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break;
case 'BonificarEntregaDia': text = 'Bonificación de Día'; break;
default: text = 'Tipo desconocido';
}
return text;
};
if (!suscripcion) return null;
return (
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
<Box sx={modalStyle}>
<Typography variant="h6">Gestionar Promociones</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Suscripción a: {suscripcion.nombrePublicacion}
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
</Typography>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{loading ? <CircularProgress /> : (
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
<>
<Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography>
<Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography>
<List dense>
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
{asignadas.map(p => (
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}>
<ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} />
<ListItemText
primary={p.descripcion}
secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`}
/>
</ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Typography>Asignar Nueva Promoción</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
<FormControl fullWidth size="small">
<Box sx={{ mt: 1 }}>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>Promociones Disponibles</InputLabel>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}>
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}>
{disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
</Select>
</FormControl>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button>
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} />
<TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} />
</Box>
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}>
Asignar
</Button>
</Box>
</>
)}

View File

@@ -1,5 +1,3 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
@@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago();
setFormData({
idFactura: factura.idFactura,
monto: factura.importeFinal,
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
fechaPago: new Date().toISOString().split('T')[0]
});
setLocalErrors({});
@@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0;
const saldo = factura?.saldoPendiente ?? 0;
if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldo) {
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
@@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Factura #{factura.idFactura} para {factura.nombreSuscriptor}
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />

View File

@@ -1,28 +1,33 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
type SelectChangeEvent, InputAdornment } from '@mui/material';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
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'
boxShadow: 24, p: 4,
maxHeight: '90vh', overflowY: 'auto'
};
const tiposPromocion = [
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' },
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' },
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones
const tiposEfecto = [
{ value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
{ value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
{ value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' },
];
const tiposCondicion = [
{ value: 'Siempre', label: 'Siempre (en todos los días de entrega)' },
{ value: 'DiaDeSemana', label: 'Un día de la semana específico' },
{ value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' },
];
const diasSemana = [
{ value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' },
{ value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' },
{ value: 7, label: 'Domingo' }
];
interface PromocionFormModalProps {
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
useEffect(() => {
if (open) {
setFormData(initialData || {
const defaults = {
descripcion: '',
tipoPromocion: 'Porcentaje',
valor: 0,
tipoEfecto: 'DescuentoPorcentajeTotal' as const,
valorEfecto: 0,
tipoCondicion: 'Siempre' as const,
valorCondicion: null,
fechaInicio: new Date().toISOString().split('T')[0],
activa: true
});
};
setFormData(initialData ? { ...initialData } : defaults);
setLocalErrors({});
}
}, [open, initialData]);
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.';
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.';
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) {
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.';
if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
errors.valorEfecto = 'El valor debe ser mayor a cero.';
}
if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) {
errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.';
}
if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.';
if (necesitaValorCondicion && !formData.valorCondicion) {
errors.valorCondicion = "Debe seleccionar un día para esta condición.";
}
if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
@@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value);
const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value);
setFormData(prev => ({ ...prev, [name]: finalValue }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
const newFormData = { ...formData, [name]: value };
if (name === 'tipoCondicion' && value === 'Siempre') {
newFormData.valorCondicion = null;
}
if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') {
newFormData.valorEfecto = 0; // Bonificar no necesita valor
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
setLoading(true);
let success = false;
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null
} as CreatePromocionDto | UpdatePromocionDto;
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
await onSubmit(dataToSubmit, initialData?.idPromocion);
success = true;
} catch (error) {
@@ -111,32 +131,43 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}>
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel>
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}>
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
<TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
<InputLabel>Efecto de la Promoción</InputLabel>
<Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
{tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
{formData.tipoEfecto !== 'BonificarEntregaDia' && (
<TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
inputProps={{ step: "0.01" }}
/>
</Box>
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
)}
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
<InputLabel>Condición de Aplicación</InputLabel>
<Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}>
{tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
</Select>
</FormControl>
{necesitaValorCondicion && (
<FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}>
<InputLabel>Día de la Semana</InputLabel>
<Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}>
{diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)}
</Select>
</FormControl>
)}
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
</Box>
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} />
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{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}>

View File

@@ -25,10 +25,10 @@ const modalStyle = {
};
const dias = [
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
{ label: 'Domingo', value: 'D' }
{ label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
{ label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
{ label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
{ label: 'Domingo', value: 'Dom' }
];
interface SuscripcionFormModalProps {

View File

@@ -1,7 +1,5 @@
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material';
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
@@ -31,9 +29,7 @@ interface SuscriptorFormModalProps {
clearErrorMessage: () => void;
}
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
}) => {
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false);
@@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (open) {
fetchFormasDePago();
setFormData(initialData || {
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
});
const dataParaFormulario: Partial<CreateSuscriptorDto> = {
nombreCompleto: initialData?.nombreCompleto || '',
email: initialData?.email || '',
telefono: initialData?.telefono || '',
direccion: initialData?.direccion || '',
tipoDocumento: initialData?.tipoDocumento || 'DNI',
nroDocumento: initialData?.nroDocumento || '',
cbu: initialData?.cbu || '',
idFormaPagoPreferida: initialData?.idFormaPagoPreferida,
observaciones: initialData?.observaciones || ''
};
setFormData(dataParaFormulario);
setLocalErrors({});
}
}, [open, initialData]);
@@ -73,9 +78,15 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
if (CBURequerido) {
if (!formData.cbu || formData.cbu.trim().length !== 22) {
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
}
} else if (formData.cbu && formData.cbu.trim().length > 0 && formData.cbu.trim().length !== 22) {
errors.cbu = 'El CBU debe tener 22 dígitos o estar vacío.';
}
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
errors.email = 'El formato del email no es válido.';
}
@@ -86,23 +97,25 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
}
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
// 2. Crear un handler específico para los Select
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) {
setLocalErrors(prev => ({ ...prev, [name]: null }));
const newFormData = { ...formData, [name]: value };
if (name === 'idFormaPagoPreferida') {
const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value);
if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) {
newFormData.cbu = '';
}
}
setFormData(newFormData);
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
@@ -111,7 +124,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
setLoading(true);
let success = false;
try {
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
const dataToSubmit = {
...formData,
idFormaPagoPreferida: Number(formData.idFormaPagoPreferida),
cbu: formData.cbu?.trim() || null
} as CreateSuscriptorDto | UpdateSuscriptorDto;
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
success = true;
} catch (error) {
@@ -140,7 +158,6 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<FormControl margin="dense" sx={{ minWidth: 120 }}>
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
<MenuItem value="DNI">DNI</MenuItem>
<MenuItem value="CUIT">CUIT</MenuItem>
@@ -151,15 +168,37 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
</Box>
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
{/* 3. Aplicar el nuevo handler a los Selects */}
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
<Select
labelId="forma-pago-label"
name="idFormaPagoPreferida"
value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''}
onChange={handleSelectChange}
label="Forma de Pago"
disabled={loading || loadingFormasPago}
>
{loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>}
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
</Select>
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
</FormControl>
{CBURequerido && (
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
<TextField
name="cbu"
label="CBU"
value={formData.cbu || ''}
onChange={handleInputChange}
required
fullWidth
margin="dense"
error={!!localErrors.cbu}
helperText={localErrors.cbu}
disabled={loading}
inputProps={{ maxLength: 22 }}
/>
)}
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}