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:
		| @@ -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}> | ||||
|   | ||||
| @@ -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> | ||||
|                     </> | ||||
|                 )} | ||||
|   | ||||
| @@ -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} /> | ||||
|   | ||||
| @@ -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}> | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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>} | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| export interface AjusteDto { | ||||
|   idAjuste: number; | ||||
|   fechaAjuste: string; | ||||
|   idSuscriptor: number; | ||||
|   tipoAjuste: 'Credito' | 'Debito'; | ||||
|   monto: number; | ||||
|   | ||||
| @@ -0,0 +1,5 @@ | ||||
| export interface AsignarPromocionDto { | ||||
|     idPromocion: number; | ||||
|     vigenciaDesde: string; // "yyyy-MM-dd" | ||||
|     vigenciaHasta?: string | null; | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| export interface CreateAjusteDto { | ||||
|   fechaAjuste: string; | ||||
|   idSuscriptor: number; | ||||
|   tipoAjuste: 'Credito' | 'Debito'; | ||||
|   monto: number; | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| export interface CreatePromocionDto { | ||||
|   descripcion: string; | ||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; | ||||
|   valor: number; | ||||
|   tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia'; | ||||
|   valorEfecto: number; | ||||
|   tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes'; | ||||
|   valorCondicion?: number | null; | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   activa: boolean; | ||||
|   | ||||
| @@ -4,6 +4,6 @@ export interface CreateSuscripcionDto { | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   estado: 'Activa' | 'Pausada' | 'Cancelada'; | ||||
|   diasEntrega: string[]; // ["L", "M", "X"] | ||||
|   diasEntrega: string[]; // ["Lun", "Mar", "Mie"] | ||||
|   observaciones?: string | null; | ||||
| } | ||||
| @@ -1,14 +1,20 @@ | ||||
| export interface FacturaDetalleDto { | ||||
|     descripcion: string; | ||||
|     importeNeto: number; | ||||
| } | ||||
|  | ||||
| export interface FacturaDto { | ||||
|   idFactura: number; | ||||
|   idSuscripcion: number; | ||||
|   periodo: string; // "YYYY-MM" | ||||
|   fechaEmision: string; // "yyyy-MM-dd" | ||||
|   fechaVencimiento: string; // "yyyy-MM-dd" | ||||
|   idSuscriptor: number; | ||||
|   periodo: string; | ||||
|   fechaEmision: string; | ||||
|   fechaVencimiento: string; | ||||
|   importeFinal: number; | ||||
|   estado: string; | ||||
|   totalPagado: number; | ||||
|   saldoPendiente: number; | ||||
|   estadoPago: string; | ||||
|   estadoFacturacion: string; | ||||
|   numeroFactura?: string | null; | ||||
|    | ||||
|   // Datos enriquecidos para la UI | ||||
|   nombreSuscriptor: string; | ||||
|   nombrePublicacion: string; | ||||
|   detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| import { type PromocionDto } from "./PromocionDto"; | ||||
|  | ||||
| export interface PromocionAsignadaDto extends PromocionDto { | ||||
|     vigenciaDesdeAsignacion: string; // "yyyy-MM-dd" | ||||
|     vigenciaHastaAsignacion?: string | null; | ||||
| } | ||||
| @@ -1,8 +1,10 @@ | ||||
| export interface PromocionDto { | ||||
|   idPromocion: number; | ||||
|   descripcion: string; | ||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; | ||||
|   valor: number; | ||||
|   tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia'; | ||||
|   valorEfecto: number; | ||||
|   tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes'; | ||||
|   valorCondicion?: number | null; | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   activa: boolean; | ||||
|   | ||||
| @@ -0,0 +1,27 @@ | ||||
| // DTO para el detalle de cada línea dentro de una factura (cada suscripción) | ||||
| export interface FacturaDetalleDto { | ||||
|     descripcion: string; | ||||
|     importeNeto: number; | ||||
| } | ||||
|  | ||||
| // DTO para cada factura individual (por empresa) dentro del resumen consolidado | ||||
| export interface FacturaConsolidadaDto { | ||||
|     idFactura: number; | ||||
|     nombreEmpresa: string; | ||||
|     importeFinal: number; | ||||
|     estadoPago: string; | ||||
|     estadoFacturacion: string; | ||||
|     numeroFactura?: string | null; | ||||
|     detalles: FacturaDetalleDto[]; | ||||
|     // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers | ||||
|     idSuscriptor: number;  | ||||
| } | ||||
|  | ||||
| // DTO principal que agrupa todo por suscriptor para la vista de consulta | ||||
| export interface ResumenCuentaSuscriptorDto { | ||||
|     idSuscriptor: number; | ||||
|     nombreSuscriptor: string; | ||||
|     saldoPendienteTotal: number; | ||||
|     importeTotal: number; | ||||
|     facturas: FacturaConsolidadaDto[]; | ||||
| } | ||||
| @@ -6,6 +6,6 @@ export interface SuscripcionDto { | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   estado: 'Activa' | 'Pausada' | 'Cancelada'; | ||||
|   diasEntrega: string; // "L,M,X,J,V,S,D" | ||||
|   diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom" | ||||
|   observaciones?: string | null; | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| export interface UpdateAjusteDto { | ||||
|   fechaAjuste: string; // "yyyy-MM-dd" | ||||
|   tipoAjuste: 'Credito' | 'Debito'; | ||||
|   monto: number; | ||||
|   motivo: string; | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| // Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx | ||||
|  | ||||
| import React, { useState } from 'react'; | ||||
| import { Box, Alert, Paper } from '@mui/material'; | ||||
| import reporteService from '../../services/Reportes/reportesService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad'; | ||||
|  | ||||
| const ReporteFacturasPublicidadPage: React.FC = () => { | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [apiError, setApiError] = useState<string | null>(null); | ||||
|      | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeVerReporte = isSuperAdmin || tienePermiso("RR010"); | ||||
|  | ||||
|     const handleGenerateReport = async (params: { anio: number; mes: number; }) => { | ||||
|         setLoading(true); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes); | ||||
|              | ||||
|             const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' })); | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = url; | ||||
|             link.setAttribute('download', fileName); | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             link.parentNode?.removeChild(link); | ||||
|             window.URL.revokeObjectURL(url); | ||||
|  | ||||
|         } catch (err: any) { | ||||
|             let message = 'Ocurrió un error al generar el reporte.'; | ||||
|             if (axios.isAxiosError(err) && err.response) { | ||||
|                 if (err.response.status === 404) { | ||||
|                     message = "No se encontraron datos para los parámetros seleccionados."; | ||||
|                 } else if (err.response.data instanceof Blob && err.response.data.type === "application/json") { | ||||
|                     const errorText = await err.response.data.text(); | ||||
|                     try { | ||||
|                         const errorJson = JSON.parse(errorText); | ||||
|                         message = errorJson.message || message; | ||||
|                     } catch {  | ||||
|                         message = errorText || message;  | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     if (!puedeVerReporte) { | ||||
|         return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}> | ||||
|             <Paper elevation={3} sx={{ borderRadius: '8px' }}> | ||||
|                  <SeleccionaReporteFacturasPublicidad | ||||
|                     onGenerarReporte={handleGenerateReport} | ||||
|                     isLoading={loading} | ||||
|                     apiErrorMessage={apiError} | ||||
|                 /> | ||||
|             </Paper> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default ReporteFacturasPublicidadPage; | ||||
| @@ -23,6 +23,7 @@ const allReportModules: { category: string; label: string; path: string }[] = [ | ||||
|   { category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' }, | ||||
|   { category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' }, | ||||
|   { category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' }, | ||||
|   { category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' }, | ||||
| ]; | ||||
|  | ||||
| const predefinedCategoryOrder = [ | ||||
| @@ -30,6 +31,7 @@ const predefinedCategoryOrder = [ | ||||
|   'Listados Distribución', | ||||
|   'Ctrl. Devoluciones', | ||||
|   'Novedades de Canillitas', | ||||
|   'Suscripciones', | ||||
|   'Existencia Papel', | ||||
|   'Movimientos Bobinas', | ||||
|   'Consumos Bobinas', | ||||
|   | ||||
| @@ -0,0 +1,81 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { | ||||
|     Box, Typography, Button, CircularProgress, Alert, | ||||
|     FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent | ||||
| } from '@mui/material'; | ||||
|  | ||||
| // --- Constantes para los selectores de fecha --- | ||||
| const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); | ||||
| const meses = [ | ||||
|     { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, | ||||
|     { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, | ||||
|     { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, | ||||
|     { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } | ||||
| ]; | ||||
|  | ||||
| interface SeleccionaReporteFacturasPublicidadProps { | ||||
|   onGenerarReporte: (params: { anio: number; mes: number; }) => Promise<void>; | ||||
|   isLoading?: boolean; | ||||
|   apiErrorMessage?: string | null; | ||||
| } | ||||
|  | ||||
| const SeleccionaReporteFacturasPublicidad: React.FC<SeleccionaReporteFacturasPublicidadProps> = ({ | ||||
|   onGenerarReporte, | ||||
|   isLoading, | ||||
|   apiErrorMessage | ||||
| }) => { | ||||
|   const [anio, setAnio] = useState<number>(new Date().getFullYear()); | ||||
|   const [mes, setMes] = useState<number>(new Date().getMonth() + 1); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!anio) errors.anio = 'Debe seleccionar un año.'; | ||||
|     if (!mes) errors.mes = 'Debe seleccionar un mes.'; | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleGenerar = () => { | ||||
|     if (!validate()) return; | ||||
|     onGenerarReporte({ anio, mes }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 300 }}> | ||||
|       <Typography variant="h6" gutterBottom> | ||||
|         Parámetros: Reporte de Facturas para Publicidad | ||||
|       </Typography> | ||||
|       <Typography variant="body2" color="text.secondary" sx={{mb: 2}}> | ||||
|         Seleccione el período para generar el reporte. | ||||
|         <br /> | ||||
|         Se incluirán todas las suscripciones pagadas que aún están pendientes de facturar. | ||||
|       </Typography> | ||||
|  | ||||
|       <Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}> | ||||
|         <FormControl fullWidth margin="normal" size="small" error={!!localErrors.mes} disabled={isLoading}> | ||||
|             <InputLabel>Mes</InputLabel> | ||||
|             <Select value={mes} label="Mes" onChange={(e: SelectChangeEvent<number>) => setMes(e.target.value as number)}> | ||||
|                 {meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)} | ||||
|             </Select> | ||||
|         </FormControl> | ||||
|         <FormControl fullWidth margin="normal" size="small" error={!!localErrors.anio} disabled={isLoading}> | ||||
|             <InputLabel>Año</InputLabel> | ||||
|             <Select value={anio} label="Año" onChange={(e: SelectChangeEvent<number>) => setAnio(e.target.value as number)}> | ||||
|                 {anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)} | ||||
|             </Select> | ||||
|         </FormControl> | ||||
|       </Box> | ||||
|  | ||||
|       {apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|       <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|         <Button onClick={handleGenerar} variant="contained" disabled={isLoading}> | ||||
|           {isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'} | ||||
|         </Button> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default SeleccionaReporteFacturasPublicidad; | ||||
							
								
								
									
										280
									
								
								Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,280 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText, Collapse, TextField } from '@mui/material'; | ||||
| import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||
| import PaymentIcon from '@mui/icons-material/Payment'; | ||||
| import EmailIcon from '@mui/icons-material/Email'; | ||||
| import EditNoteIcon from '@mui/icons-material/EditNote'; | ||||
| import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; | ||||
| import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; | ||||
| import facturacionService from '../../services/Suscripciones/facturacionService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { ResumenCuentaSuscriptorDto, FacturaConsolidadaDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; | ||||
| import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; | ||||
| import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
|  | ||||
| const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); | ||||
| const meses = [ | ||||
|     { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, | ||||
|     { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, | ||||
|     { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, | ||||
|     { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } | ||||
| ]; | ||||
|  | ||||
| const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada']; | ||||
| const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; | ||||
|  | ||||
| const SuscriptorRow: React.FC<{ | ||||
|     resumen: ResumenCuentaSuscriptorDto; | ||||
|     handleMenuOpen: (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => void; | ||||
| }> = ({ resumen, handleMenuOpen }) => { | ||||
|     const [open, setOpen] = useState(false); | ||||
|     return ( | ||||
|         <React.Fragment> | ||||
|             <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> | ||||
|                 <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> | ||||
|                 <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> | ||||
|                 <TableCell align="right"> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography> | ||||
|                 </TableCell> | ||||
|                 {/* La cabecera principal ya no tiene acciones */} | ||||
|                 <TableCell colSpan={5}></TableCell> | ||||
|             </TableRow> | ||||
|             <TableRow> | ||||
|                 <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}> | ||||
|                     <Collapse in={open} timeout="auto" unmountOnExit> | ||||
|                         <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | ||||
|                             <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> | ||||
|                             <Table size="small"> | ||||
|                                 <TableHead> | ||||
|                                     <TableRow> | ||||
|                                         <TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell> | ||||
|                                         <TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell> | ||||
|                                         <TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell> | ||||
|                                     </TableRow> | ||||
|                                 </TableHead> | ||||
|                                 <TableBody> | ||||
|                                     {resumen.facturas.map((factura) => ( | ||||
|                                         <TableRow key={factura.idFactura}> | ||||
|                                             <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> | ||||
|                                             <TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell> | ||||
|                                             <TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell> | ||||
|                                             <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> | ||||
|                                             <TableCell>{factura.numeroFactura || '-'}</TableCell> | ||||
|                                             <TableCell align="right"> | ||||
|                                                 {/* El menú de acciones vuelve a estar aquí, por factura */} | ||||
|                                                 <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> | ||||
|                                                     <MoreVertIcon /> | ||||
|                                                 </IconButton> | ||||
|                                             </TableCell> | ||||
|                                         </TableRow> | ||||
|                                     ))} | ||||
|                                 </TableBody> | ||||
|                             </Table> | ||||
|                         </Box> | ||||
|                     </Collapse> | ||||
|                 </TableCell> | ||||
|             </TableRow> | ||||
|         </React.Fragment> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| const ConsultaFacturasPage: React.FC = () => { | ||||
|     const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear()); | ||||
|     const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [apiMessage, setApiMessage] = useState<string | null>(null); | ||||
|     const [apiError, setApiError] = useState<string | null>(null); | ||||
|     const [resumenes, setResumenes] = useState<ResumenCuentaSuscriptorDto[]>([]); | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeConsultar = isSuperAdmin || tienePermiso("SU006"); | ||||
|     const puedeGestionarFactura = isSuperAdmin || tienePermiso("SU006"); | ||||
|     const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); | ||||
|     const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); | ||||
|     const [pagoModalOpen, setPagoModalOpen] = useState(false); | ||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); | ||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
|     const [filtroNombre, setFiltroNombre] = useState(''); | ||||
|     const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); | ||||
|     const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); | ||||
|  | ||||
|     const cargarResumenesDelPeriodo = useCallback(async () => { | ||||
|         if (!puedeConsultar) return; | ||||
|         setLoading(true); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const data = await facturacionService.getResumenesDeCuentaPorPeriodo( | ||||
|                 selectedAnio,  | ||||
|                 selectedMes, | ||||
|                 filtroNombre || undefined, | ||||
|                 filtroEstadoPago || undefined, | ||||
|                 filtroEstadoFacturacion || undefined | ||||
|             ); | ||||
|             setResumenes(data); | ||||
|         } catch (err) { | ||||
|             setResumenes([]); | ||||
|             setApiError("Error al cargar los resúmenes de cuenta del período."); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]);  | ||||
|  | ||||
|     useEffect(() => { | ||||
|         // Ejecutar la búsqueda cuando los filtros cambian | ||||
|         const timer = setTimeout(() => { | ||||
|             cargarResumenesDelPeriodo(); | ||||
|         }, 500); // Debounce para no buscar en cada tecla | ||||
|         return () => clearTimeout(timer); | ||||
|     }, [cargarResumenesDelPeriodo]); | ||||
|  | ||||
|     const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => { | ||||
|         setAnchorEl(event.currentTarget); | ||||
|         setSelectedFactura(factura); | ||||
|     }; | ||||
|  | ||||
|     const handleMenuClose = () => { setAnchorEl(null); }; | ||||
|     const handleOpenPagoModal = () => { setPagoModalOpen(true); handleMenuClose(); }; | ||||
|     const handleClosePagoModal = () => { setPagoModalOpen(false); setSelectedFactura(null); }; | ||||
|  | ||||
|     const handleSubmitPagoModal = async (data: CreatePagoDto) => { | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.registrarPagoManual(data); | ||||
|             setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); | ||||
|             cargarResumenesDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; | ||||
|             setApiError(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleUpdateNumeroFactura = async (factura: FacturaConsolidadaDto) => { | ||||
|         const nuevoNumero = prompt("Ingrese el número de factura (ARCA):", factura.numeroFactura || ""); | ||||
|         handleMenuClose(); | ||||
|         if (nuevoNumero !== null && nuevoNumero.trim() !== "") { | ||||
|             setApiError(null); | ||||
|             try { | ||||
|                 await facturacionService.actualizarNumeroFactura(factura.idFactura, nuevoNumero.trim()); | ||||
|                 setApiMessage(`Número de factura #${factura.idFactura} actualizado.`); | ||||
|                 cargarResumenesDelPeriodo(); | ||||
|             } catch (err: any) { | ||||
|                 setApiError(err.response?.data?.message || 'Error al actualizar el número de factura.'); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleSendEmail = async (idFactura: number) => { | ||||
|         if (!window.confirm(`¿Está seguro de enviar la factura #${idFactura} por email? Se adjuntará el PDF si se encuentra.`)) return; | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.enviarFacturaPdfPorEmail(idFactura); | ||||
|             setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`); | ||||
|         } catch (err: any) { | ||||
|             setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); | ||||
|         } finally { | ||||
|             handleMenuClose(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!puedeConsultar) return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Consulta de Facturas de Suscripciones</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Typography variant="h6">Filtros</Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, my: 2, alignItems: 'center' }}> | ||||
|                     <FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select></FormControl> | ||||
|                     <FormControl sx={{ minWidth: 120 }} size="small"><InputLabel>Año</InputLabel><Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl> | ||||
|                 <TextField  | ||||
|                         label="Buscar por Suscriptor"  | ||||
|                         size="small" | ||||
|                         value={filtroNombre} | ||||
|                         onChange={(e) => setFiltroNombre(e.target.value)} | ||||
|                         sx={{flexGrow: 1, minWidth: '200px'}} | ||||
|                     /> | ||||
|                     <FormControl sx={{ minWidth: 200 }} size="small"> | ||||
|                         <InputLabel>Estado de Pago</InputLabel> | ||||
|                         <Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}> | ||||
|                             <MenuItem value=""><em>Todos</em></MenuItem> | ||||
|                             {estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)} | ||||
|                         </Select> | ||||
|                     </FormControl> | ||||
|                     <FormControl sx={{ minWidth: 200 }} size="small"> | ||||
|                         <InputLabel>Estado de Facturación</InputLabel> | ||||
|                         <Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}> | ||||
|                              <MenuItem value=""><em>Todos</em></MenuItem> | ||||
|                              {estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)} | ||||
|                         </Select> | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|             </Paper> | ||||
|  | ||||
|             {apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>} | ||||
|             {apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table aria-label="collapsible table"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell /> | ||||
|                             <TableCell>Suscriptor</TableCell> | ||||
|                             <TableCell align="right">Saldo Total / Importe Total</TableCell> | ||||
|                             <TableCell colSpan={5}></TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) | ||||
|                             : resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||
|                                 : (resumenes.map(resumen => (<SuscriptorRow key={resumen.idSuscriptor} resumen={resumen} handleMenuOpen={handleMenuOpen} />)))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|  | ||||
|             {/* El menú de acciones ahora opera sobre la 'selectedFactura' */} | ||||
|             <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||
|                 {selectedFactura && puedeRegistrarPago && (<MenuItem onClick={handleOpenPagoModal} disabled={selectedFactura.estadoPago === 'Pagada' || selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon><ListItemText>Registrar Pago Manual</ListItemText></MenuItem>)} | ||||
|                 {selectedFactura && puedeGestionarFactura && (<MenuItem onClick={() => handleUpdateNumeroFactura(selectedFactura)} disabled={selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><EditNoteIcon fontSize="small" /></ListItemIcon><ListItemText>Cargar/Modificar Nro. Factura</ListItemText></MenuItem>)} | ||||
|                 {selectedFactura && puedeEnviarEmail && ( | ||||
|                     <MenuItem | ||||
|                         onClick={() => handleSendEmail(selectedFactura.idFactura)} | ||||
|                         disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}> | ||||
|                         <ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Enviar Factura (PDF)</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|             </Menu> | ||||
|  | ||||
|             <PagoManualModal | ||||
|                 open={pagoModalOpen} | ||||
|                 onClose={handleClosePagoModal} | ||||
|                 onSubmit={handleSubmitPagoModal} | ||||
|                 factura={ | ||||
|                     selectedFactura ? { | ||||
|                         idFactura: selectedFactura.idFactura, | ||||
|                         nombreSuscriptor: resumenes.find(r => r.idSuscriptor === selectedFactura.idSuscriptor)?.nombreSuscriptor || '', | ||||
|                         importeFinal: selectedFactura.importeFinal, | ||||
|                         // Calculamos el saldo pendiente aquí | ||||
|                         saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, // Simplificación | ||||
|                         // Rellenamos los campos restantes que el modal podría necesitar, aunque no los use. | ||||
|                         idSuscriptor: selectedFactura.idSuscriptor, // Corregido para coincidir con FacturaDto | ||||
|                         periodo: '', | ||||
|                         fechaEmision: '', | ||||
|                         fechaVencimiento: '', | ||||
|                         totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal), | ||||
|                         estadoPago: selectedFactura.estadoPago, | ||||
|                         estadoFacturacion: selectedFactura.estadoFacturacion, | ||||
|                         numeroFactura: selectedFactura.numeroFactura, | ||||
|                         detalles: selectedFactura.detalles, | ||||
|                     } : null | ||||
|                 } | ||||
|                 errorMessage={apiError} | ||||
|                 clearErrorMessage={() => setApiError(null)} /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default ConsultaFacturasPage; | ||||
| @@ -0,0 +1,241 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { useParams, useNavigate } from 'react-router-dom'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import CancelIcon from '@mui/icons-material/Cancel'; | ||||
| import ajusteService from '../../services/Suscripciones/ajusteService'; | ||||
| import suscriptorService from '../../services/Suscripciones/suscriptorService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; | ||||
| import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; | ||||
| import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto'; | ||||
| import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto'; | ||||
| import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal'; | ||||
|  | ||||
| const getInitialDateRange = () => { | ||||
|     const today = new Date(); | ||||
|     const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); | ||||
|     const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); | ||||
|     const formatDate = (date: Date) => date.toISOString().split('T')[0]; | ||||
|     return { | ||||
|         fechaDesde: formatDate(firstDay), | ||||
|         fechaHasta: formatDate(lastDay) | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| const CuentaCorrienteSuscriptorPage: React.FC = () => { | ||||
|     const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); | ||||
|     const navigate = useNavigate(); | ||||
|     const idSuscriptor = Number(idSuscriptorStr); | ||||
|  | ||||
|     const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null); | ||||
|     const [ajustes, setAjustes] = useState<AjusteDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [editingAjuste, setEditingAjuste] = useState<AjusteDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|     const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(getInitialDateRange().fechaDesde); | ||||
|     const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(getInitialDateRange().fechaHasta); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU011"); | ||||
|  | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         if (isNaN(idSuscriptor)) { | ||||
|             setError("ID de Suscriptor inválido."); setLoading(false); return; | ||||
|         } | ||||
|         setLoading(true); setApiErrorMessage(null); setError(null); | ||||
|         try { | ||||
|             const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor); | ||||
|             setSuscriptor(suscriptorData); | ||||
|  | ||||
|             const ajustesData = await ajusteService.getAjustesPorSuscriptor(idSuscriptor, filtroFechaDesde || undefined, filtroFechaHasta || undefined); | ||||
|             setAjustes(ajustesData); | ||||
|  | ||||
|         } catch (err) { | ||||
|             setError("Error al cargar los datos."); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [idSuscriptor, puedeGestionar, filtroFechaDesde, filtroFechaHasta]); | ||||
|  | ||||
|     useEffect(() => { cargarDatos(); }, [cargarDatos]); | ||||
|  | ||||
|     // --- INICIO DE LA LÓGICA DE SINCRONIZACIÓN DE FECHAS --- | ||||
|     const handleFechaDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const nuevaFechaDesde = e.target.value; | ||||
|         setFiltroFechaDesde(nuevaFechaDesde); | ||||
|         // Si la nueva fecha "desde" es posterior a la fecha "hasta", ajusta la fecha "hasta" | ||||
|         if (nuevaFechaDesde && filtroFechaHasta && new Date(nuevaFechaDesde) > new Date(filtroFechaHasta)) { | ||||
|             setFiltroFechaHasta(nuevaFechaDesde); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleFechaHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const nuevaFechaHasta = e.target.value; | ||||
|         setFiltroFechaHasta(nuevaFechaHasta); | ||||
|         // Si la nueva fecha "hasta" es anterior a la fecha "desde", ajusta la fecha "desde" | ||||
|         if (nuevaFechaHasta && filtroFechaDesde && new Date(nuevaFechaHasta) < new Date(filtroFechaDesde)) { | ||||
|             setFiltroFechaDesde(nuevaFechaHasta); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleOpenModal = (ajuste?: AjusteDto) => { | ||||
|         setEditingAjuste(ajuste || null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
|         setModalOpen(false); | ||||
|         setEditingAjuste(null); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitModal = async (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if (id && editingAjuste) { | ||||
|                 await ajusteService.updateAjuste(id, data as UpdateAjusteDto); | ||||
|             } else { | ||||
|                 await ajusteService.createAjusteManual(data as CreateAjusteDto); | ||||
|             } | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.'; | ||||
|             setApiErrorMessage(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleAnularAjuste = async (idAjuste: number) => { | ||||
|         if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) { | ||||
|             setApiErrorMessage(null); | ||||
|             try { | ||||
|                 await ajusteService.anularAjuste(idAjuste); | ||||
|                 cargarDatos(); | ||||
|             } catch (err: any) { | ||||
|                 setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste."); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const formatDisplayDate = (dateString: string): string => { | ||||
|         if (!dateString) return ''; | ||||
|         const datePart = dateString.split(' ')[0]; | ||||
|         const parts = datePart.split('-'); | ||||
|         if (parts.length === 3) { | ||||
|             return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||
|         } | ||||
|         return dateString; | ||||
|     }; | ||||
|  | ||||
|     if (loading && !suscriptor) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     if (error) return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}> | ||||
|                 Volver a Suscriptores | ||||
|             </Button> | ||||
|             <Typography variant="h5" gutterBottom>Cuenta Corriente de:</Typography> | ||||
|             <Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography> | ||||
|  | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}> | ||||
|                     <Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}> | ||||
|                         <TextField | ||||
|                             label="Fecha Desde" | ||||
|                             type="date" | ||||
|                             size="small" | ||||
|                             value={filtroFechaDesde} | ||||
|                             onChange={handleFechaDesdeChange} // <-- USAR NUEVO HANDLER | ||||
|                             InputLabelProps={{ shrink: true }} | ||||
|                         /> | ||||
|                         <TextField | ||||
|                             label="Fecha Hasta" | ||||
|                             type="date" | ||||
|                             size="small" | ||||
|                             value={filtroFechaHasta} | ||||
|                             onChange={handleFechaHastaChange} // <-- USAR NUEVO HANDLER | ||||
|                             InputLabelProps={{ shrink: true }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                     {puedeGestionar && ( | ||||
|                         <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mt: { xs: 2, sm: 0 } }}> | ||||
|                             Nuevo Ajuste | ||||
|                         </Button> | ||||
|                     )} | ||||
|                 </Box> | ||||
|             </Paper> | ||||
|  | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>Fecha Ajuste</TableCell> | ||||
|                             <TableCell>Tipo</TableCell> | ||||
|                             <TableCell>Motivo</TableCell> | ||||
|                             <TableCell align="right">Monto</TableCell> | ||||
|                             <TableCell>Estado</TableCell> | ||||
|                             <TableCell>Usuario Carga</TableCell> | ||||
|                             <TableCell align="right">Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {loading ? ( | ||||
|                             <TableRow><TableCell colSpan={7} align="center"><CircularProgress size={24} /></TableCell></TableRow> | ||||
|                         ) : ajustes.length === 0 ? ( | ||||
|                             <TableRow><TableCell colSpan={7} align="center">No se encontraron ajustes para los filtros seleccionados.</TableCell></TableRow> | ||||
|                         ) : ( | ||||
|                             ajustes.map(a => ( | ||||
|                                 <TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}> | ||||
|                                     <TableCell>{formatDisplayDate(a.fechaAjuste)}</TableCell> | ||||
|                                     <TableCell> | ||||
|                                         <Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} /> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell>{a.motivo}</TableCell> | ||||
|                                     <TableCell align="right">${a.monto.toFixed(2)}</TableCell> | ||||
|                                     <TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell> | ||||
|                                     <TableCell>{a.nombreUsuarioAlta}</TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         {a.estado === 'Pendiente' && puedeGestionar && ( | ||||
|                                             <> | ||||
|                                                 <Tooltip title="Editar Ajuste"> | ||||
|                                                     <IconButton onClick={() => handleOpenModal(a)} size="small"> | ||||
|                                                         <EditIcon /> | ||||
|                                                     </IconButton> | ||||
|                                                 </Tooltip> | ||||
|                                                 <Tooltip title="Anular Ajuste"> | ||||
|                                                     <IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small"> | ||||
|                                                         <CancelIcon color="error" /> | ||||
|                                                     </IconButton> | ||||
|                                                 </Tooltip> | ||||
|                                             </> | ||||
|                                         )} | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|                             )) | ||||
|                         )} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|  | ||||
|             <AjusteFormModal | ||||
|                 open={modalOpen} | ||||
|                 onClose={handleCloseModal} | ||||
|                 onSubmit={handleSubmitModal} | ||||
|                 idSuscriptor={idSuscriptor} | ||||
|                 initialData={editingAjuste} | ||||
|                 errorMessage={apiErrorMessage} | ||||
|                 clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default CuentaCorrienteSuscriptorPage; | ||||
| @@ -1,125 +0,0 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| import ajusteService from '../../services/Suscripciones/ajusteService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; | ||||
| import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; | ||||
| import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal'; | ||||
| import CancelIcon from '@mui/icons-material/Cancel'; | ||||
|  | ||||
| interface CuentaCorrienteSuscriptorTabProps { | ||||
|     idSuscriptor: number; | ||||
| } | ||||
|  | ||||
| const CuentaCorrienteSuscriptorTab: React.FC<CuentaCorrienteSuscriptorTabProps> = ({ idSuscriptor }) => { | ||||
|     const [ajustes, setAjustes] = useState<AjusteDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU011"); | ||||
|  | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         if (!puedeGestionar) { | ||||
|             setError("No tiene permiso para ver la cuenta corriente."); setLoading(false); return; | ||||
|         } | ||||
|         setLoading(true); setApiErrorMessage(null); | ||||
|         try { | ||||
|             const data = await ajusteService.getAjustesPorSuscriptor(idSuscriptor); | ||||
|             setAjustes(data); | ||||
|         } catch (err) { | ||||
|             setError("Error al cargar los ajustes del suscriptor."); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [idSuscriptor, puedeGestionar]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarDatos(); | ||||
|     }, [cargarDatos]); | ||||
|  | ||||
|     const handleSubmitModal = async (data: CreateAjusteDto) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             await ajusteService.createAjusteManual(data); | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.'; | ||||
|             setApiErrorMessage(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleAnularAjuste = async (idAjuste: number) => { | ||||
|         if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) { | ||||
|             setApiErrorMessage(null); | ||||
|             try { | ||||
|                 await ajusteService.anularAjuste(idAjuste); | ||||
|                 cargarDatos(); // Recargar para ver el cambio de estado | ||||
|             } catch (err: any) { | ||||
|                 setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste."); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||
|                 <Typography variant="h6">Historial de Ajustes</Typography> | ||||
|                 <Button variant="contained" startIcon={<AddIcon />} onClick={() => setModalOpen(true)} disabled={!puedeGestionar}> | ||||
|                     Nuevo Ajuste | ||||
|                 </Button> | ||||
|             </Paper> | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>} | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead><TableRow> | ||||
|                         <TableCell>Fecha</TableCell><TableCell>Tipo</TableCell><TableCell>Motivo</TableCell> | ||||
|                         <TableCell align="right">Monto</TableCell><TableCell>Estado</TableCell><TableCell>Usuario</TableCell> | ||||
|                         <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell> | ||||
|                     </TableRow></TableHead> | ||||
|                     <TableBody> | ||||
|                         {ajustes.map(a => ( | ||||
|                             <TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}> | ||||
|                                 <TableCell>{a.fechaAlta}</TableCell> | ||||
|                                 <TableCell> | ||||
|                                     <Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} /> | ||||
|                                 </TableCell> | ||||
|                                 <TableCell>{a.motivo}</TableCell> | ||||
|                                 <TableCell align="right">${a.monto.toFixed(2)}</TableCell> | ||||
|                                 <TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell> | ||||
|                                 <TableCell>{a.nombreUsuarioAlta}</TableCell> | ||||
|                                 <TableCell align="right"> | ||||
|                                     {a.estado === 'Pendiente' && puedeGestionar && ( | ||||
|                                         <Tooltip title="Anular Ajuste"> | ||||
|                                             <IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small"> | ||||
|                                                 <CancelIcon color="error" /> | ||||
|                                             </IconButton> | ||||
|                                         </Tooltip> | ||||
|                                     )} | ||||
|                                 </TableCell> | ||||
|                             </TableRow> | ||||
|                         ))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|             <AjusteFormModal | ||||
|                 open={modalOpen} | ||||
|                 onClose={() => setModalOpen(false)} | ||||
|                 onSubmit={handleSubmitModal} | ||||
|                 idSuscriptor={idSuscriptor} | ||||
|                 errorMessage={apiErrorMessage} | ||||
|                 clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default CuentaCorrienteSuscriptorTab; | ||||
| @@ -1,18 +1,12 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material'; | ||||
| import React, { useState } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material'; | ||||
| import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||
| import PaymentIcon from '@mui/icons-material/Payment'; | ||||
| import EmailIcon from '@mui/icons-material/Email'; | ||||
| import UploadFileIcon from '@mui/icons-material/UploadFile'; | ||||
| import { styled } from '@mui/material/styles'; | ||||
| import facturacionService from '../../services/Suscripciones/facturacionService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; | ||||
| import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
|  | ||||
| const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); | ||||
| const meses = [ | ||||
| @@ -35,37 +29,14 @@ const FacturacionPage: React.FC = () => { | ||||
|     const [loadingProceso, setLoadingProceso] = useState(false); | ||||
|     const [apiMessage, setApiMessage] = useState<string | null>(null); | ||||
|     const [apiError, setApiError] = useState<string | null>(null); | ||||
|     const [facturas, setFacturas] = useState<FacturaDto[]>([]); | ||||
|     const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006"); | ||||
|     const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007"); | ||||
|     const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); | ||||
|     const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); | ||||
|     const [pagoModalOpen, setPagoModalOpen] = useState(false); | ||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaDto | null>(null); | ||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
|     const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null); | ||||
|  | ||||
|     const cargarFacturasDelPeriodo = useCallback(async () => { | ||||
|         if (!puedeGenerarFacturacion) return; | ||||
|         setLoading(true); | ||||
|         try { | ||||
|             const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes); | ||||
|             setFacturas(data); | ||||
|         } catch (err) { | ||||
|             setFacturas([]); | ||||
|             console.error(err); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [selectedAnio, selectedMes, puedeGenerarFacturacion]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarFacturasDelPeriodo(); | ||||
|     }, [cargarFacturasDelPeriodo]); | ||||
|  | ||||
|     const handleGenerarFacturacion = async () => { | ||||
|         if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) { | ||||
|         if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) { | ||||
|             return; | ||||
|         } | ||||
|         setLoading(true); | ||||
| @@ -74,7 +45,6 @@ const FacturacionPage: React.FC = () => { | ||||
|         try { | ||||
|             const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); | ||||
|             setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`); | ||||
|             await cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message | ||||
|                 ? err.response.data.message | ||||
| @@ -103,7 +73,6 @@ const FacturacionPage: React.FC = () => { | ||||
|             link.parentNode?.removeChild(link); | ||||
|             window.URL.revokeObjectURL(url); | ||||
|             setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`); | ||||
|             cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             let message = 'Ocurrió un error al generar el archivo.'; | ||||
|             if (axios.isAxiosError(err) && err.response) { | ||||
| @@ -119,52 +88,6 @@ const FacturacionPage: React.FC = () => { | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaDto) => { | ||||
|         setAnchorEl(event.currentTarget); | ||||
|         setSelectedFactura(factura); | ||||
|     }; | ||||
|  | ||||
|     const handleMenuClose = () => { | ||||
|         setAnchorEl(null); | ||||
|         setSelectedFactura(null); | ||||
|     }; | ||||
|  | ||||
|     const handleOpenPagoModal = () => { | ||||
|         setPagoModalOpen(true); | ||||
|         handleMenuClose(); | ||||
|     }; | ||||
|  | ||||
|     const handleClosePagoModal = () => { | ||||
|         setPagoModalOpen(false); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitPagoModal = async (data: CreatePagoDto) => { | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.registrarPagoManual(data); | ||||
|             setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); | ||||
|             cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; | ||||
|             setApiError(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleSendEmail = async (idFactura: number) => { | ||||
|         if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return; | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.enviarFacturaPorEmail(idFactura); | ||||
|             setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`); | ||||
|         } catch (err: any) { | ||||
|             setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); | ||||
|         } finally { | ||||
|             handleMenuClose(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         if (event.target.files && event.target.files.length > 0) { | ||||
|             setArchivoSeleccionado(event.target.files[0]); | ||||
| @@ -187,7 +110,6 @@ const FacturacionPage: React.FC = () => { | ||||
|             if (response.errores?.length > 0) { | ||||
|                 setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`); | ||||
|             } | ||||
|             cargarFacturasDelPeriodo(); // Recargar para ver los estados finales | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen | ||||
|                 ? err.response.data.mensajeResumen | ||||
| @@ -203,15 +125,20 @@ const FacturacionPage: React.FC = () => { | ||||
|         return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!puedeGenerarFacturacion) { | ||||
|         return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Typography variant="h6">1. Generación de Facturación</Typography> | ||||
|             <Typography variant="h5" gutterBottom>Procesos Mensuales de Suscripciones</Typography> | ||||
|  | ||||
|             <Paper sx={{ p: 1, mb: 1 }}> | ||||
|                 <Typography variant="h6">1. Generación de Cierre Mensual</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                     Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado. | ||||
|                     Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor. | ||||
|                 </Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}> | ||||
|                     <FormControl sx={{ minWidth: 120 }} size="small"> | ||||
|                         <InputLabel>Mes</InputLabel> | ||||
|                         <Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select> | ||||
| @@ -221,20 +148,23 @@ const FacturacionPage: React.FC = () => { | ||||
|                         <Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select> | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|                 <Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo}>Generar Facturación del Período</Button> | ||||
|                 <Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo || loadingProceso}> | ||||
|                     Generar Cierre del Período | ||||
|                 </Button> | ||||
|             </Paper> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|  | ||||
|             <Paper sx={{ p: 1, mb: 1 }}> | ||||
|                 <Typography variant="h6">2. Generación de Archivo para Banco</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography> | ||||
|                 <Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button> | ||||
|             </Paper> | ||||
|  | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|             <Paper sx={{ p: 1, mb: 1 }}> | ||||
|                 <Typography variant="h6">3. Procesar Respuesta del Banco</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | ||||
|                     Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada". | ||||
|                 </Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}> | ||||
|                     <Button | ||||
|                         component="label" | ||||
|                         role={undefined} | ||||
| @@ -262,58 +192,8 @@ const FacturacionPage: React.FC = () => { | ||||
|                 )} | ||||
|             </Paper> | ||||
|  | ||||
|             {apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>} | ||||
|             {apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>} | ||||
|  | ||||
|             <Typography variant="h6" sx={{ mt: 4 }}>Facturas del Período</Typography> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>ID</TableCell><TableCell>Suscriptor</TableCell><TableCell>Publicación</TableCell> | ||||
|                             <TableCell align="right">Importe</TableCell><TableCell>Estado</TableCell><TableCell>Nro. Factura</TableCell> | ||||
|                             <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {loading ? (<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>) | ||||
|                             : facturas.length === 0 ? (<TableRow><TableCell colSpan={7} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||
|                                 : (facturas.map(f => ( | ||||
|                                     <TableRow key={f.idFactura} hover> | ||||
|                                         <TableCell>{f.idFactura}</TableCell> | ||||
|                                         <TableCell>{f.nombreSuscriptor}</TableCell> | ||||
|                                         <TableCell>{f.nombrePublicacion}</TableCell> | ||||
|                                         <TableCell align="right">${f.importeFinal.toFixed(2)}</TableCell> | ||||
|                                         <TableCell><Chip label={f.estado} size="small" color={f.estado === 'Pagada' ? 'success' : (f.estado === 'Rechazada' ? 'error' : 'default')} /></TableCell> | ||||
|                                         <TableCell>{f.numeroFactura || '-'}</TableCell> | ||||
|                                         <TableCell align="right"> | ||||
|                                             <IconButton onClick={(e) => handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}> | ||||
|                                                 <MoreVertIcon /> | ||||
|                                             </IconButton> | ||||
|                                         </TableCell> | ||||
|                                     </TableRow> | ||||
|                                 )))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|             <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||
|                 {selectedFactura && puedeRegistrarPago && ( | ||||
|                     <MenuItem onClick={handleOpenPagoModal}> | ||||
|                         <ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Registrar Pago Manual</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|                 {selectedFactura && puedeEnviarEmail && ( | ||||
|                     <MenuItem | ||||
|                         onClick={() => handleSendEmail(selectedFactura.idFactura)} | ||||
|                         disabled={!selectedFactura.numeroFactura} | ||||
|                     > | ||||
|                         <ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Enviar Email</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|             </Menu> | ||||
|             <PagoManualModal open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} factura={selectedFactura} errorMessage={apiError} clearErrorMessage={() => setApiError(null)} /> | ||||
|             {apiError && <Alert severity="error" sx={{ my: 1 }}>{apiError}</Alert>} | ||||
|             {apiMessage && <Alert severity="success" sx={{ my: 1 }}>{apiMessage}</Alert>} | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
| @@ -79,10 +79,13 @@ const GestionarPromocionesPage: React.FC = () => { | ||||
|         return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||
|     }; | ||||
|  | ||||
|     const formatTipo = (tipo: string) => { | ||||
|         if (tipo === 'MontoFijo') return 'Monto Fijo'; | ||||
|         if (tipo === 'Porcentaje') return 'Porcentaje'; | ||||
|         return tipo; | ||||
|     const formatTipo = (tipo: PromocionDto['tipoEfecto']) => { | ||||
|         switch(tipo) { | ||||
|             case 'DescuentoMontoFijoTotal': return 'Monto Fijo'; | ||||
|             case 'DescuentoPorcentajeTotal': return 'Porcentaje'; | ||||
|             case 'BonificarEntregaDia': return 'Día Bonificado'; | ||||
|             default: return tipo; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
| @@ -106,7 +109,7 @@ const GestionarPromocionesPage: React.FC = () => { | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Tipo</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Efecto</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> | ||||
| @@ -121,8 +124,12 @@ const GestionarPromocionesPage: React.FC = () => { | ||||
|                             promociones.map(p => ( | ||||
|                                 <TableRow key={p.idPromocion} hover> | ||||
|                                     <TableCell>{p.descripcion}</TableCell> | ||||
|                                     <TableCell>{formatTipo(p.tipoPromocion)}</TableCell> | ||||
|                                     <TableCell align="right">{p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`}</TableCell> | ||||
|                                     <TableCell>{formatTipo(p.tipoEfecto)}</TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         {p.tipoEfecto === 'DescuentoPorcentajeTotal' ? `${p.valorEfecto}%` :  | ||||
|                                          p.tipoEfecto === 'DescuentoMontoFijoTotal' ? `$${p.valorEfecto.toFixed(2)}` :  | ||||
|                                          '-'} | ||||
|                                     </TableCell> | ||||
|                                     <TableCell>{formatDate(p.fechaInicio)}</TableCell> | ||||
|                                     <TableCell>{formatDate(p.fechaFin)}</TableCell> | ||||
|                                     <TableCell align="center"> | ||||
| @@ -130,9 +137,11 @@ const GestionarPromocionesPage: React.FC = () => { | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         <Tooltip title="Editar Promoción"> | ||||
|                                             <IconButton onClick={() => handleOpenModal(p)}> | ||||
|                                                 <EditIcon /> | ||||
|                                             </IconButton> | ||||
|                                             <span> | ||||
|                                                 <IconButton onClick={() => handleOpenModal(p)} disabled={!puedeGestionar}> | ||||
|                                                     <EditIcon /> | ||||
|                                                 </IconButton> | ||||
|                                             </span> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|   | ||||
| @@ -1,12 +1,16 @@ | ||||
| // Archivo: Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx
 | ||||
| // Archivo: Frontend/src/pages/Suscripciones/GestionarSuscripcionesDeClientePage.tsx
 | ||||
| 
 | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { useParams, useNavigate } from 'react-router-dom'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import LoyaltyIcon from '@mui/icons-material/Loyalty'; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import suscripcionService from '../../services/Suscripciones/suscripcionService'; | ||||
| import suscriptorService from '../../services/Suscripciones/suscriptorService'; | ||||
| import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto'; | ||||
| import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto'; | ||||
| import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; | ||||
| import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; | ||||
| import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal'; | ||||
| @@ -14,11 +18,12 @@ import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscri | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| 
 | ||||
| interface SuscripcionesTabProps { | ||||
|     idSuscriptor: number; | ||||
| } | ||||
| const GestionarSuscripcionesDeClientePage: React.FC = () => { | ||||
|     const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); | ||||
|     const navigate = useNavigate(); | ||||
|     const idSuscriptor = Number(idSuscriptorStr); | ||||
| 
 | ||||
| const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => { | ||||
|     const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null); | ||||
|     const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
| @@ -32,28 +37,32 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU005"); | ||||
| 
 | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         setLoading(true); | ||||
|         setApiErrorMessage(null); | ||||
|         if (isNaN(idSuscriptor)) { | ||||
|             setError("ID de Suscriptor inválido."); setLoading(false); return; | ||||
|         } | ||||
|         setLoading(true); setApiErrorMessage(null); setError(null); | ||||
|         try { | ||||
|             const data = await suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor); | ||||
|             setSuscripciones(data); | ||||
|             const [suscriptorData, suscripcionesData] = await Promise.all([ | ||||
|                 suscriptorService.getSuscriptorById(idSuscriptor), | ||||
|                 suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor) | ||||
|             ]); | ||||
|             setSuscriptor(suscriptorData); | ||||
|             setSuscripciones(suscripcionesData); | ||||
|         } catch (err) { | ||||
|             setError('Error al cargar las suscripciones del cliente.'); | ||||
|             setError('Error al cargar los datos.'); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [idSuscriptor]); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         cargarDatos(); | ||||
|     }, [cargarDatos]); | ||||
|     useEffect(() => { cargarDatos(); }, [cargarDatos]); | ||||
| 
 | ||||
|     const handleOpenModal = (suscripcion?: SuscripcionDto) => { | ||||
|         setEditingSuscripcion(suscripcion || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|      | ||||
| 
 | ||||
|     const handleCloseModal = () => { | ||||
|         setModalOpen(false); | ||||
|         setEditingSuscripcion(null); | ||||
| @@ -86,13 +95,18 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => | ||||
|         return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||
|     }; | ||||
| 
 | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>; | ||||
| 
 | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||
|                 <Typography variant="h6">Suscripciones Contratadas</Typography> | ||||
|             <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}> | ||||
|                 Volver a Suscriptores | ||||
|             </Button> | ||||
|             <Typography variant="h5" gutterBottom>Gestionar Suscripciones de:</Typography> | ||||
|             <Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography> | ||||
|              | ||||
|             <Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}> | ||||
|                 {puedeGestionar && ( | ||||
|                     <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}> | ||||
|                         Nueva Suscripción | ||||
| @@ -169,4 +183,4 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default SuscripcionesTab; | ||||
| export default GestionarSuscripcionesDeClientePage; | ||||
| @@ -1,83 +0,0 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { useParams, useNavigate } from 'react-router-dom'; | ||||
| import { Box, Typography, Button, CircularProgress, Alert, Tabs, Tab } from '@mui/material'; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import suscriptorService from '../../services/Suscripciones/suscriptorService'; | ||||
| import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import SuscripcionesTab from './SuscripcionesTab'; | ||||
| import CuentaCorrienteSuscriptorTab from './CuentaCorrienteSuscriptorTab'; | ||||
|  | ||||
| const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); | ||||
|     const navigate = useNavigate(); | ||||
|     const idSuscriptor = Number(idSuscriptorStr); | ||||
|  | ||||
|     const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [tabValue, setTabValue] = useState(0); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeVer = isSuperAdmin || tienePermiso("SU001"); | ||||
|  | ||||
|     const cargarSuscriptor = useCallback(async () => { | ||||
|         if (isNaN(idSuscriptor)) { | ||||
|             setError("ID de Suscriptor inválido."); setLoading(false); return; | ||||
|         } | ||||
|         if (!puedeVer) { | ||||
|             setError("No tiene permiso para ver esta sección."); setLoading(false); return; | ||||
|         } | ||||
|         setLoading(true); | ||||
|         try { | ||||
|             const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor); | ||||
|             setSuscriptor(suscriptorData); | ||||
|         } catch (err) { | ||||
|             setError('Error al cargar los datos del suscriptor.'); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [idSuscriptor, puedeVer]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarSuscriptor(); | ||||
|     }, [cargarSuscriptor]); | ||||
|      | ||||
|     const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { | ||||
|         setTabValue(newValue); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>; | ||||
|     if (!puedeVer) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 2 }}> | ||||
|             <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}> | ||||
|                 Volver a Suscriptores | ||||
|             </Button> | ||||
|             <Typography variant="h4" gutterBottom>{suscriptor?.nombreCompleto || 'Cargando...'}</Typography> | ||||
|             <Typography variant="subtitle1" color="text.secondary" gutterBottom> | ||||
|                 Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion} | ||||
|             </Typography> | ||||
|  | ||||
|             <Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 3 }}> | ||||
|                 <Tabs value={tabValue} onChange={handleTabChange}> | ||||
|                     <Tab label="Suscripciones" /> | ||||
|                     <Tab label="Cuenta Corriente / Ajustes" /> | ||||
|                 </Tabs> | ||||
|             </Box> | ||||
|  | ||||
|             <Box sx={{ pt: 2 }}> | ||||
|                 {tabValue === 0 && (                     | ||||
|                     <SuscripcionesTab idSuscriptor={idSuscriptor} /> | ||||
|                 )} | ||||
|                 {tabValue === 1 && ( | ||||
|                     <CuentaCorrienteSuscriptorTab idSuscriptor={idSuscriptor} /> | ||||
|                 )} | ||||
|             </Box> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default GestionarSuscripcionesSuscriptorPage; | ||||
| @@ -1,5 +1,3 @@ | ||||
| // Archivo: Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx | ||||
|  | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, CircularProgress, Alert, Chip, ListItemIcon, ListItemText, FormControlLabel } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| @@ -16,188 +14,220 @@ import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import ArticleIcon from '@mui/icons-material/Article'; | ||||
| import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; | ||||
|  | ||||
| const GestionarSuscriptoresPage: React.FC = () => { | ||||
|   const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [filtroNombre, setFiltroNombre] = useState(''); | ||||
|   const [filtroNroDoc, setFiltroNroDoc] = useState(''); | ||||
|   const [filtroSoloActivos, setFiltroSoloActivos] = useState(true); | ||||
|   const [modalOpen, setModalOpen] = useState(false); | ||||
|   const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null); | ||||
|   const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|   const [page, setPage] = useState(0); | ||||
|   const [rowsPerPage, setRowsPerPage] = useState(15); | ||||
|   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
|   const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null); | ||||
|     const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [filtroNombre, setFiltroNombre] = useState(''); | ||||
|     const [filtroNroDoc, setFiltroNroDoc] = useState(''); | ||||
|     const [filtroSoloActivos, setFiltroSoloActivos] = useState(true); | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|     const [page, setPage] = useState(0); | ||||
|     const [rowsPerPage, setRowsPerPage] = useState(15); | ||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
|     const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null); | ||||
|  | ||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const navigate = useNavigate(); | ||||
|  | ||||
|   const puedeVer = isSuperAdmin || tienePermiso("SU001"); | ||||
|   const puedeCrear = isSuperAdmin || tienePermiso("SU002"); | ||||
|   const puedeModificar = isSuperAdmin || tienePermiso("SU003"); | ||||
|   const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004"); | ||||
|     const puedeVer = isSuperAdmin || tienePermiso("SU001"); | ||||
|     const puedeCrear = isSuperAdmin || tienePermiso("SU002"); | ||||
|     const puedeModificar = isSuperAdmin || tienePermiso("SU003"); | ||||
|     const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004"); | ||||
|     const puedeVerSuscripciones = isSuperAdmin || tienePermiso("SU005"); | ||||
|     const puedeVerCuentaCorriente = isSuperAdmin || tienePermiso("SU011"); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const cargarSuscriptores = useCallback(async () => { | ||||
|     if (!puedeVer) { | ||||
|       setError("No tiene permiso para ver esta sección."); | ||||
|       setLoading(false); | ||||
|       return; | ||||
|     } | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|     setApiErrorMessage(null); | ||||
|     try { | ||||
|       const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos); | ||||
|       setSuscriptores(data); | ||||
|     } catch (err) { | ||||
|       console.error(err); | ||||
|       setError('Error al cargar los suscriptores.'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     cargarSuscriptores(); | ||||
|   }, [cargarSuscriptores]); | ||||
|  | ||||
|   const handleOpenModal = (suscriptor?: SuscriptorDto) => { | ||||
|     setEditingSuscriptor(suscriptor || null); | ||||
|     setApiErrorMessage(null); | ||||
|     setModalOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleCloseModal = () => { | ||||
|     setModalOpen(false); | ||||
|     setEditingSuscriptor(null); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => { | ||||
|     setApiErrorMessage(null); | ||||
|     try { | ||||
|       if (id && editingSuscriptor) { | ||||
|         await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto); | ||||
|       } else { | ||||
|         await suscriptorService.createSuscriptor(data as CreateSuscriptorDto); | ||||
|       } | ||||
|       cargarSuscriptores(); | ||||
|     } catch (err: any) { | ||||
|       const message = axios.isAxiosError(err) && err.response?.data?.message | ||||
|         ? err.response.data.message | ||||
|         : 'Error al guardar el suscriptor.'; | ||||
|       setApiErrorMessage(message); | ||||
|       throw err; // Re-lanzar para que el modal sepa que falló | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleToggleActivo = async (suscriptor: SuscriptorDto) => { | ||||
|     const action = suscriptor.activo ? 'desactivar' : 'activar'; | ||||
|     if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) { | ||||
|       setApiErrorMessage(null); | ||||
|       try { | ||||
|         if (suscriptor.activo) { | ||||
|           await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor); | ||||
|         } else { | ||||
|           await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor); | ||||
|     const cargarSuscriptores = useCallback(async () => { | ||||
|         if (!puedeVer) { | ||||
|             setError("No tiene permiso para ver esta sección."); | ||||
|             setLoading(false); | ||||
|             return; | ||||
|         } | ||||
|         setLoading(true); | ||||
|         setError(null); | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos); | ||||
|             setSuscriptores(data); | ||||
|         } catch (err) { | ||||
|             console.error(err); | ||||
|             setError('Error al cargar los suscriptores.'); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarSuscriptores(); | ||||
|       } catch (err: any) { | ||||
|         const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`; | ||||
|         setApiErrorMessage(message); | ||||
|       } | ||||
|     }, [cargarSuscriptores]); | ||||
|  | ||||
|     const handleOpenModal = (suscriptor?: SuscriptorDto) => { | ||||
|         setEditingSuscriptor(suscriptor || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
|         setModalOpen(false); | ||||
|         setEditingSuscriptor(null); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if (id && editingSuscriptor) { | ||||
|                 await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto); | ||||
|             } else { | ||||
|                 await suscriptorService.createSuscriptor(data as CreateSuscriptorDto); | ||||
|             } | ||||
|             cargarSuscriptores(); | ||||
|         } catch (err: any) { | ||||
|             let message = 'Error al guardar el suscriptor.'; | ||||
|             if (axios.isAxiosError(err) && err.response?.data?.errors) { | ||||
|                 const validationErrors = err.response.data.errors; | ||||
|                 const errorMessages = Object.values(validationErrors).flat(); | ||||
|                 message = errorMessages.join(' '); | ||||
|             } else if (axios.isAxiosError(err) && err.response?.data?.message) { | ||||
|                 message = err.response.data.message; | ||||
|             } | ||||
|             setApiErrorMessage(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleToggleActivo = async (suscriptor: SuscriptorDto) => { | ||||
|         const action = suscriptor.activo ? 'desactivar' : 'activar'; | ||||
|         if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) { | ||||
|             setApiErrorMessage(null); | ||||
|             try { | ||||
|                 if (suscriptor.activo) { | ||||
|                     await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor); | ||||
|                 } else { | ||||
|                     await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor); | ||||
|                 } | ||||
|                 cargarSuscriptores(); | ||||
|             } catch (err: any) { | ||||
|                 const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`; | ||||
|                 setApiErrorMessage(message); | ||||
|             } | ||||
|         } | ||||
|         handleMenuClose(); | ||||
|     }; | ||||
|  | ||||
|     const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => { | ||||
|         setAnchorEl(event.currentTarget); | ||||
|         setSelectedRow(suscriptor); | ||||
|     }; | ||||
|  | ||||
|     const handleMenuClose = () => { | ||||
|         setAnchorEl(null); | ||||
|         setSelectedRow(null); | ||||
|     }; | ||||
|  | ||||
|     // --- INICIO DE LA CORRECCIÓN CLAVE --- | ||||
|     const handleNavigateToSuscripciones = (idSuscriptor: number) => { | ||||
|         // La ruta debe ser la ruta completa y final que renderiza el componente | ||||
|         navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`); | ||||
|         handleMenuClose(); | ||||
|     }; | ||||
|     // --- FIN DE LA CORRECCIÓN CLAVE --- | ||||
|  | ||||
|     const handleNavigateToCuentaCorriente = (idSuscriptor: number) => { | ||||
|         navigate(`/suscripciones/suscriptor/${idSuscriptor}/cuenta-corriente`); | ||||
|         handleMenuClose(); | ||||
|     }; | ||||
|  | ||||
|     const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); | ||||
|     const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         setRowsPerPage(parseInt(event.target.value, 10)); | ||||
|         setPage(0); | ||||
|     }; | ||||
|  | ||||
|     const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||
|  | ||||
|     if (!puedeVer) { | ||||
|         return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>; | ||||
|     } | ||||
|     handleMenuClose(); | ||||
|   }; | ||||
|  | ||||
|   const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => { | ||||
|     setAnchorEl(event.currentTarget); | ||||
|     setSelectedRow(suscriptor); | ||||
|   }; | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}> | ||||
|                     <TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} /> | ||||
|                     <TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} /> | ||||
|                     <FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" /> | ||||
|                 </Box> | ||||
|                 {puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>} | ||||
|             </Paper> | ||||
|  | ||||
|   const handleMenuClose = () => { | ||||
|     setAnchorEl(null); | ||||
|     setSelectedRow(null); | ||||
|   }; | ||||
|             {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||
|             {error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|   const handleNavigateToSuscripciones = (idSuscriptor: number) => { | ||||
|     navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`); | ||||
|     handleMenuClose(); | ||||
|   }; | ||||
|             {!loading && !error && ( | ||||
|                 <> | ||||
|                     <TableContainer component={Paper}> | ||||
|                         <Table size="small"> | ||||
|                             <TableHead><TableRow> | ||||
|                                 <TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell> | ||||
|                                 <TableCell>Forma de Pago</TableCell><TableCell>Mail</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell> | ||||
|                             </TableRow></TableHead> | ||||
|                             <TableBody> | ||||
|                                 {displayData.map((s) => ( | ||||
|                                     <TableRow key={s.idSuscriptor} hover> | ||||
|                                         <TableCell>{s.nombreCompleto}</TableCell> | ||||
|                                         <TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell> | ||||
|                                         <TableCell>{s.direccion}</TableCell> | ||||
|                                         <TableCell>{s.nombreFormaPagoPreferida}</TableCell> | ||||
|                                         <TableCell>{s.email}</TableCell> | ||||
|                                         <TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell> | ||||
|                                         <TableCell align="right"> | ||||
|                                             <IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton> | ||||
|                                         </TableCell> | ||||
|                                     </TableRow> | ||||
|                                 ))} | ||||
|                             </TableBody> | ||||
|                         </Table> | ||||
|                     </TableContainer> | ||||
|                     <TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} /> | ||||
|                 </> | ||||
|             )} | ||||
|  | ||||
|   const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); | ||||
|   const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     setRowsPerPage(parseInt(event.target.value, 10)); | ||||
|     setPage(0); | ||||
|   }; | ||||
|             <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||
|                 {selectedRow && puedeModificar && ( | ||||
|                     <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}> | ||||
|                         <ListItemIcon><EditIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Editar Datos</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|                 {selectedRow && puedeVerSuscripciones && ( | ||||
|                     <MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}> | ||||
|                         <ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Ver Suscripciones</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|                 {selectedRow && puedeVerCuentaCorriente && ( | ||||
|                     <MenuItem onClick={() => handleNavigateToCuentaCorriente(selectedRow.idSuscriptor)}> | ||||
|                         <ListItemIcon><AccountBalanceWalletIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Ver Cuenta Corriente</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|                 {selectedRow && puedeActivarDesactivar && ( | ||||
|                     <MenuItem onClick={() => handleToggleActivo(selectedRow)}> | ||||
|                         {selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>} | ||||
|                         <ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|             </Menu> | ||||
|  | ||||
|   const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | ||||
|  | ||||
|   if (!puedeVer) { | ||||
|     return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ p: 1 }}> | ||||
|       <Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography> | ||||
|       <Paper sx={{ p: 2, mb: 2 }}> | ||||
|         <Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}> | ||||
|           <TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} /> | ||||
|           <TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} /> | ||||
|           <FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" /> | ||||
|             <SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} /> | ||||
|         </Box> | ||||
|         {puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>} | ||||
|       </Paper> | ||||
|  | ||||
|       {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} | ||||
|       {error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||
|       {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|       {!loading && !error && ( | ||||
|         <> | ||||
|           <TableContainer component={Paper}> | ||||
|             <Table size="small"> | ||||
|               <TableHead><TableRow> | ||||
|                 <TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell> | ||||
|                 <TableCell>Forma de Pago</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell> | ||||
|               </TableRow></TableHead> | ||||
|               <TableBody> | ||||
|                 {displayData.map((s) => ( | ||||
|                   <TableRow key={s.idSuscriptor} hover> | ||||
|                     <TableCell>{s.nombreCompleto}</TableCell> | ||||
|                     <TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell> | ||||
|                     <TableCell>{s.direccion}</TableCell> | ||||
|                     <TableCell>{s.nombreFormaPagoPreferida}</TableCell> | ||||
|                     <TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell> | ||||
|                     <TableCell align="right"> | ||||
|                       <IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton> | ||||
|                     </TableCell> | ||||
|                   </TableRow> | ||||
|                 ))} | ||||
|               </TableBody> | ||||
|             </Table> | ||||
|           </TableContainer> | ||||
|           <TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} /> | ||||
|         </> | ||||
|       )} | ||||
|  | ||||
|       <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||
|         {selectedRow && puedeModificar && <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><ListItemIcon><EditIcon fontSize="small" /></ListItemIcon><ListItemText>Editar</ListItemText></MenuItem>} | ||||
|         {selectedRow && <MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}><ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon><ListItemText>Ver Suscripciones</ListItemText></MenuItem>} | ||||
|         {selectedRow && puedeActivarDesactivar && ( | ||||
|           <MenuItem onClick={() => handleToggleActivo(selectedRow)}> | ||||
|             {selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>} | ||||
|             <ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText> | ||||
|           </MenuItem> | ||||
|         )} | ||||
|       </Menu> | ||||
|  | ||||
|       <SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} /> | ||||
|     </Box> | ||||
|   ); | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default GestionarSuscriptoresPage; | ||||
| @@ -3,80 +3,76 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; | ||||
| import { Outlet, useNavigate, useLocation } from 'react-router-dom'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
|  | ||||
| // Define las pestañas del módulo. Ajusta los permisos según sea necesario. | ||||
| const suscripcionesSubModules = [ | ||||
|   { label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' }, | ||||
|   { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' }, | ||||
|   { label: 'Consulta Pagos y Facturas', path: 'consulta-facturas', requiredPermission: 'SU006' }, | ||||
|   { label: 'Cierre y Procesos', path: 'procesos', requiredPermission: 'SU006' }, | ||||
|   { label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' }, | ||||
| ]; | ||||
|  | ||||
| const SuscripcionesIndexPage: React.FC = () => { | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|   const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|   const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false); | ||||
|  | ||||
|   // Filtra los sub-módulos a los que el usuario tiene acceso | ||||
|   const accessibleSubModules = suscripcionesSubModules.filter( | ||||
|     (subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission) | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (accessibleSubModules.length === 0) { | ||||
|         // Si no tiene acceso a ningún submódulo, no hacemos nada. | ||||
|         // El enrutador principal debería manejar esto. | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const currentBasePath = '/suscripciones'; | ||||
|     const subPath = location.pathname.startsWith(`${currentBasePath}/`) | ||||
|       ? location.pathname.substring(currentBasePath.length + 1) | ||||
|       : (location.pathname === currentBasePath ? accessibleSubModules[0]?.path : undefined); | ||||
|  | ||||
|     const activeTabIndex = accessibleSubModules.findIndex( | ||||
|       (subModule) => subModule.path === subPath | ||||
|     ); | ||||
|  | ||||
|     if (activeTabIndex !== -1) { | ||||
|       setSelectedSubTab(activeTabIndex); | ||||
|     } else if (location.pathname === currentBasePath) { | ||||
|       navigate(accessibleSubModules[0].path, { replace: true }); | ||||
|     } else { | ||||
|       setSelectedSubTab(false); | ||||
|     } | ||||
|   }, [location.pathname, navigate, accessibleSubModules]); | ||||
|     const navigate = useNavigate(); | ||||
|     const location = useLocation(); | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false); | ||||
|    | ||||
|   const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { | ||||
|     navigate(accessibleSubModules[newValue].path); | ||||
|   }; | ||||
|     const accessibleSubModules = suscripcionesSubModules.filter( | ||||
|       (subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission) | ||||
|     ); | ||||
|    | ||||
|     useEffect(() => { | ||||
|         if (accessibleSubModules.length === 0) return; | ||||
|      | ||||
|         const currentPath = location.pathname; | ||||
|         const basePath = '/suscripciones'; | ||||
|          | ||||
|         // Encuentra la pestaña que mejor coincide con la ruta actual | ||||
|         const activeTabIndex = accessibleSubModules.findIndex(subModule =>  | ||||
|             currentPath.startsWith(`${basePath}/${subModule.path}`) | ||||
|         ); | ||||
|  | ||||
|   if (accessibleSubModules.length === 0) { | ||||
|     return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography> | ||||
|       <Paper square elevation={1}> | ||||
|         <Tabs | ||||
|           value={selectedSubTab} | ||||
|           onChange={handleSubTabChange} | ||||
|           indicatorColor="primary" | ||||
|           textColor="primary" | ||||
|           variant="scrollable" | ||||
|           scrollButtons="auto" | ||||
|           aria-label="sub-módulos de suscripciones" | ||||
|         > | ||||
|           {accessibleSubModules.map((subModule) => ( | ||||
|             <Tab key={subModule.path} label={subModule.label} /> | ||||
|           ))} | ||||
|         </Tabs> | ||||
|       </Paper> | ||||
|       <Box sx={{ pt: 2 }}> | ||||
|         <Outlet /> | ||||
|         if (activeTabIndex !== -1) { | ||||
|             setSelectedSubTab(activeTabIndex); | ||||
|         } else if (currentPath === basePath && accessibleSubModules.length > 0) { | ||||
|             // Si estamos en la raíz del módulo, redirigir a la primera pestaña accesible | ||||
|             navigate(accessibleSubModules[0].path, { replace: true }); | ||||
|         } else { | ||||
|             setSelectedSubTab(false); // Ninguna pestaña activa | ||||
|         } | ||||
|     }, [location.pathname, navigate, accessibleSubModules]); | ||||
|      | ||||
|     const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => { | ||||
|       navigate(accessibleSubModules[newValue].path); | ||||
|     }; | ||||
|    | ||||
|     if (accessibleSubModules.length === 0) { | ||||
|       return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>; | ||||
|     } | ||||
|    | ||||
|     return ( | ||||
|       <Box> | ||||
|         <Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography> | ||||
|         <Paper square elevation={1}> | ||||
|           <Tabs | ||||
|             value={selectedSubTab} | ||||
|             onChange={handleSubTabChange} | ||||
|             indicatorColor="primary" | ||||
|             textColor="primary" | ||||
|             variant="scrollable" | ||||
|             scrollButtons="auto" | ||||
|             aria-label="sub-módulos de suscripciones" | ||||
|           > | ||||
|             {accessibleSubModules.map((subModule) => ( | ||||
|               <Tab key={subModule.path} label={subModule.label} /> | ||||
|             ))} | ||||
|           </Tabs> | ||||
|         </Paper> | ||||
|         <Box sx={{ pt: 2 }}> | ||||
|           {/* Aquí se renderizará el componente de la sub-ruta activa */} | ||||
|           <Outlet /> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default SuscripcionesIndexPage; | ||||
| @@ -75,13 +75,16 @@ import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevo | ||||
| import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage'; | ||||
| import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage'; | ||||
| import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage'; | ||||
| import ReporteFacturasPublicidadPage from '../pages/Reportes/ReporteFacturasPublicidadPage'; | ||||
|  | ||||
| // Suscripciones | ||||
| import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage'; | ||||
| import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage'; | ||||
| import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage'; | ||||
| import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; | ||||
| import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage'; | ||||
| import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage'; | ||||
| import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; | ||||
| import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage'; | ||||
| import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage'; | ||||
|  | ||||
| // Anonalías | ||||
| import AlertasPage from '../pages/Anomalia/AlertasPage'; | ||||
| @@ -185,36 +188,44 @@ const AppRoutes = () => { | ||||
|             </Route> | ||||
|           </Route> | ||||
|  | ||||
|           {/* --- Módulo de Suscripciones --- */} | ||||
|           {/* Módulo de Suscripciones */} | ||||
|           <Route | ||||
|             path="/suscripciones" | ||||
|             path="suscripciones" | ||||
|             element={ | ||||
|               <SectionProtectedRoute requiredPermission="SS007" sectionName="Suscripciones"> | ||||
|                 <SuscripcionesIndexPage /> | ||||
|                 {/* Este Outlet es para las sub-rutas anidadas */} | ||||
|                 <Outlet /> | ||||
|               </SectionProtectedRoute> | ||||
|             } | ||||
|           > | ||||
|             <Route index element={<Navigate to="suscriptores" replace />} /> | ||||
|             <Route path="suscriptores" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU001" sectionName="Suscriptores"> | ||||
|                 <GestionarSuscriptoresPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             <Route path="suscriptor/:idSuscriptor" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU001" sectionName="Suscripciones del Cliente"> | ||||
|                 <GestionarSuscripcionesSuscriptorPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             <Route path="facturacion" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU006" sectionName="Facturación de Suscripciones"> | ||||
|                 <FacturacionPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             <Route path="promociones" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU010" sectionName="Promociones"> | ||||
|                 <GestionarPromocionesPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             {/* 1. Ruta para el layout con pestañas */} | ||||
|             <Route | ||||
|               element={<SuscripcionesIndexPage />} | ||||
|             > | ||||
|               <Route index element={<Navigate to="suscriptores" replace />} /> | ||||
|               <Route path="suscriptores" element={<GestionarSuscriptoresPage />} /> | ||||
|               <Route path="consulta-facturas" element={<ConsultaFacturasPage />} /> | ||||
|               <Route path="procesos" element={<FacturacionPage />} /> | ||||
|               <Route path="promociones" element={<GestionarPromocionesPage />} /> | ||||
|             </Route> | ||||
|  | ||||
|             {/* 2. Rutas de detalle que NO usan el layout de pestañas */} | ||||
|             <Route  | ||||
|                 path="suscriptor/:idSuscriptor/suscripciones"  | ||||
|                 element={ | ||||
|                     <SectionProtectedRoute requiredPermission="SU005" sectionName="Gestionar Suscripciones de Cliente"> | ||||
|                        <GestionarSuscripcionesDeClientePage /> | ||||
|                     </SectionProtectedRoute> | ||||
|                 }  | ||||
|             /> | ||||
|             <Route  | ||||
|                 path="suscriptor/:idSuscriptor/cuenta-corriente"  | ||||
|                 element={ | ||||
|                     <SectionProtectedRoute requiredPermission="SU011" sectionName="Cuenta Corriente del Suscriptor"> | ||||
|                        <CuentaCorrienteSuscriptorPage /> | ||||
|                     </SectionProtectedRoute> | ||||
|                 }  | ||||
|             /> | ||||
|           </Route> | ||||
|  | ||||
|           {/* Módulo Contable (anidado) */} | ||||
| @@ -273,6 +284,11 @@ const AppRoutes = () => { | ||||
|             <Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} /> | ||||
|             <Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} /> | ||||
|             <Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} /> | ||||
|             <Route path="suscripciones-facturas-publicidad" element={ | ||||
|                 <SectionProtectedRoute requiredPermission="RR010" sectionName="Reporte Facturas a Publicidad"> | ||||
|                     <ReporteFacturasPublicidadPage /> | ||||
|                 </SectionProtectedRoute> | ||||
|             }/> | ||||
|           </Route> | ||||
|  | ||||
|           {/* Módulo de Radios (anidado) */} | ||||
|   | ||||
| @@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const getReporteFacturasPublicidadPdf = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => { | ||||
|     const params = new URLSearchParams({ anio: String(anio), mes: String(mes) }); | ||||
|         const url = `/reportes/suscripciones/facturas-para-publicidad/pdf?${params.toString()}`; | ||||
|     const response = await apiClient.get(url, { | ||||
|         responseType: 'blob', | ||||
|     }); | ||||
|  | ||||
|     const contentDisposition = response.headers['content-disposition']; | ||||
|     let fileName = `ReportePublicidad_Suscripciones_${anio}-${String(mes).padStart(2, '0')}.pdf`; // Fallback | ||||
|     if (contentDisposition) { | ||||
|         const fileNameMatch = contentDisposition.match(/filename="(.+)"/); | ||||
|         if (fileNameMatch && fileNameMatch.length > 1) { | ||||
|             fileName = fileNameMatch[1]; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     return { fileContent: response.data, fileName: fileName }; | ||||
| }; | ||||
|  | ||||
| const reportesService = { | ||||
|   getExistenciaPapel, | ||||
|   getExistenciaPapelPdf, | ||||
| @@ -487,7 +506,8 @@ const reportesService = { | ||||
|   getListadoDistMensualDiarios, | ||||
|   getListadoDistMensualDiariosPdf, | ||||
|   getListadoDistMensualPorPublicacion, | ||||
|   getListadoDistMensualPorPublicacionPdf,   | ||||
|   getListadoDistMensualPorPublicacionPdf, | ||||
|   getReporteFacturasPublicidadPdf, | ||||
| }; | ||||
|  | ||||
| export default reportesService; | ||||
| @@ -1,12 +1,26 @@ | ||||
| import apiClient from '../apiClient'; | ||||
| import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto'; | ||||
| import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto'; | ||||
| import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto'; | ||||
|  | ||||
| const API_URL_BY_SUSCRIPTOR = '/suscriptores'; | ||||
| const API_URL_BASE = '/ajustes'; | ||||
|  | ||||
| const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise<AjusteDto[]> => { | ||||
|     const response = await apiClient.get<AjusteDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`); | ||||
| const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise<AjusteDto[]> => { | ||||
|     // URLSearchParams nos ayuda a construir la query string de forma segura y limpia | ||||
|     const params = new URLSearchParams(); | ||||
|     if (fechaDesde) { | ||||
|         params.append('fechaDesde', fechaDesde); | ||||
|     } | ||||
|     if (fechaHasta) { | ||||
|         params.append('fechaHasta', fechaHasta); | ||||
|     } | ||||
|      | ||||
|     // Si hay parámetros, los añadimos a la URL. Si no, la URL queda limpia. | ||||
|     const queryString = params.toString(); | ||||
|     const url = `${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes${queryString ? `?${queryString}` : ''}`; | ||||
|  | ||||
|     const response = await apiClient.get<AjusteDto[]>(url); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`); | ||||
| }; | ||||
|  | ||||
| const updateAjuste = async (idAjuste: number, data: UpdateAjusteDto): Promise<void> => { | ||||
|     await apiClient.put(`${API_URL_BASE}/${idAjuste}`, data); | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     getAjustesPorSuscriptor, | ||||
|     createAjusteManual, | ||||
|     anularAjuste | ||||
|     anularAjuste, | ||||
|     updateAjuste, | ||||
| }; | ||||
| @@ -1,29 +1,33 @@ | ||||
| import apiClient from '../apiClient'; | ||||
| import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto'; | ||||
| import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; | ||||
| import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
| import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; | ||||
| import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; | ||||
|  | ||||
| const API_URL = '/facturacion'; | ||||
| const DEBITOS_URL = '/debitos'; | ||||
| const PAGOS_URL = '/pagos'; | ||||
| const FACTURAS_URL = '/facturas'; | ||||
|  | ||||
| const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => { | ||||
|     const formData = new FormData(); | ||||
|     formData.append('archivo', archivo); | ||||
|  | ||||
|     const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, { | ||||
|         headers: { | ||||
|             'Content-Type': 'multipart/form-data', | ||||
|         }, | ||||
|         headers: { 'Content-Type': 'multipart/form-data' }, | ||||
|     }); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => { | ||||
|     const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`); | ||||
| const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => { | ||||
|     const params = new URLSearchParams(); | ||||
|     if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); | ||||
|     if (estadoPago) params.append('estadoPago', estadoPago); | ||||
|     if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); | ||||
|  | ||||
|     const queryString = params.toString(); | ||||
|     const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; | ||||
|  | ||||
|     const response = await apiClient.get<ResumenCuentaSuscriptorDto[]>(url); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -36,7 +40,6 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo | ||||
|     const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, { | ||||
|         responseType: 'blob', | ||||
|     }); | ||||
|  | ||||
|     const contentDisposition = response.headers['content-disposition']; | ||||
|     let fileName = `debito_${anio}_${mes}.txt`; | ||||
|     if (contentDisposition) { | ||||
| @@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo | ||||
|             fileName = fileNameMatch[1]; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     return { fileContent: response.data, fileName: fileName }; | ||||
| }; | ||||
|  | ||||
| const getPagosPorFactura = async (idFactura: number): Promise<PagoDto[]> => { | ||||
|     const response = await apiClient.get<PagoDto[]>(`${FACTURAS_URL}/${idFactura}/pagos`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => { | ||||
|     const response = await apiClient.post<PagoDto>(PAGOS_URL, data); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL}/${idFactura}/enviar-email`); | ||||
| const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise<void> => { | ||||
|     await apiClient.put(`${API_URL}/${idFactura}/numero-factura`, `"${numeroFactura}"`, { | ||||
|         headers: { 'Content-Type': 'application/json' } | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`); | ||||
| }; | ||||
|  | ||||
| const enviarFacturaPdfPorEmail = async (idFactura: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`); | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     procesarArchivoRespuesta, | ||||
|     getFacturasPorPeriodo, | ||||
|     getResumenesDeCuentaPorPeriodo, | ||||
|     generarFacturacionMensual, | ||||
|     generarArchivoDebito, | ||||
|     getPagosPorFactura, | ||||
|     registrarPagoManual, | ||||
|     enviarFacturaPorEmail, | ||||
|     actualizarNumeroFactura, | ||||
|     enviarAvisoCuentaPorEmail, | ||||
|     enviarFacturaPdfPorEmail, | ||||
| }; | ||||
| @@ -3,12 +3,15 @@ import type { SuscripcionDto } from '../../models/dtos/Suscripciones/Suscripcion | ||||
| import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; | ||||
| import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; | ||||
| import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import type { PromocionAsignadaDto } from '../../models/dtos/Suscripciones/PromocionAsignadaDto'; | ||||
| import type { AsignarPromocionDto } from '../../models/dtos/Suscripciones/AsignarPromocionDto'; | ||||
|  | ||||
| const API_URL_BASE = '/suscripciones'; | ||||
| const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada | ||||
| const API_URL_SUSCRIPTORES = '/suscriptores'; | ||||
|  | ||||
| const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise<SuscripcionDto[]> => { | ||||
|     const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`); | ||||
|     // La URL correcta es /suscriptores/{id}/suscripciones, no /suscripciones/suscriptor/... | ||||
|     const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_SUSCRIPTORES}/${idSuscriptor}/suscripciones`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis | ||||
|     await apiClient.put(`${API_URL_BASE}/${id}`, data); | ||||
| }; | ||||
|  | ||||
| const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => { | ||||
|     const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`); | ||||
| const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionAsignadaDto[]> => { | ||||
|     const response = await apiClient.get<PromocionAsignadaDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise<Promoci | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); | ||||
| const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data); | ||||
| }; | ||||
|  | ||||
| const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { | ||||
| @@ -52,5 +55,5 @@ export default { | ||||
|     getPromocionesAsignadas, | ||||
|     getPromocionesDisponibles, | ||||
|     asignarPromocion, | ||||
|     quitarPromocion | ||||
|     quitarPromocion, | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user