Feat: Implementa auditoría de envíos masivos y mejora de procesos

Se introduce un sistema completo para auditar los envíos masivos de correos durante el cierre mensual y se refactoriza la interfaz de usuario de procesos para una mayor claridad y escalabilidad. Además, se mejora la lógica de negocio para la gestión de bajas de suscripciones.

###  Nuevas Características

- **Auditoría de Envíos Masivos (Cierre Mensual):**
    - Se crea una nueva tabla `com_LotesDeEnvio` para registrar cada ejecución del proceso de facturación mensual.
    - El `FacturacionService` ahora crea un "lote" al iniciar el cierre, registra el resultado de cada envío de email individual asociándolo a dicho lote, y actualiza las estadísticas finales (enviados, fallidos) al terminar.
    - Se implementa un nuevo `LotesEnvioController` con un endpoint para consultar los detalles de cualquier lote de envío histórico.

### 🔄 Refactorización y Mejoras

- **Rediseño de la Página de Procesos:**
    - La antigua página "Facturación" se renombra a `CierreYProcesosPage` y se rediseña completamente utilizando una interfaz de Pestañas (Tabs).
    - **Pestaña "Procesos Mensuales":** Aisla las acciones principales (Generar Cierre, Archivo de Débito, Procesar Respuesta), mostrando un resumen del resultado del último envío.
    - **Pestaña "Historial de Cierres":** Muestra una tabla con todos los lotes de envío pasados y permite al usuario ver los detalles de cada uno en un modal.

- **Filtros para el Historial de Cierres:**
    - Se añaden filtros por Mes y Año a la pestaña de "Historial de Cierres", permitiendo al usuario buscar y auditar procesos pasados de manera eficiente. El filtrado se realiza en el backend para un rendimiento óptimo.

- **Lógica de `FechaFin` Obligatoria para Bajas:**
    - Se implementa una regla de negocio crucial: al cambiar el estado de una suscripción a "Pausada" o "Cancelada", ahora es obligatorio establecer una `FechaFin`.
    - **Frontend:** El modal de suscripciones ahora gestiona esto automáticamente, haciendo el campo `FechaFin` requerido y visible según el estado seleccionado.
    - **Backend:** Se añade una validación en `SuscripcionService` como segunda capa de seguridad para garantizar la integridad de los datos.

### 🐛 Corrección de Errores

- **Reporte de Distribución:** Se corrigió un bug en la generación del PDF donde la columna de fecha no mostraba la "Fecha de Baja" para las suscripciones finalizadas. Ahora se muestra la fecha correcta según la sección (Altas o Bajas).
- **Errores de Compilación y Dependencias:** Se solucionaron varios errores de compilación en el backend, principalmente relacionados con la falta de registro de los nuevos repositorios (`ILoteDeEnvioRepository`, `IEmailLogService`, etc.) en el contenedor de inyección de dependencias (`Program.cs`).
- **Errores de Tipado en Frontend:** Se corrigieron múltiples errores de TypeScript en `CierreYProcesosPage` debidos a la inconsistencia entre `PascalCase` (C#) y `camelCase` (JSON/TypeScript), asegurando un mapeo correcto de los datos de la API.
This commit is contained in:
2025-08-11 11:14:03 -03:00
parent 7dc0940001
commit 1a288fcfa5
32 changed files with 1175 additions and 512 deletions

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Paper, Chip, CircularProgress, Alert } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import type { EmailLogDto } from '../../../models/dtos/Comunicaciones/EmailLogDto';
import facturacionService from '../../../services/Suscripciones/facturacionService';
interface ResultadoEnvioModalProps {
open: boolean;
onClose: () => void;
loteId: number | null;
periodo: string;
}
const ResultadoEnvioModal: React.FC<ResultadoEnvioModalProps> = ({ open, onClose, loteId, periodo }) => {
const [activeTab, setActiveTab] = useState(0);
const [logs, setLogs] = useState<EmailLogDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open && loteId) {
const fetchDetails = async () => {
setLoading(true);
setError(null);
try {
const data = await facturacionService.getDetallesLoteEnvio(loteId);
setLogs(data);
} catch (err) {
setError('No se pudieron cargar los detalles del envío.');
} finally {
setLoading(false);
}
};
fetchDetails();
}
}, [open, loteId]);
const filteredLogs = useMemo(() => {
if (activeTab === 1) return logs.filter(log => log.estado === 'Enviado');
if (activeTab === 2) return logs.filter(log => log.estado === 'Fallido');
return logs; // Tab 0 es 'Todos'
}, [logs, activeTab]);
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
Detalle del Lote de Envío - Período {periodo}
<IconButton onClick={onClose} sx={{ position: 'absolute', right: 8, top: 8 }}><CloseIcon /></IconButton>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_e, newValue) => setActiveTab(newValue)}>
<Tab label={`Todos (${logs.length})`} />
<Tab label={`Enviados (${logs.filter(l => l.estado === 'Enviado').length})`} />
<Tab label={`Fallidos (${logs.filter(l => l.estado === 'Fallido').length})`} />
</Tabs>
</Box>
{loading ? <CircularProgress /> : error ? <Alert severity="error">{error}</Alert> :
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Destinatario</TableCell>
<TableCell>Asunto</TableCell>
<TableCell>Estado</TableCell>
<TableCell>Detalle del Error</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredLogs.map((log, index) => (
<TableRow key={index}>
<TableCell>{log.destinatarioEmail}</TableCell>
<TableCell>{log.asunto}</TableCell>
<TableCell><Chip label={log.estado} color={log.estado === 'Enviado' ? 'success' : 'error'} size="small" /></TableCell>
<TableCell sx={{ color: 'error.main' }}>{log.error || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
}
</DialogContent>
</Dialog>
);
};
export default ResultadoEnvioModal;

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel,
Checkbox, type SelectChangeEvent, Paper
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormGroup, FormControlLabel,
Checkbox, type SelectChangeEvent, Paper
} from '@mui/material';
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
import type { CreateSuscripcionDto } from '../../../models/dtos/Suscripciones/CreateSuscripcionDto';
@@ -25,10 +25,10 @@ const modalStyle = {
};
const dias = [
{ 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' }
{ 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 {
@@ -41,13 +41,12 @@ interface SuscripcionFormModalProps {
clearErrorMessage: () => void;
}
// Usamos una interfaz local que contenga todos los campos posibles del formulario
interface FormState {
idPublicacion?: number | '';
fechaInicio?: string;
fechaFin?: string | null;
estado?: 'Activa' | 'Pausada' | 'Cancelada';
observaciones?: string;
idPublicacion?: number | '';
fechaInicio?: string;
fechaFin?: string | null;
estado?: 'Activa' | 'Pausada' | 'Cancelada';
observaciones?: string;
}
const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, initialData, errorMessage, clearErrorMessage }) => {
@@ -57,7 +56,7 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
const [loading, setLoading] = useState(false);
const [loadingPubs, setLoadingPubs] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const isEditing = Boolean(initialData);
useEffect(() => {
@@ -92,8 +91,15 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
const errors: { [key: string]: string | null } = {};
if (!formData.idPublicacion) errors.idPublicacion = "Debe seleccionar una publicación.";
if (!formData.fechaInicio?.trim()) errors.fechaInicio = 'La Fecha de Inicio es obligatoria.';
// --- INICIO DE LA MODIFICACIÓN ---
if (formData.estado !== 'Activa' && !formData.fechaFin) {
errors.fechaFin = 'La Fecha de Fin es obligatoria si el estado es Pausada o Cancelada.';
}
// --- FIN DE LA MODIFICACIÓN ---
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
errors.fechaFin = 'La Fecha de Fin no puede ser anterior a la de inicio.';
}
if (selectedDays.size === 0) errors.diasEntrega = "Debe seleccionar al menos un día de entrega.";
setLocalErrors(errors);
@@ -105,10 +111,10 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (newSelection.has(dayValue)) newSelection.delete(dayValue);
else newSelection.add(dayValue);
setSelectedDays(newSelection);
if (localErrors.diasEntrega) setLocalErrors(prev => ({...prev, diasEntrega: null}));
if (localErrors.diasEntrega) setLocalErrors(prev => ({ ...prev, diasEntrega: null }));
if (errorMessage) clearErrorMessage();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -123,17 +129,38 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
if (errorMessage) clearErrorMessage();
};
// --- INICIO DE LA MODIFICACIÓN ---
const handleEstadoChange = (e: SelectChangeEvent<'Activa' | 'Pausada' | 'Cancelada'>) => {
const nuevoEstado = e.target.value as 'Activa' | 'Pausada' | 'Cancelada';
const hoy = new Date().toISOString().split('T')[0];
setFormData(prev => {
const newState = { ...prev, estado: nuevoEstado };
if ((nuevoEstado === 'Cancelada' || nuevoEstado === 'Pausada') && !prev.fechaFin) {
newState.fechaFin = hoy;
} else if (nuevoEstado === 'Activa') {
newState.fechaFin = null; // Limpiar la fecha de fin si se reactiva
}
return newState;
});
// Limpiar errores al cambiar
if (localErrors.fechaFin) setLocalErrors(prev => ({ ...prev, fechaFin: null }));
if (errorMessage) clearErrorMessage();
};
// --- FIN DE LA MODIFICACIÓN ---
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
let success = false;
try {
const dataToSubmit = {
...formData,
fechaFin: formData.fechaFin || null,
fechaFin: formData.estado === 'Activa' ? null : formData.fechaFin, // Asegurarse de que fechaFin es null si está activa
diasEntrega: Array.from(selectedDays),
};
@@ -156,43 +183,57 @@ const SuscripcionFormModal: React.FC<SuscripcionFormModalProps> = ({ open, onClo
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">{isEditing ? 'Editar Suscripción' : 'Nueva Suscripción'}</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}>
<InputLabel id="pub-label" required>Publicación</InputLabel>
<Select name="idPublicacion" labelId="pub-label" value={formData.idPublicacion || ''} onChange={handleSelectChange} label="Publicación" disabled={loading || loadingPubs || isEditing}>
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>)}
</Select>
{localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>}
</FormControl>
<Typography sx={{ mt: 2, mb: 1, color: localErrors.diasEntrega ? 'error.main' : 'inherit' }}>Días de Entrega *</Typography>
<Paper variant="outlined" sx={{ p: 1, borderColor: localErrors.diasEntrega ? 'error.main' : 'rgba(0, 0, 0, 0.23)' }}>
<FormGroup row>
{dias.map(d => <FormControlLabel key={d.value} control={<Checkbox checked={selectedDays.has(d.value)} onChange={() => handleDayChange(d.value)} disabled={loading}/>} label={d.label} />)}
</FormGroup>
</Paper>
{localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>}
<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>
<FormControl fullWidth margin="dense">
<InputLabel id="estado-label">Estado</InputLabel>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleSelectChange} label="Estado" disabled={loading}>
<MenuItem value="Activa">Activa</MenuItem>
<MenuItem value="Pausada">Pausada</MenuItem>
<MenuItem value="Cancelada">Cancelada</MenuItem>
</Select>
</FormControl>
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
<FormControl fullWidth margin="dense" error={!!localErrors.idPublicacion}>
<InputLabel id="pub-label" required>Publicación</InputLabel>
<Select name="idPublicacion" labelId="pub-label" value={formData.idPublicacion || ''} onChange={handleSelectChange} label="Publicación" disabled={loading || loadingPubs || isEditing}>
{publicaciones.map(p => <MenuItem key={p.idPublicacion} value={p.idPublicacion}>{p.nombre} ({p.nombreEmpresa})</MenuItem>)}
</Select>
{localErrors.idPublicacion && <Typography color="error" variant="caption">{localErrors.idPublicacion}</Typography>}
</FormControl>
<Typography sx={{ mt: 2, mb: 1, color: localErrors.diasEntrega ? 'error.main' : 'inherit' }}>Días de Entrega *</Typography>
<Paper variant="outlined" sx={{ p: 1, borderColor: localErrors.diasEntrega ? 'error.main' : 'rgba(0, 0, 0, 0.23)' }}>
<FormGroup row>
{dias.map(d => <FormControlLabel key={d.value} control={<Checkbox checked={selectedDays.has(d.value)} onChange={() => handleDayChange(d.value)} disabled={loading} />} label={d.label} />)}
</FormGroup>
</Paper>
{localErrors.diasEntrega && <Typography color="error" variant="caption">{localErrors.diasEntrega}</Typography>}
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<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={formData.estado !== 'Activa' ? "Fecha Fin (Requerida)" : "Fecha Fin (Automática)"}
type="date"
value={formData.fechaFin || ''}
onChange={handleInputChange}
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
error={!!localErrors.fechaFin}
helperText={localErrors.fechaFin}
disabled={loading || formData.estado === 'Activa'} // Deshabilitado si está activa
required={formData.estado !== 'Activa'} // Requerido si no está activa
/>
</Box>
<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 || loadingPubs}>
{loading ? <CircularProgress size={24} /> : 'Guardar'}
</Button>
</Box>
<FormControl fullWidth margin="dense">
<InputLabel id="estado-label">Estado</InputLabel>
<Select name="estado" labelId="estado-label" value={formData.estado || 'Activa'} onChange={handleEstadoChange} label="Estado" disabled={loading}>
<MenuItem value="Activa">Activa</MenuItem>
<MenuItem value="Pausada">Pausada</MenuItem>
<MenuItem value="Cancelada">Cancelada</MenuItem>
</Select>
</FormControl>
<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 }}>{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 || loadingPubs}>
{loading ? <CircularProgress size={24} /> : 'Guardar'}
</Button>
</Box>
</Box>
</Box>
</Modal>

View File

@@ -0,0 +1,23 @@
import type { EmailLogDto } from "./EmailLogDto";
// Representa el resumen inmediato que se muestra tras el cierre
export interface LoteDeEnvioResumenDto {
idLoteDeEnvio: number;
periodo: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
erroresDetallados: EmailLogDto[]; // Lista de errores inmediatos
}
// Representa una fila en la tabla de historial
export interface LoteDeEnvioHistorialDto {
idLoteDeEnvio: number;
fechaInicio: string;
periodo: string;
estado: string;
totalCorreos: number;
totalEnviados: number;
totalFallidos: number;
nombreUsuarioDisparo: string;
}

View File

@@ -0,0 +1,299 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Paper, Alert, Button, CircularProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Chip, Select, MenuItem, FormControl, InputLabel, Tabs, Tab } from '@mui/material';
import facturacionService from '../../services/Suscripciones/facturacionService';
import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto';
import ResultadoEnvioModal from '../../components/Modals/Suscripciones/ResultadoEnvioModal';
import { usePermissions } from '../../hooks/usePermissions';
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
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' }
];
// --- Sub-componente para renderizar la Pestaña de Historial ---
const TabHistorial: React.FC<{
puedeConsultar: boolean;
onVerDetalles: (lote: LoteDeEnvioHistorialDto) => void;
formatDate: (date: string) => string;
}> = ({ puedeConsultar, onVerDetalles, formatDate }) => {
const [historial, setHistorial] = useState<LoteDeEnvioHistorialDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filtroAnio, setFiltroAnio] = useState<number>(new Date().getFullYear());
const [filtroMes, setFiltroMes] = useState<number>(new Date().getMonth() + 1);
const cargarHistorial = useCallback(async () => {
if (!puedeConsultar) { setLoading(false); return; }
setLoading(true); setError(null);
try {
const data = await facturacionService.getHistorialLotesEnvio(filtroAnio, filtroMes);
setHistorial(data);
} catch (error) {
setError("No se pudo cargar el historial de cierres.");
} finally {
setLoading(false);
}
}, [puedeConsultar, filtroAnio, filtroMes]);
useEffect(() => {
cargarHistorial();
}, [cargarHistorial]);
return (
<Box>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle1" gutterBottom>Filtrar Historial</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={filtroMes} label="Mes" onChange={(e) => setFiltroMes(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={filtroAnio} label="Año" onChange={(e) => setFiltroAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl>
</Box>
</Paper>
{error && <Alert severity="error">{error}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Período</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Fecha Proceso</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Usuario</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Total Correos</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Enviados</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="center">Fallidos</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>
) : historial.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No hay registros para el período seleccionado.</TableCell></TableRow>
) : (
historial.map(lote => (
<TableRow key={lote.idLoteDeEnvio} hover>
<TableCell sx={{ fontWeight: 'bold' }}>{lote.periodo}</TableCell>
<TableCell>{formatDate(lote.fechaInicio)}</TableCell>
<TableCell>{lote.nombreUsuarioDisparo}</TableCell>
<TableCell align="center">{lote.totalCorreos}</TableCell>
<TableCell align="center"><Chip label={lote.totalEnviados} color="success" size="small" variant="outlined" /></TableCell>
<TableCell align="center"><Chip label={lote.totalFallidos} color={lote.totalFallidos > 0 ? 'error' : 'default'} size="small" variant="outlined" /></TableCell>
<TableCell>
<Button size="small" onClick={() => onVerDetalles(lote)}>Detalles</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
const CierreYProcesosPage: React.FC = () => {
const [activeTab, setActiveTab] = useState(0);
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
const [loadingCierre, setLoadingCierre] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [loadingArchivoDebito, setLoadingArchivoDebito] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loadingRespuesta, setLoadingRespuesta] = useState(false);
const [respuestaProceso, setRespuestaProceso] = useState<ProcesamientoLoteResponseDto | null>(null);
const [ultimoResultadoEnvio, setUltimoResultadoEnvio] = useState<LoteDeEnvioResumenDto | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [selectedLoteId, setSelectedLoteId] = useState<number | null>(null);
const [selectedLotePeriodo, setSelectedLotePeriodo] = useState("");
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionarCierre = isSuperAdmin || tienePermiso("SU006");
const puedeGestionarDebitos = isSuperAdmin || tienePermiso("SU007");
const handleGenerarCierre = async () => {
if (!window.confirm(`¿Está seguro de que desea generar el cierre y la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Esta acción no se puede deshacer y enviará avisos por email.`)) return;
setLoadingCierre(true);
setApiMessage(null);
setApiError(null);
setUltimoResultadoEnvio(null);
try {
const { message, resultadoEnvio } = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(message);
if (resultadoEnvio) {
setUltimoResultadoEnvio(resultadoEnvio);
}
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al generar el cierre del período.');
} finally {
setLoadingCierre(false);
}
};
const handleGenerarArchivoDebito = async () => {
setLoadingArchivoDebito(true);
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);
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al generar el archivo de débito.');
} finally {
setLoadingArchivoDebito(false);
}
};
const handleProcesarRespuesta = async () => {
if (!selectedFile) {
setApiError("Por favor, seleccione un archivo de respuesta.");
return;
}
setLoadingRespuesta(true);
setApiError(null);
setRespuestaProceso(null);
try {
const data = await facturacionService.procesarArchivoRespuesta(selectedFile);
setRespuestaProceso(data);
setApiMessage("Archivo de respuesta procesado exitosamente.");
} catch (err: any) {
const message = err.response?.data?.message || 'Error al procesar el archivo.';
setApiError(message);
if (err.response?.data) {
setRespuestaProceso(err.response.data);
}
} finally {
setLoadingRespuesta(false);
}
};
const handleOpenModal = (lote: LoteDeEnvioHistorialDto) => {
setSelectedLoteId(lote.idLoteDeEnvio);
setSelectedLotePeriodo(lote.periodo);
setModalOpen(true);
};
const formatDisplayDateTime = (dateString: string): string => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
if (!puedeGestionarCierre && !puedeGestionarDebitos) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
return (
<Box sx={{ p: { xs: 1, sm: 2 } }}>
<Typography variant="h5" gutterBottom>Cierre de Período y Procesos Mensuales</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={activeTab} onChange={(_event, newValue) => setActiveTab(newValue)}>
<Tab label="Procesos Mensuales" />
<Tab label="Historial de Cierres" />
</Tabs>
</Box>
{activeTab === 0 && (
<Box>
{apiError && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setApiError(null)}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setApiMessage(null)}>{apiMessage}</Alert>}
{puedeGestionarCierre && (
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">1. Generación de Cierre Mensual</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Este proceso calcula las deudas, aplica ajustes y promociones, y envía los avisos de vencimiento por email.
</Typography>
<Box sx={{ display: 'flex', gap: 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>
<Button onClick={handleGenerarCierre} variant="contained" disabled={loadingCierre}>{loadingCierre ? <CircularProgress size={24} /> : 'Generar Cierre del Período'}</Button>
</Box>
{ultimoResultadoEnvio && (
<Alert severity={ultimoResultadoEnvio.totalFallidos > 0 ? "warning" : "success"} sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: 'bold' }}>
Resultado del envío de correos para {ultimoResultadoEnvio.periodo}
</Typography>
<Typography variant="body2">
{ultimoResultadoEnvio.totalEnviados} enviados exitosamente, {ultimoResultadoEnvio.totalFallidos} fallidos.
</Typography>
</Box>
<Button size="small" variant="outlined" color="inherit" onClick={() => {
setSelectedLoteId(ultimoResultadoEnvio.idLoteDeEnvio);
setSelectedLotePeriodo(ultimoResultadoEnvio.periodo);
setModalOpen(true);
}}>Ver Detalles</Button>
</Box>
</Alert>
)}
</Paper>
)}
{puedeGestionarDebitos && (
<>
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Genere el archivo de texto para procesar los débitos automáticos del período seleccionado.
</Typography>
<Button onClick={handleGenerarArchivoDebito} variant="contained" color="secondary" disabled={loadingArchivoDebito}>{loadingArchivoDebito ? <CircularProgress size={24} /> : 'Generar Archivo de Débito'}</Button>
</Paper>
<Paper sx={{ p: 2, mb: 3 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Suba el archivo de respuesta del banco para actualizar automáticamente el estado de los pagos.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button variant="outlined" component="label">
Seleccionar Archivo
<input type="file" hidden onChange={(e) => setSelectedFile(e.target.files ? e.target.files[0] : null)} />
</Button>
{selectedFile && <Typography variant="body2">{selectedFile.name}</Typography>}
<Button onClick={handleProcesarRespuesta} variant="contained" color="secondary" disabled={loadingRespuesta || !selectedFile}>{loadingRespuesta ? <CircularProgress size={24} /> : 'Procesar Archivo'}</Button>
</Box>
{respuestaProceso && (
<Alert severity={respuestaProceso.errores.length > 0 ? "warning" : "info"} sx={{ mt: 2 }}>
{respuestaProceso.mensajeResumen}
{respuestaProceso.errores.length > 0 && (
<ul>{respuestaProceso.errores.map((e: string, i: number) => <li key={i}>{e}</li>)}</ul>
)}
</Alert>
)}
</Paper>
</>
)}
</Box>
)}
{activeTab === 1 && (
<TabHistorial
puedeConsultar={puedeGestionarCierre}
onVerDetalles={handleOpenModal}
formatDate={formatDisplayDateTime}
/>
)}
<ResultadoEnvioModal
open={modalOpen}
onClose={() => setModalOpen(false)}
loteId={selectedLoteId}
periodo={selectedLotePeriodo}
/>
</Box>
);
};
export default CierreYProcesosPage;

View File

@@ -1,201 +0,0 @@
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 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';
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 [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007");
const handleGenerarFacturacion = async () => {
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);
setApiMessage(null);
setApiError(null);
try {
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
} 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.`);
} 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 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')}`);
}
} 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>;
}
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>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 y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor.
</Typography>
<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>
</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 || loadingProceso}>
Generar Cierre del Período
</Button>
</Paper>
<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: 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: 1, mb: 1 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<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: 1, 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: 1 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 1 }}>{apiMessage}</Alert>}
</Box>
);
};
export default FacturacionPage;

View File

@@ -83,7 +83,7 @@ import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPag
import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage';
import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage';
import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage';
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
import CierreYProcesosPage from '../pages/Suscripciones/CierreYProcesosPage';
import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage';
import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage';
@@ -206,7 +206,7 @@ const AppRoutes = () => {
<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="procesos" element={<CierreYProcesosPage />} />
<Route path="promociones" element={<GestionarPromocionesPage />} />
</Route>

View File

@@ -1,9 +1,9 @@
import apiClient from '../apiClient';
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';
import type { LoteDeEnvioHistorialDto, LoteDeEnvioResumenDto } from '../../models/dtos/Comunicaciones/LoteDeEnvioDto';
import type { EmailLogDto } from '../../models/dtos/Comunicaciones/EmailLogDto';
const API_URL = '/facturacion';
@@ -32,8 +32,8 @@ const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreS
return response.data;
};
const generarFacturacionMensual = async (anio: number, mes: number): Promise<GenerarFacturacionResponseDto> => {
const response = await apiClient.post<GenerarFacturacionResponseDto>(`${API_URL}/${anio}/${mes}`);
const generarFacturacionMensual = async (anio: number, mes: number): Promise<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }> => {
const response = await apiClient.post<{ message: string, resultadoEnvio: LoteDeEnvioResumenDto }>(`${API_URL}/${anio}/${mes}`);
return response.data;
};
@@ -77,6 +77,23 @@ const getHistorialEnvios = async (idFactura: number): Promise<EmailLogDto[]> =>
return response.data;
};
const getHistorialLotesEnvio = async (anio?: number, mes?: number): Promise<LoteDeEnvioHistorialDto[]> => {
const params = new URLSearchParams();
if (anio) params.append('anio', String(anio));
if (mes) params.append('mes', String(mes));
const queryString = params.toString();
const url = `${API_URL}/historial-lotes-envio${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<LoteDeEnvioHistorialDto[]>(url);
return response.data;
};
const getDetallesLoteEnvio = async (idLote: number): Promise<EmailLogDto[]> => {
const response = await apiClient.get<EmailLogDto[]>(`/lotes-envio/${idLote}/detalles`);
return response.data;
};
export default {
procesarArchivoRespuesta,
getResumenesDeCuentaPorPeriodo,
@@ -87,4 +104,6 @@ export default {
enviarAvisoCuentaPorEmail,
enviarFacturaPdfPorEmail,
getHistorialEnvios,
getHistorialLotesEnvio,
getDetallesLoteEnvio,
};