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>