Feat: Implementa flujo completo de facturación y promociones
Este commit introduce la funcionalidad completa para la facturación mensual, la gestión de promociones y la comunicación con el cliente en el módulo de suscripciones. Backend: - Se añade el servicio de Facturación que calcula automáticamente los importes mensuales basándose en las suscripciones activas, días de entrega y precios. - Se implementa el servicio DebitoAutomaticoService, capaz de generar el archivo de texto plano para "Pago Directo Galicia" y de procesar el archivo de respuesta para la conciliación de pagos. - Se desarrolla el ABM completo para Promociones (Servicio, Repositorio, Controlador y DTOs), permitiendo la creación de descuentos por porcentaje o monto fijo. - Se implementa la lógica para asignar y desasignar promociones a suscripciones específicas. - Se añade un servicio de envío de email (EmailService) integrado con MailKit y un endpoint para notificar facturas a los clientes. - Se crea la lógica para registrar pagos manuales (efectivo, tarjeta, etc.) y actualizar el estado de las facturas. - Se añaden todos los permisos necesarios a la base de datos para segmentar el acceso a las nuevas funcionalidades. Frontend: - Se crea la página de Facturación, que permite al usuario seleccionar un período, generar la facturación, listar los resultados y generar el archivo de débito para el banco. - Se implementa la funcionalidad para subir y procesar el archivo de respuesta del banco, actualizando la UI en consecuencia. - Se añade la página completa para el ABM de Promociones. - Se integra un modal en la gestión de suscripciones para asignar y desasignar promociones a un cliente. - Se añade la opción "Enviar Email" en el menú de acciones de las facturas, conectada al nuevo endpoint del backend. - Se completan y corrigen los componentes `PagoManualModal` y `FacturacionPage` para incluir la lógica de registro de pagos y solucionar errores de TypeScript.
This commit is contained in:
		
							
								
								
									
										321
									
								
								Frontend/src/pages/Suscripciones/FacturacionPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								Frontend/src/pages/Suscripciones/FacturacionPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,321 @@ | ||||
| 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 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 = [ | ||||
|     { 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 VisuallyHiddenInput = styled('input')({ | ||||
|     clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', height: 1, overflow: 'hidden', | ||||
|     position: 'absolute', bottom: 0, left: 0, whiteSpace: 'nowrap', width: 1, | ||||
| }); | ||||
|  | ||||
| const FacturacionPage: React.FC = () => { | ||||
|     const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear()); | ||||
|     const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [loadingArchivo, setLoadingArchivo] = useState(false); | ||||
|     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 { 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.`)) { | ||||
|             return; | ||||
|         } | ||||
|         setLoading(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         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 | ||||
|                 : 'Ocurrió un error al generar la facturación.'; | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleGenerarArchivo = async () => { | ||||
|         if (!window.confirm(`Se generará el archivo de débito para las facturas del período ${meses.find(m => m.value === selectedMes)?.label}/${selectedAnio} que estén en estado 'Pendiente de Cobro'. ¿Continuar?`)) { | ||||
|             return; | ||||
|         } | ||||
|         setLoadingArchivo(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes); | ||||
|             const url = window.URL.createObjectURL(new Blob([fileContent])); | ||||
|             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); | ||||
|             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) { | ||||
|                 const errorText = await err.response.data.text(); | ||||
|                 try { | ||||
|                     const errorJson = JSON.parse(errorText); | ||||
|                     message = errorJson.message || message; | ||||
|                 } catch { message = errorText || message; } | ||||
|             } | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoadingArchivo(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     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]); | ||||
|             setApiMessage(null); | ||||
|             setApiError(null); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleProcesarArchivo = async () => { | ||||
|         if (!archivoSeleccionado) { | ||||
|             setApiError("Por favor, seleccione un archivo de respuesta para procesar."); | ||||
|             return; | ||||
|         } | ||||
|         setLoadingProceso(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const response = await facturacionService.procesarArchivoRespuesta(archivoSeleccionado); | ||||
|             setApiMessage(response.mensajeResumen); | ||||
|             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 | ||||
|                 : 'Ocurrió un error crítico al procesar el archivo.'; | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoadingProceso(false); | ||||
|             setArchivoSeleccionado(null); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     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="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                     Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado. | ||||
|                 </Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 2, 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> | ||||
|                     </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> | ||||
|                 </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> | ||||
|             </Paper> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <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> | ||||
|                 <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 }}> | ||||
|                 <Typography variant="h6">3. Procesar Respuesta del Banco</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                     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' }}> | ||||
|                     <Button | ||||
|                         component="label" | ||||
|                         role={undefined} | ||||
|                         variant="outlined" | ||||
|                         tabIndex={-1} | ||||
|                         startIcon={<UploadFileIcon />} | ||||
|                         disabled={loadingProceso} | ||||
|                     > | ||||
|                         Seleccionar Archivo | ||||
|                         <VisuallyHiddenInput type="file" onChange={handleFileChange} accept=".txt, text/plain" /> | ||||
|                     </Button> | ||||
|                     {archivoSeleccionado && <Typography variant="body2">{archivoSeleccionado.name}</Typography>} | ||||
|                 </Box> | ||||
|                 {archivoSeleccionado && ( | ||||
|                     <Button | ||||
|                         variant="contained" | ||||
|                         color="success" | ||||
|                         sx={{ mt: 2 }} | ||||
|                         onClick={handleProcesarArchivo} | ||||
|                         disabled={loadingProceso} | ||||
|                         startIcon={loadingProceso ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} | ||||
|                     > | ||||
|                         Procesar Archivo de Respuesta | ||||
|                     </Button> | ||||
|                 )} | ||||
|             </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)} /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default FacturacionPage; | ||||
							
								
								
									
										157
									
								
								Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Switch, FormControlLabel, Tooltip } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import promocionService from '../../services/Suscripciones/promocionService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import type { CreatePromocionDto, UpdatePromocionDto } from '../../models/dtos/Suscripciones/CreatePromocionDto'; | ||||
| import PromocionFormModal from '../../components/Modals/Suscripciones/PromocionFormModal'; | ||||
|  | ||||
| const GestionarPromocionesPage: React.FC = () => { | ||||
|     const [promociones, setPromociones] = useState<PromocionDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [filtroSoloActivas, setFiltroSoloActivas] = useState(true); | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [editingPromocion, setEditingPromocion] = useState<PromocionDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|      | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU010"); | ||||
|  | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         if (!puedeGestionar) {  | ||||
|             setError("No tiene permiso para gestionar promociones.");  | ||||
|             setLoading(false);  | ||||
|             return;  | ||||
|         } | ||||
|         setLoading(true); | ||||
|         setError(null); | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             const data = await promocionService.getAllPromociones(filtroSoloActivas); | ||||
|             setPromociones(data); | ||||
|         } catch (err) {  | ||||
|             setError("Error al cargar las promociones.");  | ||||
|         } finally {  | ||||
|             setLoading(false);  | ||||
|         } | ||||
|     }, [filtroSoloActivas, puedeGestionar]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarDatos(); | ||||
|     }, [cargarDatos]); | ||||
|  | ||||
|     const handleOpenModal = (promocion?: PromocionDto) => { | ||||
|         setEditingPromocion(promocion || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
|         setModalOpen(false); | ||||
|         setEditingPromocion(null); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitModal = async (data: CreatePromocionDto | UpdatePromocionDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if (id && editingPromocion) { | ||||
|                 await promocionService.updatePromocion(id, data as UpdatePromocionDto); | ||||
|             } else { | ||||
|                 await promocionService.createPromocion(data as CreatePromocionDto); | ||||
|             } | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message | ||||
|                 ? err.response.data.message | ||||
|                 : 'Error al guardar la promoción.'; | ||||
|             setApiErrorMessage(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     const formatDate = (dateString?: string | null) => { | ||||
|         if (!dateString) return 'Indefinido'; | ||||
|         const parts = dateString.split('-'); | ||||
|         return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||
|     }; | ||||
|  | ||||
|     const formatTipo = (tipo: string) => { | ||||
|         if (tipo === 'MontoFijo') return 'Monto Fijo'; | ||||
|         if (tipo === 'Porcentaje') return 'Porcentaje'; | ||||
|         return tipo; | ||||
|     }; | ||||
|  | ||||
|     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 (!puedeGestionar) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Gestionar Promociones</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||
|                 <FormControlLabel control={<Switch checked={filtroSoloActivas} onChange={(e) => setFiltroSoloActivas(e.target.checked)} />} label="Ver Solo Activas" /> | ||||
|                 <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}> | ||||
|                     Nueva Promoción | ||||
|                 </Button> | ||||
|             </Paper> | ||||
|              | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Tipo</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> | ||||
|                             <TableCell align="center" sx={{fontWeight: 'bold'}}>Estado</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {promociones.length === 0 ? ( | ||||
|                             <TableRow><TableCell colSpan={7} align="center">No se encontraron promociones.</TableCell></TableRow> | ||||
|                         ) : ( | ||||
|                             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>{formatDate(p.fechaInicio)}</TableCell> | ||||
|                                     <TableCell>{formatDate(p.fechaFin)}</TableCell> | ||||
|                                     <TableCell align="center"> | ||||
|                                         <Chip label={p.activa ? 'Activa' : 'Inactiva'} color={p.activa ? 'success' : 'default'} size="small" /> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         <Tooltip title="Editar Promoción"> | ||||
|                                             <IconButton onClick={() => handleOpenModal(p)}> | ||||
|                                                 <EditIcon /> | ||||
|                                             </IconButton> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|                             )) | ||||
|                         )} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|              | ||||
|             <PromocionFormModal  | ||||
|                 open={modalOpen} | ||||
|                 onClose={handleCloseModal} | ||||
|                 onSubmit={handleSubmitModal} | ||||
|                 initialData={editingPromocion} | ||||
|                 errorMessage={apiErrorMessage} | ||||
|                 clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default GestionarPromocionesPage; | ||||
| @@ -13,6 +13,8 @@ import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; | ||||
| import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; | ||||
| import LoyaltyIcon from '@mui/icons-material/Loyalty'; | ||||
| import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal'; | ||||
|  | ||||
| const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); | ||||
| @@ -27,6 +29,9 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const [editingSuscripcion, setEditingSuscripcion] = useState<SuscripcionDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|  | ||||
|     const [promocionesModalOpen, setPromocionesModalOpen] = useState(false); | ||||
|     const [selectedSuscripcion, setSelectedSuscripcion] = useState<SuscripcionDto | null>(null); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeVer = isSuperAdmin || tienePermiso("SU001"); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU005"); | ||||
| @@ -54,11 +59,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     }, [idSuscriptor, puedeVer]); | ||||
|  | ||||
|     useEffect(() => { cargarDatos(); }, [cargarDatos]); | ||||
|      | ||||
|  | ||||
|     const handleOpenModal = (suscripcion?: SuscripcionDto) => { | ||||
|       setEditingSuscripcion(suscripcion || null); | ||||
|       setApiErrorMessage(null); | ||||
|       setModalOpen(true); | ||||
|         setEditingSuscripcion(suscripcion || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
| @@ -69,7 +74,7 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const handleSubmitModal = async (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if(id && editingSuscripcion) { | ||||
|             if (id && editingSuscripcion) { | ||||
|                 await suscripcionService.updateSuscripcion(id, data as UpdateSuscripcionDto); | ||||
|             } else { | ||||
|                 await suscripcionService.createSuscripcion(data as CreateSuscripcionDto); | ||||
| @@ -82,6 +87,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleOpenPromocionesModal = (suscripcion: SuscripcionDto) => { | ||||
|         setSelectedSuscripcion(suscripcion); | ||||
|         setPromocionesModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const formatDate = (dateString?: string | null) => { | ||||
|         if (!dateString) return 'Indefinido'; | ||||
|         // Asume que la fecha viene como "yyyy-MM-dd" | ||||
| @@ -90,8 +100,8 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     }; | ||||
|  | ||||
|     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> | ||||
|     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 }}> | ||||
| @@ -102,22 +112,22 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|             <Typography variant="subtitle1" color="text.secondary" gutterBottom> | ||||
|                 Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion} | ||||
|             </Typography> | ||||
|              | ||||
|  | ||||
|             {puedeGestionar && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ my: 2 }}>Nueva Suscripción</Button>} | ||||
|              | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Publicación</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Estado</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Días Entrega</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Observaciones</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Publicación</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Días Entrega</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Inicio</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Fin</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Observaciones</TableCell> | ||||
|                             <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
| @@ -128,10 +138,10 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|                                 <TableRow key={s.idSuscripcion} hover> | ||||
|                                     <TableCell>{s.nombrePublicacion}</TableCell> | ||||
|                                     <TableCell> | ||||
|                                         <Chip  | ||||
|                                             label={s.estado}  | ||||
|                                             color={s.estado === 'Activa' ? 'success' : s.estado === 'Pausada' ? 'warning' : 'default'}  | ||||
|                                             size="small"  | ||||
|                                         <Chip | ||||
|                                             label={s.estado} | ||||
|                                             color={s.estado === 'Activa' ? 'success' : s.estado === 'Pausada' ? 'warning' : 'default'} | ||||
|                                             size="small" | ||||
|                                         /> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell>{s.diasEntrega.split(',').join(', ')}</TableCell> | ||||
| @@ -147,14 +157,23 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|                                             </span> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         <Tooltip title="Editar Suscripción"> | ||||
|                                             <span><IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}><EditIcon /></IconButton></span> | ||||
|                                         </Tooltip> | ||||
|                                         <Tooltip title="Gestionar Promociones"> | ||||
|                                             <span><IconButton onClick={() => handleOpenPromocionesModal(s)} disabled={!puedeGestionar}><LoyaltyIcon /></IconButton></span> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|  | ||||
|                             )) | ||||
|                         )} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|  | ||||
|             {idSuscriptor &&  | ||||
|             {idSuscriptor && | ||||
|                 <SuscripcionFormModal | ||||
|                     open={modalOpen} | ||||
|                     onClose={handleCloseModal} | ||||
| @@ -165,6 +184,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|                     clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|                 /> | ||||
|             } | ||||
|             <GestionarPromocionesSuscripcionModal | ||||
|                 open={promocionesModalOpen} | ||||
|                 onClose={() => setPromocionesModalOpen(false)} | ||||
|                 suscripcion={selectedSuscripcion} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
| @@ -6,8 +6,8 @@ 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: 'SU005' }, | ||||
|   // { label: 'Promociones', path: 'promociones', requiredPermission: 'SU006' }, | ||||
|   { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' }, | ||||
|   { label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' }, | ||||
| ]; | ||||
|  | ||||
| const SuscripcionesIndexPage: React.FC = () => { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user