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;
|
||||
Reference in New Issue
Block a user