Refactor: Mejora la lógica de facturación y la UI
Este commit introduce una refactorización significativa en el módulo de
suscripciones para alinear el sistema con reglas de negocio clave:
facturación consolidada por empresa, cobro a mes adelantado con
imputación de ajustes diferida, y una interfaz de usuario más clara.
Backend:
- **Facturación por Empresa:** Se modifica `FacturacionService` para
agrupar las suscripciones por cliente y empresa, generando una
factura consolidada para cada combinación. Esto asegura la correcta
separación fiscal.
- **Imputación de Ajustes:** Se ajusta la lógica para que la facturación
de un período (ej. Septiembre) aplique únicamente los ajustes
pendientes cuya fecha corresponde al período anterior (Agosto).
- **Cierre Secuencial:** Se implementa una validación en
`GenerarFacturacionMensual` que impide generar la facturación de un
período si el anterior no ha sido cerrado, garantizando el orden
cronológico.
- **Emails Consolidados:** El proceso de notificación automática al
generar el cierre ahora envía un único email consolidado por
suscriptor, detallando los cargos de todas sus facturas/empresas.
- **Envío de PDF Individual:** Se refactoriza el endpoint de envío manual
para que opere sobre una `idFactura` individual y adjunte el PDF
correspondiente si existe.
- **Repositorios Mejorados:** Se optimizan y añaden métodos en
`FacturaRepository` y `AjusteRepository` para soportar los nuevos
requisitos de filtrado y consulta de datos consolidados.
Frontend:
- **Separación de Vistas:** La página de "Facturación" se divide en dos:
- `ProcesosPage`: Para acciones masivas (generar cierre, archivo de
débito, procesar respuesta).
- `ConsultaFacturasPage`: Una nueva página dedicada a buscar,
filtrar y gestionar facturas individuales con una interfaz de doble
acordeón (Suscriptor -> Empresa).
- **Filtros Avanzados:** La página `ConsultaFacturasPage` ahora incluye
filtros por nombre de suscriptor, estado de pago y estado de
facturación.
- **Filtros de Fecha por Defecto:** La página de "Cuenta Corriente"
ahora filtra por el mes actual por defecto para mejorar el rendimiento
y la usabilidad.
- **Validación de Fechas:** Se añade lógica en los filtros de fecha para
impedir la selección de rangos inválidos.
- **Validación de Monto de Pago:** El modal de pago manual ahora impide
registrar un monto superior al saldo pendiente de la factura.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { CreateAjusteDto } from '../../../models/dtos/Suscripciones/CreateAjusteDto';
|
||||
import type { UpdateAjusteDto } from '../../../models/dtos/Suscripciones/UpdateAjusteDto';
|
||||
import type { AjusteDto } from '../../../models/dtos/Suscripciones/AjusteDto';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
@@ -12,34 +16,47 @@ const modalStyle = {
|
||||
boxShadow: 24, p: 4,
|
||||
};
|
||||
|
||||
// --- TIPO UNIFICADO PARA EL ESTADO DEL FORMULARIO ---
|
||||
type AjusteFormData = Partial<CreateAjusteDto & UpdateAjusteDto>;
|
||||
|
||||
interface AjusteFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateAjusteDto) => Promise<void>;
|
||||
onSubmit: (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => Promise<void>;
|
||||
initialData?: AjusteDto | null;
|
||||
idSuscriptor: number;
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => {
|
||||
const [formData, setFormData] = useState<Partial<CreateAjusteDto>>({});
|
||||
const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage, initialData }) => {
|
||||
const [formData, setFormData] = useState<AjusteFormData>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Formatear fecha correctamente: el DTO de Ajuste tiene FechaAlta con hora, pero el input necesita "yyyy-MM-dd"
|
||||
const fechaParaFormulario = initialData?.fechaAjuste
|
||||
? initialData.fechaAjuste.split(' ')[0] // Tomar solo la parte de la fecha
|
||||
: new Date().toISOString().split('T')[0];
|
||||
|
||||
setFormData({
|
||||
idSuscriptor: idSuscriptor,
|
||||
tipoAjuste: 'Credito', // Por defecto es un crédito (descuento)
|
||||
monto: 0,
|
||||
motivo: ''
|
||||
idSuscriptor: initialData?.idSuscriptor || idSuscriptor,
|
||||
fechaAjuste: fechaParaFormulario,
|
||||
tipoAjuste: initialData?.tipoAjuste || 'Credito',
|
||||
monto: initialData?.monto || undefined, // undefined para que el placeholder se muestre
|
||||
motivo: initialData?.motivo || ''
|
||||
});
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, idSuscriptor]);
|
||||
}, [open, initialData, idSuscriptor]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.fechaAjuste) errors.fechaAjuste = "La fecha es obligatoria.";
|
||||
if (!formData.tipoAjuste) errors.tipoAjuste = "Seleccione un tipo.";
|
||||
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
|
||||
if (!formData.motivo?.trim()) errors.motivo = "El motivo es obligatorio.";
|
||||
@@ -47,16 +64,20 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// --- HANDLERS CON TIPADO EXPLÍCITO ---
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: name === 'monto' ? parseFloat(value) : value }));
|
||||
setFormData((prev: AjusteFormData) => ({
|
||||
...prev,
|
||||
[name]: name === 'monto' && value !== '' ? parseFloat(value) : value
|
||||
}));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<string>) => { // Tipado como string
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setFormData((prev: AjusteFormData) => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
@@ -68,7 +89,11 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
await onSubmit(formData as CreateAjusteDto);
|
||||
if (isEditing && initialData) {
|
||||
await onSubmit(formData as UpdateAjusteDto, initialData.idAjuste);
|
||||
} else {
|
||||
await onSubmit(formData as CreateAjusteDto);
|
||||
}
|
||||
success = true;
|
||||
} catch (error) {
|
||||
success = false;
|
||||
@@ -81,20 +106,22 @@ const AjusteFormModal: React.FC<AjusteFormModalProps> = ({ open, onClose, onSubm
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">Registrar Ajuste Manual</Typography>
|
||||
<Typography variant="h6">{isEditing ? 'Editar Ajuste Manual' : 'Registrar Ajuste Manual'}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<TextField name="fechaAjuste" label="Fecha del Ajuste" type="date" value={formData.fechaAjuste || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaAjuste} helperText={localErrors.fechaAjuste} />
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoAjuste}>
|
||||
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
|
||||
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
|
||||
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
|
||||
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
|
||||
</Select>
|
||||
<InputLabel id="tipo-ajuste-label" required>Tipo de Ajuste</InputLabel>
|
||||
<Select name="tipoAjuste" labelId="tipo-ajuste-label" value={formData.tipoAjuste || ''} onChange={handleSelectChange} label="Tipo de Ajuste">
|
||||
<MenuItem value="Credito">Crédito (Descuento a favor del cliente)</MenuItem>
|
||||
<MenuItem value="Debito">Débito (Cargo extra al cliente)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="monto" label="Monto" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
|
||||
<TextField name="motivo" label="Motivo" value={formData.motivo || ''} onChange={handleInputChange} required fullWidth margin="dense" multiline rows={3} error={!!localErrors.motivo} helperText={localErrors.motivo} />
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Nota: Este ajuste se aplicará en la facturación del período correspondiente a la "Fecha del Ajuste".
|
||||
</Alert>
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
|
||||
<Button type="submit" variant="contained" disabled={loading}>
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material';
|
||||
import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider, type SelectChangeEvent, TextField } from '@mui/material';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
|
||||
import type { PromocionAsignadaDto } from '../../../models/dtos/Suscripciones/PromocionAsignadaDto';
|
||||
import type { AsignarPromocionDto } from '../../../models/dtos/Suscripciones/AsignarPromocionDto';
|
||||
import suscripcionService from '../../../services/Suscripciones/suscripcionService';
|
||||
|
||||
const modalStyle = { /* ... */ };
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
};
|
||||
|
||||
interface GestionarPromocionesSuscripcionModalProps {
|
||||
open: boolean;
|
||||
@@ -15,12 +29,15 @@ interface GestionarPromocionesSuscripcionModalProps {
|
||||
}
|
||||
|
||||
const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => {
|
||||
const [asignadas, setAsignadas] = useState<PromocionDto[]>([]);
|
||||
const [asignadas, setAsignadas] = useState<PromocionAsignadaDto[]>([]);
|
||||
const [disponibles, setDisponibles] = useState<PromocionDto[]>([]);
|
||||
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedPromo, setSelectedPromo] = useState<number | string>('');
|
||||
const [vigenciaDesde, setVigenciaDesde] = useState('');
|
||||
const [vigenciaHasta, setVigenciaHasta] = useState('');
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
if (!suscripcion) return;
|
||||
setLoading(true);
|
||||
@@ -40,16 +57,30 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
}, [suscripcion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (open && suscripcion) {
|
||||
cargarDatos();
|
||||
setSelectedPromo('');
|
||||
setVigenciaDesde(suscripcion.fechaInicio);
|
||||
setVigenciaHasta('');
|
||||
}
|
||||
}, [open, cargarDatos]);
|
||||
}, [open, suscripcion]);
|
||||
|
||||
const handleAsignar = async () => {
|
||||
if (!suscripcion || !selectedPromo) return;
|
||||
if (!suscripcion || !selectedPromo || !vigenciaDesde) {
|
||||
setError("Debe seleccionar una promoción y una fecha de inicio.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo));
|
||||
const dto: AsignarPromocionDto = {
|
||||
idPromocion: Number(selectedPromo),
|
||||
vigenciaDesde: vigenciaDesde,
|
||||
vigenciaHasta: vigenciaHasta || null
|
||||
};
|
||||
await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, dto);
|
||||
setSelectedPromo('');
|
||||
setVigenciaDesde(suscripcion.fechaInicio);
|
||||
setVigenciaHasta('');
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al asignar la promoción.");
|
||||
@@ -58,14 +89,34 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
|
||||
const handleQuitar = async (idPromocion: number) => {
|
||||
if (!suscripcion) return;
|
||||
try {
|
||||
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al quitar la promoción.");
|
||||
setError(null);
|
||||
if (window.confirm("¿Está seguro de que desea quitar esta promoción de la suscripción?")) {
|
||||
try {
|
||||
await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion);
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || "Error al quitar la promoción.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return 'Indefinida';
|
||||
const parts = dateString.split('-');
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
};
|
||||
|
||||
const formatSecondaryText = (promo: PromocionAsignadaDto): string => {
|
||||
let text = '';
|
||||
switch (promo.tipoEfecto) {
|
||||
case 'DescuentoPorcentajeTotal': text = `Descuento Total: ${promo.valorEfecto}%`; break;
|
||||
case 'DescuentoMontoFijoTotal': text = `Descuento Total: $${promo.valorEfecto.toFixed(2)}`; break;
|
||||
case 'BonificarEntregaDia': text = 'Bonificación de Día'; break;
|
||||
default: text = 'Tipo desconocido';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
if (!suscripcion) return null;
|
||||
|
||||
return (
|
||||
@@ -73,30 +124,39 @@ const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscrip
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">Gestionar Promociones</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Suscripción a: {suscripcion.nombrePublicacion}
|
||||
Suscripción a: <strong>{suscripcion.nombrePublicacion}</strong>
|
||||
</Typography>
|
||||
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{loading ? <CircularProgress /> : (
|
||||
{loading ? <CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> : (
|
||||
<>
|
||||
<Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography>
|
||||
<Typography sx={{ mt: 2, fontWeight: 'medium' }}>Promociones Asignadas</Typography>
|
||||
<List dense>
|
||||
{asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>}
|
||||
{asignadas.map(p => (
|
||||
<ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}>
|
||||
<ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} />
|
||||
<ListItemText
|
||||
primary={p.descripcion}
|
||||
secondary={`Vigente del ${formatDate(p.vigenciaDesdeAsignacion)} al ${formatDate(p.vigenciaHastaAsignacion)} - ${formatSecondaryText(p)}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography>Asignar Nueva Promoción</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<InputLabel>Promociones Disponibles</InputLabel>
|
||||
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}>
|
||||
<Select value={selectedPromo} label="Promociones Disponibles" onChange={(e: SelectChangeEvent<number | string>) => setSelectedPromo(e.target.value)}>
|
||||
{disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField label="Vigencia Desde" type="date" value={vigenciaDesde} onChange={(e) => setVigenciaDesde(e.target.value)} required fullWidth size="small" InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Vigencia Hasta (Opcional)" type="date" value={vigenciaHasta} onChange={(e) => setVigenciaHasta(e.target.value)} fullWidth size="small" InputLabelProps={{ shrink: true }} />
|
||||
</Box>
|
||||
<Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo} sx={{ mt: 2 }} startIcon={<AddCircleOutlineIcon />}>
|
||||
Asignar
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto';
|
||||
@@ -54,7 +52,7 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
|
||||
fetchFormasDePago();
|
||||
setFormData({
|
||||
idFactura: factura.idFactura,
|
||||
monto: factura.importeFinal,
|
||||
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto
|
||||
fechaPago: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setLocalErrors({});
|
||||
@@ -64,8 +62,18 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
|
||||
if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero.";
|
||||
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
|
||||
|
||||
const monto = formData.monto ?? 0;
|
||||
const saldo = factura?.saldoPendiente ?? 0;
|
||||
|
||||
if (monto <= 0) {
|
||||
errors.monto = "El monto debe ser mayor a cero.";
|
||||
} else if (monto > saldo) {
|
||||
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje
|
||||
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
|
||||
}
|
||||
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
@@ -109,8 +117,8 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6">Registrar Pago Manual</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Factura #{factura.idFactura} para {factura.nombreSuscriptor}
|
||||
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}>
|
||||
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox,
|
||||
type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
||||
import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto';
|
||||
import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: '80%', md: '600px' },
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
boxShadow: 24, p: 4,
|
||||
maxHeight: '90vh', overflowY: 'auto'
|
||||
};
|
||||
|
||||
const tiposPromocion = [
|
||||
{ value: 'Porcentaje', label: 'Descuento Porcentual (%)' },
|
||||
{ value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' },
|
||||
// { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones
|
||||
const tiposEfecto = [
|
||||
{ value: 'DescuentoPorcentajeTotal', label: 'Descuento en Porcentaje (%) sobre el total' },
|
||||
{ value: 'DescuentoMontoFijoTotal', label: 'Descuento en Monto Fijo ($) sobre el total' },
|
||||
{ value: 'BonificarEntregaDia', label: 'Bonificar / Día Gratis (Precio del día = $0)' },
|
||||
];
|
||||
const tiposCondicion = [
|
||||
{ value: 'Siempre', label: 'Siempre (en todos los días de entrega)' },
|
||||
{ value: 'DiaDeSemana', label: 'Un día de la semana específico' },
|
||||
{ value: 'PrimerDiaSemanaDelMes', label: 'El primer día de la semana del mes' },
|
||||
];
|
||||
const diasSemana = [
|
||||
{ value: 1, label: 'Lunes' }, { value: 2, label: 'Martes' }, { value: 3, label: 'Miércoles' },
|
||||
{ value: 4, label: 'Jueves' }, { value: 5, label: 'Viernes' }, { value: 6, label: 'Sábado' },
|
||||
{ value: 7, label: 'Domingo' }
|
||||
];
|
||||
|
||||
interface PromocionFormModalProps {
|
||||
@@ -38,18 +43,22 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
const necesitaValorCondicion = formData.tipoCondicion === 'DiaDeSemana' || formData.tipoCondicion === 'PrimerDiaSemanaDelMes';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData || {
|
||||
const defaults = {
|
||||
descripcion: '',
|
||||
tipoPromocion: 'Porcentaje',
|
||||
valor: 0,
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' as const,
|
||||
valorEfecto: 0,
|
||||
tipoCondicion: 'Siempre' as const,
|
||||
valorCondicion: null,
|
||||
fechaInicio: new Date().toISOString().split('T')[0],
|
||||
activa: true
|
||||
});
|
||||
};
|
||||
setFormData(initialData ? { ...initialData } : defaults);
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
@@ -57,10 +66,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.';
|
||||
if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.';
|
||||
if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.';
|
||||
if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) {
|
||||
errors.valor = 'El valor para porcentaje no puede ser mayor a 100.';
|
||||
if (!formData.tipoEfecto) errors.tipoEfecto = 'El tipo de efecto es obligatorio.';
|
||||
if (formData.tipoEfecto !== 'BonificarEntregaDia' && (!formData.valorEfecto || formData.valorEfecto <= 0)) {
|
||||
errors.valorEfecto = 'El valor debe ser mayor a cero.';
|
||||
}
|
||||
if (formData.tipoEfecto === 'DescuentoPorcentajeTotal' && formData.valorEfecto && formData.valorEfecto > 100) {
|
||||
errors.valorEfecto = 'El valor para porcentaje no puede ser mayor a 100.';
|
||||
}
|
||||
if (!formData.tipoCondicion) errors.tipoCondicion = 'La condición es obligatoria.';
|
||||
if (necesitaValorCondicion && !formData.valorCondicion) {
|
||||
errors.valorCondicion = "Debe seleccionar un día para esta condición.";
|
||||
}
|
||||
if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.';
|
||||
if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) {
|
||||
@@ -72,7 +87,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value);
|
||||
const finalValue = type === 'checkbox' ? checked : (name === 'valorEfecto' && value !== '' ? parseFloat(value) : value);
|
||||
setFormData(prev => ({ ...prev, [name]: finalValue }));
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
@@ -80,7 +95,16 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
|
||||
if (name === 'tipoCondicion' && value === 'Siempre') {
|
||||
newFormData.valorCondicion = null;
|
||||
}
|
||||
if (name === 'tipoEfecto' && value === 'BonificarEntregaDia') {
|
||||
newFormData.valorEfecto = 0; // Bonificar no necesita valor
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
@@ -93,11 +117,7 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
fechaFin: formData.fechaFin || null
|
||||
} as CreatePromocionDto | UpdatePromocionDto;
|
||||
|
||||
const dataToSubmit = { ...formData, fechaFin: formData.fechaFin || null } as CreatePromocionDto | UpdatePromocionDto;
|
||||
await onSubmit(dataToSubmit, initialData?.idPromocion);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
@@ -111,32 +131,43 @@ const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose,
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
|
||||
<Typography variant="h6">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}>
|
||||
<InputLabel id="tipo-promo-label" required>Tipo</InputLabel>
|
||||
<Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}>
|
||||
{tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoEfecto}>
|
||||
<InputLabel>Efecto de la Promoción</InputLabel>
|
||||
<Select name="tipoEfecto" value={formData.tipoEfecto || ''} onChange={handleSelectChange} label="Efecto de la Promoción" disabled={loading}>
|
||||
{tiposEfecto.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{formData.tipoEfecto !== 'BonificarEntregaDia' && (
|
||||
<TextField name="valorEfecto" label="Valor" type="number" value={formData.valorEfecto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.valorEfecto} helperText={localErrors.valorEfecto} disabled={loading}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoEfecto === 'DescuentoPorcentajeTotal' ? '%' : '$'}</InputAdornment> }}
|
||||
inputProps={{ step: "0.01" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
)}
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.tipoCondicion}>
|
||||
<InputLabel>Condición de Aplicación</InputLabel>
|
||||
<Select name="tipoCondicion" value={formData.tipoCondicion || ''} onChange={handleSelectChange} label="Condición de Aplicación" disabled={loading}>
|
||||
{tiposCondicion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{necesitaValorCondicion && (
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.valorCondicion}>
|
||||
<InputLabel>Día de la Semana</InputLabel>
|
||||
<Select name="valorCondicion" value={formData.valorCondicion || ''} onChange={handleSelectChange} label="Día de la Semana" disabled={loading}>
|
||||
{diasSemana.map(d => <MenuItem key={d.value} value={d.value}>{d.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||
<TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} />
|
||||
<TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} />
|
||||
</Box>
|
||||
|
||||
<FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
|
||||
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
|
||||
<Button type="submit" variant="contained" disabled={loading}>
|
||||
|
||||
@@ -25,10 +25,10 @@ const modalStyle = {
|
||||
};
|
||||
|
||||
const dias = [
|
||||
{ label: 'Lunes', value: 'L' }, { label: 'Martes', value: 'M' },
|
||||
{ label: 'Miércoles', value: 'X' }, { label: 'Jueves', value: 'J' },
|
||||
{ label: 'Viernes', value: 'V' }, { label: 'Sábado', value: 'S' },
|
||||
{ label: 'Domingo', value: 'D' }
|
||||
{ label: 'Lunes', value: 'Lun' }, { label: 'Martes', value: 'Mar' },
|
||||
{ label: 'Miércoles', value: 'Mie' }, { label: 'Jueves', value: 'Jue' },
|
||||
{ label: 'Viernes', value: 'Vie' }, { label: 'Sábado', value: 'Sab' },
|
||||
{ label: 'Domingo', value: 'Dom' }
|
||||
];
|
||||
|
||||
interface SuscripcionFormModalProps {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Archivo: Frontend/src/components/Modals/Suscripciones/SuscriptorFormModal.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material'; // 1. Importar SelectChangeEvent
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent } from '@mui/material';
|
||||
import type { SuscriptorDto } from '../../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import type { CreateSuscriptorDto } from '../../../models/dtos/Suscripciones/CreateSuscriptorDto';
|
||||
import type { UpdateSuscriptorDto } from '../../../models/dtos/Suscripciones/UpdateSuscriptorDto';
|
||||
@@ -31,9 +29,7 @@ interface SuscriptorFormModalProps {
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage
|
||||
}) => {
|
||||
const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => {
|
||||
const [formData, setFormData] = useState<Partial<CreateSuscriptorDto>>({});
|
||||
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -59,9 +55,18 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
|
||||
if (open) {
|
||||
fetchFormasDePago();
|
||||
setFormData(initialData || {
|
||||
nombreCompleto: '', tipoDocumento: 'DNI', nroDocumento: '', cbu: ''
|
||||
});
|
||||
const dataParaFormulario: Partial<CreateSuscriptorDto> = {
|
||||
nombreCompleto: initialData?.nombreCompleto || '',
|
||||
email: initialData?.email || '',
|
||||
telefono: initialData?.telefono || '',
|
||||
direccion: initialData?.direccion || '',
|
||||
tipoDocumento: initialData?.tipoDocumento || 'DNI',
|
||||
nroDocumento: initialData?.nroDocumento || '',
|
||||
cbu: initialData?.cbu || '',
|
||||
idFormaPagoPreferida: initialData?.idFormaPagoPreferida,
|
||||
observaciones: initialData?.observaciones || ''
|
||||
};
|
||||
setFormData(dataParaFormulario);
|
||||
setLocalErrors({});
|
||||
}
|
||||
}, [open, initialData]);
|
||||
@@ -73,9 +78,15 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
if (!formData.tipoDocumento) errors.tipoDocumento = 'El tipo de documento es obligatorio.';
|
||||
if (!formData.nroDocumento?.trim()) errors.nroDocumento = 'El número de documento es obligatorio.';
|
||||
if (!formData.idFormaPagoPreferida) errors.idFormaPagoPreferida = 'La forma de pago es obligatoria.';
|
||||
if (CBURequerido && (!formData.cbu || formData.cbu.trim().length !== 22)) {
|
||||
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
|
||||
|
||||
if (CBURequerido) {
|
||||
if (!formData.cbu || formData.cbu.trim().length !== 22) {
|
||||
errors.cbu = 'El CBU es obligatorio y debe tener 22 dígitos.';
|
||||
}
|
||||
} else if (formData.cbu && formData.cbu.trim().length > 0 && formData.cbu.trim().length !== 22) {
|
||||
errors.cbu = 'El CBU debe tener 22 dígitos o estar vacío.';
|
||||
}
|
||||
|
||||
if (formData.email && !/^\S+@\S+\.\S+$/.test(formData.email)) {
|
||||
errors.email = 'El formato del email no es válido.';
|
||||
}
|
||||
@@ -86,23 +97,25 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
// 2. Crear un handler específico para los Select
|
||||
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (localErrors[name]) {
|
||||
setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
const newFormData = { ...formData, [name]: value };
|
||||
|
||||
if (name === 'idFormaPagoPreferida') {
|
||||
const formaDePagoSeleccionada = formasDePago.find(fp => fp.idFormaPago === value);
|
||||
if (formaDePagoSeleccionada && !formaDePagoSeleccionada.requiereCBU) {
|
||||
newFormData.cbu = '';
|
||||
}
|
||||
}
|
||||
setFormData(newFormData);
|
||||
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
clearErrorMessage();
|
||||
@@ -111,7 +124,12 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
setLoading(true);
|
||||
let success = false;
|
||||
try {
|
||||
const dataToSubmit = formData as CreateSuscriptorDto | UpdateSuscriptorDto;
|
||||
const dataToSubmit = {
|
||||
...formData,
|
||||
idFormaPagoPreferida: Number(formData.idFormaPagoPreferida),
|
||||
cbu: formData.cbu?.trim() || null
|
||||
} as CreateSuscriptorDto | UpdateSuscriptorDto;
|
||||
|
||||
await onSubmit(dataToSubmit, initialData?.idSuscriptor);
|
||||
success = true;
|
||||
} catch (error) {
|
||||
@@ -140,7 +158,6 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<FormControl margin="dense" sx={{ minWidth: 120 }}>
|
||||
<InputLabel id="tipo-doc-label">Tipo</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="tipo-doc-label" name="tipoDocumento" value={formData.tipoDocumento || 'DNI'} onChange={handleSelectChange} label="Tipo" disabled={loading}>
|
||||
<MenuItem value="DNI">DNI</MenuItem>
|
||||
<MenuItem value="CUIT">CUIT</MenuItem>
|
||||
@@ -151,15 +168,37 @@ const SuscriptorFormModal: React.FC<SuscriptorFormModalProps> = ({
|
||||
</Box>
|
||||
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPagoPreferida}>
|
||||
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
|
||||
{/* 3. Aplicar el nuevo handler a los Selects */}
|
||||
<Select labelId="forma-pago-label" name="idFormaPagoPreferida" value={formData.idFormaPagoPreferida || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loading || loadingFormasPago}>
|
||||
<Select
|
||||
labelId="forma-pago-label"
|
||||
name="idFormaPagoPreferida"
|
||||
value={loadingFormasPago ? '' : formData.idFormaPagoPreferida || ''}
|
||||
onChange={handleSelectChange}
|
||||
label="Forma de Pago"
|
||||
disabled={loading || loadingFormasPago}
|
||||
>
|
||||
{loadingFormasPago && <MenuItem value=""><em>Cargando...</em></MenuItem>}
|
||||
|
||||
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
|
||||
</Select>
|
||||
{localErrors.idFormaPagoPreferida && <Typography color="error" variant="caption">{localErrors.idFormaPagoPreferida}</Typography>}
|
||||
</FormControl>
|
||||
|
||||
{CBURequerido && (
|
||||
<TextField name="cbu" label="CBU" value={formData.cbu || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.cbu} helperText={localErrors.cbu} disabled={loading} inputProps={{ maxLength: 22 }} />
|
||||
<TextField
|
||||
name="cbu"
|
||||
label="CBU"
|
||||
value={formData.cbu || ''}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
error={!!localErrors.cbu}
|
||||
helperText={localErrors.cbu}
|
||||
disabled={loading}
|
||||
inputProps={{ maxLength: 22 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField name="observaciones" label="Observaciones" value={formData.observaciones || ''} onChange={handleInputChange} multiline rows={2} fullWidth margin="dense" disabled={loading} />
|
||||
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface AjusteDto {
|
||||
idAjuste: number;
|
||||
fechaAjuste: string;
|
||||
idSuscriptor: number;
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AsignarPromocionDto {
|
||||
idPromocion: number;
|
||||
vigenciaDesde: string; // "yyyy-MM-dd"
|
||||
vigenciaHasta?: string | null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface CreateAjusteDto {
|
||||
fechaAjuste: string;
|
||||
idSuscriptor: number;
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export interface CreatePromocionDto {
|
||||
descripcion: string;
|
||||
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
|
||||
valor: number;
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
|
||||
valorEfecto: number;
|
||||
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
|
||||
valorCondicion?: number | null;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
activa: boolean;
|
||||
|
||||
@@ -4,6 +4,6 @@ export interface CreateSuscripcionDto {
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string[]; // ["L", "M", "X"]
|
||||
diasEntrega: string[]; // ["Lun", "Mar", "Mie"]
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
export interface FacturaDetalleDto {
|
||||
descripcion: string;
|
||||
importeNeto: number;
|
||||
}
|
||||
|
||||
export interface FacturaDto {
|
||||
idFactura: number;
|
||||
idSuscripcion: number;
|
||||
periodo: string; // "YYYY-MM"
|
||||
fechaEmision: string; // "yyyy-MM-dd"
|
||||
fechaVencimiento: string; // "yyyy-MM-dd"
|
||||
idSuscriptor: number;
|
||||
periodo: string;
|
||||
fechaEmision: string;
|
||||
fechaVencimiento: string;
|
||||
importeFinal: number;
|
||||
estado: string;
|
||||
totalPagado: number;
|
||||
saldoPendiente: number;
|
||||
estadoPago: string;
|
||||
estadoFacturacion: string;
|
||||
numeroFactura?: string | null;
|
||||
|
||||
// Datos enriquecidos para la UI
|
||||
nombreSuscriptor: string;
|
||||
nombrePublicacion: string;
|
||||
detalles: FacturaDetalleDto[]; // <-- AÑADIR ESTA LÍNEA
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type PromocionDto } from "./PromocionDto";
|
||||
|
||||
export interface PromocionAsignadaDto extends PromocionDto {
|
||||
vigenciaDesdeAsignacion: string; // "yyyy-MM-dd"
|
||||
vigenciaHastaAsignacion?: string | null;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
export interface PromocionDto {
|
||||
idPromocion: number;
|
||||
descripcion: string;
|
||||
tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias';
|
||||
valor: number;
|
||||
tipoEfecto: 'DescuentoPorcentajeTotal' | 'DescuentoMontoFijoTotal' | 'BonificarEntregaDia';
|
||||
valorEfecto: number;
|
||||
tipoCondicion: 'Siempre' | 'DiaDeSemana' | 'PrimerDiaSemanaDelMes';
|
||||
valorCondicion?: number | null;
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
activa: boolean;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// DTO para el detalle de cada línea dentro de una factura (cada suscripción)
|
||||
export interface FacturaDetalleDto {
|
||||
descripcion: string;
|
||||
importeNeto: number;
|
||||
}
|
||||
|
||||
// DTO para cada factura individual (por empresa) dentro del resumen consolidado
|
||||
export interface FacturaConsolidadaDto {
|
||||
idFactura: number;
|
||||
nombreEmpresa: string;
|
||||
importeFinal: number;
|
||||
estadoPago: string;
|
||||
estadoFacturacion: string;
|
||||
numeroFactura?: string | null;
|
||||
detalles: FacturaDetalleDto[];
|
||||
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
|
||||
idSuscriptor: number;
|
||||
}
|
||||
|
||||
// DTO principal que agrupa todo por suscriptor para la vista de consulta
|
||||
export interface ResumenCuentaSuscriptorDto {
|
||||
idSuscriptor: number;
|
||||
nombreSuscriptor: string;
|
||||
saldoPendienteTotal: number;
|
||||
importeTotal: number;
|
||||
facturas: FacturaConsolidadaDto[];
|
||||
}
|
||||
@@ -6,6 +6,6 @@ export interface SuscripcionDto {
|
||||
fechaInicio: string; // "yyyy-MM-dd"
|
||||
fechaFin?: string | null;
|
||||
estado: 'Activa' | 'Pausada' | 'Cancelada';
|
||||
diasEntrega: string; // "L,M,X,J,V,S,D"
|
||||
diasEntrega: string; // "Lun,Mar,Mie,Jue,Vie,Sab,Dom"
|
||||
observaciones?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface UpdateAjusteDto {
|
||||
fechaAjuste: string; // "yyyy-MM-dd"
|
||||
tipoAjuste: 'Credito' | 'Debito';
|
||||
monto: number;
|
||||
motivo: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Archivo: Frontend/src/pages/Reportes/ReporteFacturasPublicidadPage.tsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Alert, Paper } from '@mui/material';
|
||||
import reporteService from '../../services/Reportes/reportesService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import SeleccionaReporteFacturasPublicidad from './SeleccionaReporteFacturasPublicidad';
|
||||
|
||||
const ReporteFacturasPublicidadPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVerReporte = isSuperAdmin || tienePermiso("RR010");
|
||||
|
||||
const handleGenerateReport = async (params: { anio: number; mes: number; }) => {
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const { fileContent, fileName } = await reporteService.getReporteFacturasPublicidadPdf(params.anio, params.mes);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([fileContent], { type: 'application/pdf' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (err: any) {
|
||||
let message = 'Ocurrió un error al generar el reporte.';
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
if (err.response.status === 404) {
|
||||
message = "No se encontraron datos para los parámetros seleccionados.";
|
||||
} else if (err.response.data instanceof Blob && err.response.data.type === "application/json") {
|
||||
const errorText = await err.response.data.text();
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
message = errorJson.message || message;
|
||||
} catch {
|
||||
message = errorText || message;
|
||||
}
|
||||
}
|
||||
}
|
||||
setApiError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!puedeVerReporte) {
|
||||
return <Alert severity="error">No tiene permiso para ver este reporte.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'flex-start', pt: 4 }}>
|
||||
<Paper elevation={3} sx={{ borderRadius: '8px' }}>
|
||||
<SeleccionaReporteFacturasPublicidad
|
||||
onGenerarReporte={handleGenerateReport}
|
||||
isLoading={loading}
|
||||
apiErrorMessage={apiError}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReporteFacturasPublicidadPage;
|
||||
@@ -23,6 +23,7 @@ const allReportModules: { category: string; label: string; path: string }[] = [
|
||||
{ category: 'Ctrl. Devoluciones', label: 'Control de Devoluciones', path: 'control-devoluciones' },
|
||||
{ category: 'Novedades de Canillitas', label: 'Novedades de Canillitas', path: 'novedades-canillas' },
|
||||
{ category: 'Listados Distribución', label: 'Dist. Mensual Can/Acc', path: 'listado-distribucion-mensual' },
|
||||
{ category: 'Suscripciones', label: 'Facturas para Publicidad', path: 'suscripciones-facturas-publicidad' },
|
||||
];
|
||||
|
||||
const predefinedCategoryOrder = [
|
||||
@@ -30,6 +31,7 @@ const predefinedCategoryOrder = [
|
||||
'Listados Distribución',
|
||||
'Ctrl. Devoluciones',
|
||||
'Novedades de Canillitas',
|
||||
'Suscripciones',
|
||||
'Existencia Papel',
|
||||
'Movimientos Bobinas',
|
||||
'Consumos Bobinas',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box, Typography, Button, CircularProgress, Alert,
|
||||
FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent
|
||||
} from '@mui/material';
|
||||
|
||||
// --- Constantes para los selectores de fecha ---
|
||||
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
|
||||
const meses = [
|
||||
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
|
||||
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
|
||||
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
|
||||
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
|
||||
];
|
||||
|
||||
interface SeleccionaReporteFacturasPublicidadProps {
|
||||
onGenerarReporte: (params: { anio: number; mes: number; }) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
apiErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
const SeleccionaReporteFacturasPublicidad: React.FC<SeleccionaReporteFacturasPublicidadProps> = ({
|
||||
onGenerarReporte,
|
||||
isLoading,
|
||||
apiErrorMessage
|
||||
}) => {
|
||||
const [anio, setAnio] = useState<number>(new Date().getFullYear());
|
||||
const [mes, setMes] = useState<number>(new Date().getMonth() + 1);
|
||||
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: { [key: string]: string | null } = {};
|
||||
if (!anio) errors.anio = 'Debe seleccionar un año.';
|
||||
if (!mes) errors.mes = 'Debe seleccionar un mes.';
|
||||
setLocalErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleGenerar = () => {
|
||||
if (!validate()) return;
|
||||
onGenerarReporte({ anio, mes });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 300 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Parámetros: Reporte de Facturas para Publicidad
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>
|
||||
Seleccione el período para generar el reporte.
|
||||
<br />
|
||||
Se incluirán todas las suscripciones pagadas que aún están pendientes de facturar.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||
<FormControl fullWidth margin="normal" size="small" error={!!localErrors.mes} disabled={isLoading}>
|
||||
<InputLabel>Mes</InputLabel>
|
||||
<Select value={mes} label="Mes" onChange={(e: SelectChangeEvent<number>) => setMes(e.target.value as number)}>
|
||||
{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth margin="normal" size="small" error={!!localErrors.anio} disabled={isLoading}>
|
||||
<InputLabel>Año</InputLabel>
|
||||
<Select value={anio} label="Año" onChange={(e: SelectChangeEvent<number>) => setAnio(e.target.value as number)}>
|
||||
{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ mt: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button onClick={handleGenerar} variant="contained" disabled={isLoading}>
|
||||
{isLoading ? <CircularProgress size={24} /> : 'Generar Reporte'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeleccionaReporteFacturasPublicidad;
|
||||
280
Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx
Normal file
280
Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText, Collapse, TextField } from '@mui/material';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PaymentIcon from '@mui/icons-material/Payment';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import facturacionService from '../../services/Suscripciones/facturacionService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import type { ResumenCuentaSuscriptorDto, FacturaConsolidadaDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
|
||||
import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal';
|
||||
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
|
||||
|
||||
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
|
||||
const meses = [
|
||||
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
|
||||
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
|
||||
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
|
||||
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
|
||||
];
|
||||
|
||||
const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada'];
|
||||
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
|
||||
|
||||
const SuscriptorRow: React.FC<{
|
||||
resumen: ResumenCuentaSuscriptorDto;
|
||||
handleMenuOpen: (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => void;
|
||||
}> = ({ resumen, handleMenuOpen }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover>
|
||||
<TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography>
|
||||
</TableCell>
|
||||
{/* La cabecera principal ya no tiene acciones */}
|
||||
<TableCell colSpan={5}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
|
||||
<Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell>
|
||||
<TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell>
|
||||
<TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{resumen.facturas.map((factura) => (
|
||||
<TableRow key={factura.idFactura}>
|
||||
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
|
||||
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell>
|
||||
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell>
|
||||
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
|
||||
<TableCell>{factura.numeroFactura || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{/* El menú de acciones vuelve a estar aquí, por factura */}
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const ConsultaFacturasPage: React.FC = () => {
|
||||
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
|
||||
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [apiMessage, setApiMessage] = useState<string | null>(null);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [resumenes, setResumenes] = useState<ResumenCuentaSuscriptorDto[]>([]);
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeConsultar = isSuperAdmin || tienePermiso("SU006");
|
||||
const puedeGestionarFactura = isSuperAdmin || tienePermiso("SU006");
|
||||
const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008");
|
||||
const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009");
|
||||
const [pagoModalOpen, setPagoModalOpen] = useState(false);
|
||||
const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [filtroNombre, setFiltroNombre] = useState('');
|
||||
const [filtroEstadoPago, setFiltroEstadoPago] = useState('');
|
||||
const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState('');
|
||||
|
||||
const cargarResumenesDelPeriodo = useCallback(async () => {
|
||||
if (!puedeConsultar) return;
|
||||
setLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const data = await facturacionService.getResumenesDeCuentaPorPeriodo(
|
||||
selectedAnio,
|
||||
selectedMes,
|
||||
filtroNombre || undefined,
|
||||
filtroEstadoPago || undefined,
|
||||
filtroEstadoFacturacion || undefined
|
||||
);
|
||||
setResumenes(data);
|
||||
} catch (err) {
|
||||
setResumenes([]);
|
||||
setApiError("Error al cargar los resúmenes de cuenta del período.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ejecutar la búsqueda cuando los filtros cambian
|
||||
const timer = setTimeout(() => {
|
||||
cargarResumenesDelPeriodo();
|
||||
}, 500); // Debounce para no buscar en cada tecla
|
||||
return () => clearTimeout(timer);
|
||||
}, [cargarResumenesDelPeriodo]);
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaConsolidadaDto) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedFactura(factura);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => { setAnchorEl(null); };
|
||||
const handleOpenPagoModal = () => { setPagoModalOpen(true); handleMenuClose(); };
|
||||
const handleClosePagoModal = () => { setPagoModalOpen(false); setSelectedFactura(null); };
|
||||
|
||||
const handleSubmitPagoModal = async (data: CreatePagoDto) => {
|
||||
setApiError(null);
|
||||
try {
|
||||
await facturacionService.registrarPagoManual(data);
|
||||
setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`);
|
||||
cargarResumenesDelPeriodo();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.';
|
||||
setApiError(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNumeroFactura = async (factura: FacturaConsolidadaDto) => {
|
||||
const nuevoNumero = prompt("Ingrese el número de factura (ARCA):", factura.numeroFactura || "");
|
||||
handleMenuClose();
|
||||
if (nuevoNumero !== null && nuevoNumero.trim() !== "") {
|
||||
setApiError(null);
|
||||
try {
|
||||
await facturacionService.actualizarNumeroFactura(factura.idFactura, nuevoNumero.trim());
|
||||
setApiMessage(`Número de factura #${factura.idFactura} actualizado.`);
|
||||
cargarResumenesDelPeriodo();
|
||||
} catch (err: any) {
|
||||
setApiError(err.response?.data?.message || 'Error al actualizar el número de factura.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async (idFactura: number) => {
|
||||
if (!window.confirm(`¿Está seguro de enviar la factura #${idFactura} por email? Se adjuntará el PDF si se encuentra.`)) return;
|
||||
setApiMessage(null);
|
||||
setApiError(null);
|
||||
try {
|
||||
await facturacionService.enviarFacturaPdfPorEmail(idFactura);
|
||||
setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`);
|
||||
} catch (err: any) {
|
||||
setApiError(err.response?.data?.message || 'Error al intentar enviar el email.');
|
||||
} finally {
|
||||
handleMenuClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!puedeConsultar) return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Consulta de Facturas de Suscripciones</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6">Filtros</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, my: 2, alignItems: 'center' }}>
|
||||
<FormControl sx={{ minWidth: 150 }} size="small"><InputLabel>Mes</InputLabel><Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select></FormControl>
|
||||
<FormControl sx={{ minWidth: 120 }} size="small"><InputLabel>Año</InputLabel><Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select></FormControl>
|
||||
<TextField
|
||||
label="Buscar por Suscriptor"
|
||||
size="small"
|
||||
value={filtroNombre}
|
||||
onChange={(e) => setFiltroNombre(e.target.value)}
|
||||
sx={{flexGrow: 1, minWidth: '200px'}}
|
||||
/>
|
||||
<FormControl sx={{ minWidth: 200 }} size="small">
|
||||
<InputLabel>Estado de Pago</InputLabel>
|
||||
<Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}>
|
||||
<MenuItem value=""><em>Todos</em></MenuItem>
|
||||
{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ minWidth: 200 }} size="small">
|
||||
<InputLabel>Estado de Facturación</InputLabel>
|
||||
<Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}>
|
||||
<MenuItem value=""><em>Todos</em></MenuItem>
|
||||
{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>}
|
||||
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label="collapsible table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell />
|
||||
<TableCell>Suscriptor</TableCell>
|
||||
<TableCell align="right">Saldo Total / Importe Total</TableCell>
|
||||
<TableCell colSpan={5}></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>)
|
||||
: resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
|
||||
: (resumenes.map(resumen => (<SuscriptorRow key={resumen.idSuscriptor} resumen={resumen} handleMenuOpen={handleMenuOpen} />)))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* El menú de acciones ahora opera sobre la 'selectedFactura' */}
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedFactura && puedeRegistrarPago && (<MenuItem onClick={handleOpenPagoModal} disabled={selectedFactura.estadoPago === 'Pagada' || selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon><ListItemText>Registrar Pago Manual</ListItemText></MenuItem>)}
|
||||
{selectedFactura && puedeGestionarFactura && (<MenuItem onClick={() => handleUpdateNumeroFactura(selectedFactura)} disabled={selectedFactura.estadoPago === 'Anulada'}><ListItemIcon><EditNoteIcon fontSize="small" /></ListItemIcon><ListItemText>Cargar/Modificar Nro. Factura</ListItemText></MenuItem>)}
|
||||
{selectedFactura && puedeEnviarEmail && (
|
||||
<MenuItem
|
||||
onClick={() => handleSendEmail(selectedFactura.idFactura)}
|
||||
disabled={!selectedFactura.numeroFactura || selectedFactura.estadoPago === 'Anulada'}>
|
||||
<ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Enviar Factura (PDF)</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<PagoManualModal
|
||||
open={pagoModalOpen}
|
||||
onClose={handleClosePagoModal}
|
||||
onSubmit={handleSubmitPagoModal}
|
||||
factura={
|
||||
selectedFactura ? {
|
||||
idFactura: selectedFactura.idFactura,
|
||||
nombreSuscriptor: resumenes.find(r => r.idSuscriptor === selectedFactura.idSuscriptor)?.nombreSuscriptor || '',
|
||||
importeFinal: selectedFactura.importeFinal,
|
||||
// Calculamos el saldo pendiente aquí
|
||||
saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, // Simplificación
|
||||
// Rellenamos los campos restantes que el modal podría necesitar, aunque no los use.
|
||||
idSuscriptor: selectedFactura.idSuscriptor, // Corregido para coincidir con FacturaDto
|
||||
periodo: '',
|
||||
fechaEmision: '',
|
||||
fechaVencimiento: '',
|
||||
totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal),
|
||||
estadoPago: selectedFactura.estadoPago,
|
||||
estadoFacturacion: selectedFactura.estadoFacturacion,
|
||||
numeroFactura: selectedFactura.numeroFactura,
|
||||
detalles: selectedFactura.detalles,
|
||||
} : null
|
||||
}
|
||||
errorMessage={apiError}
|
||||
clearErrorMessage={() => setApiError(null)} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsultaFacturasPage;
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, Tooltip, IconButton, TextField } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import ajusteService from '../../services/Suscripciones/ajusteService';
|
||||
import suscriptorService from '../../services/Suscripciones/suscriptorService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
|
||||
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
|
||||
import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto';
|
||||
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal';
|
||||
|
||||
const getInitialDateRange = () => {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
const formatDate = (date: Date) => date.toISOString().split('T')[0];
|
||||
return {
|
||||
fechaDesde: formatDate(firstDay),
|
||||
fechaHasta: formatDate(lastDay)
|
||||
};
|
||||
};
|
||||
|
||||
const CuentaCorrienteSuscriptorPage: React.FC = () => {
|
||||
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
|
||||
const navigate = useNavigate();
|
||||
const idSuscriptor = Number(idSuscriptorStr);
|
||||
|
||||
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [ajustes, setAjustes] = useState<AjusteDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAjuste, setEditingAjuste] = useState<AjusteDto | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(getInitialDateRange().fechaDesde);
|
||||
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(getInitialDateRange().fechaHasta);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeGestionar = isSuperAdmin || tienePermiso("SU011");
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
if (isNaN(idSuscriptor)) {
|
||||
setError("ID de Suscriptor inválido."); setLoading(false); return;
|
||||
}
|
||||
setLoading(true); setApiErrorMessage(null); setError(null);
|
||||
try {
|
||||
const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor);
|
||||
setSuscriptor(suscriptorData);
|
||||
|
||||
const ajustesData = await ajusteService.getAjustesPorSuscriptor(idSuscriptor, filtroFechaDesde || undefined, filtroFechaHasta || undefined);
|
||||
setAjustes(ajustesData);
|
||||
|
||||
} catch (err) {
|
||||
setError("Error al cargar los datos.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idSuscriptor, puedeGestionar, filtroFechaDesde, filtroFechaHasta]);
|
||||
|
||||
useEffect(() => { cargarDatos(); }, [cargarDatos]);
|
||||
|
||||
// --- INICIO DE LA LÓGICA DE SINCRONIZACIÓN DE FECHAS ---
|
||||
const handleFechaDesdeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nuevaFechaDesde = e.target.value;
|
||||
setFiltroFechaDesde(nuevaFechaDesde);
|
||||
// Si la nueva fecha "desde" es posterior a la fecha "hasta", ajusta la fecha "hasta"
|
||||
if (nuevaFechaDesde && filtroFechaHasta && new Date(nuevaFechaDesde) > new Date(filtroFechaHasta)) {
|
||||
setFiltroFechaHasta(nuevaFechaDesde);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFechaHastaChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nuevaFechaHasta = e.target.value;
|
||||
setFiltroFechaHasta(nuevaFechaHasta);
|
||||
// Si la nueva fecha "hasta" es anterior a la fecha "desde", ajusta la fecha "desde"
|
||||
if (nuevaFechaHasta && filtroFechaDesde && new Date(nuevaFechaHasta) < new Date(filtroFechaDesde)) {
|
||||
setFiltroFechaDesde(nuevaFechaHasta);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (ajuste?: AjusteDto) => {
|
||||
setEditingAjuste(ajuste || null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingAjuste(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateAjusteDto | UpdateAjusteDto, id?: number) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (id && editingAjuste) {
|
||||
await ajusteService.updateAjuste(id, data as UpdateAjusteDto);
|
||||
} else {
|
||||
await ajusteService.createAjusteManual(data as CreateAjusteDto);
|
||||
}
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.';
|
||||
setApiErrorMessage(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnularAjuste = async (idAjuste: number) => {
|
||||
if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
await ajusteService.anularAjuste(idAjuste);
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDisplayDate = (dateString: string): string => {
|
||||
if (!dateString) return '';
|
||||
const datePart = dateString.split(' ')[0];
|
||||
const parts = datePart.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
}
|
||||
return dateString;
|
||||
};
|
||||
|
||||
if (loading && !suscriptor) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
|
||||
Volver a Suscriptores
|
||||
</Button>
|
||||
<Typography variant="h5" gutterBottom>Cuenta Corriente de:</Typography>
|
||||
<Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Fecha Desde"
|
||||
type="date"
|
||||
size="small"
|
||||
value={filtroFechaDesde}
|
||||
onChange={handleFechaDesdeChange} // <-- USAR NUEVO HANDLER
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label="Fecha Hasta"
|
||||
type="date"
|
||||
size="small"
|
||||
value={filtroFechaHasta}
|
||||
onChange={handleFechaHastaChange} // <-- USAR NUEVO HANDLER
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
{puedeGestionar && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mt: { xs: 2, sm: 0 } }}>
|
||||
Nuevo Ajuste
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Fecha Ajuste</TableCell>
|
||||
<TableCell>Tipo</TableCell>
|
||||
<TableCell>Motivo</TableCell>
|
||||
<TableCell align="right">Monto</TableCell>
|
||||
<TableCell>Estado</TableCell>
|
||||
<TableCell>Usuario Carga</TableCell>
|
||||
<TableCell align="right">Acciones</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={7} align="center"><CircularProgress size={24} /></TableCell></TableRow>
|
||||
) : ajustes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} align="center">No se encontraron ajustes para los filtros seleccionados.</TableCell></TableRow>
|
||||
) : (
|
||||
ajustes.map(a => (
|
||||
<TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}>
|
||||
<TableCell>{formatDisplayDate(a.fechaAjuste)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} />
|
||||
</TableCell>
|
||||
<TableCell>{a.motivo}</TableCell>
|
||||
<TableCell align="right">${a.monto.toFixed(2)}</TableCell>
|
||||
<TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell>
|
||||
<TableCell>{a.nombreUsuarioAlta}</TableCell>
|
||||
<TableCell align="right">
|
||||
{a.estado === 'Pendiente' && puedeGestionar && (
|
||||
<>
|
||||
<Tooltip title="Editar Ajuste">
|
||||
<IconButton onClick={() => handleOpenModal(a)} size="small">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Anular Ajuste">
|
||||
<IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small">
|
||||
<CancelIcon color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<AjusteFormModal
|
||||
open={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSubmit={handleSubmitModal}
|
||||
idSuscriptor={idSuscriptor}
|
||||
initialData={editingAjuste}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CuentaCorrienteSuscriptorPage;
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ajusteService from '../../services/Suscripciones/ajusteService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
|
||||
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
|
||||
import AjusteFormModal from '../../components/Modals/Suscripciones/AjusteFormModal';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
||||
interface CuentaCorrienteSuscriptorTabProps {
|
||||
idSuscriptor: number;
|
||||
}
|
||||
|
||||
const CuentaCorrienteSuscriptorTab: React.FC<CuentaCorrienteSuscriptorTabProps> = ({ idSuscriptor }) => {
|
||||
const [ajustes, setAjustes] = useState<AjusteDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeGestionar = isSuperAdmin || tienePermiso("SU011");
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
if (!puedeGestionar) {
|
||||
setError("No tiene permiso para ver la cuenta corriente."); setLoading(false); return;
|
||||
}
|
||||
setLoading(true); setApiErrorMessage(null);
|
||||
try {
|
||||
const data = await ajusteService.getAjustesPorSuscriptor(idSuscriptor);
|
||||
setAjustes(data);
|
||||
} catch (err) {
|
||||
setError("Error al cargar los ajustes del suscriptor.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idSuscriptor, puedeGestionar]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarDatos();
|
||||
}, [cargarDatos]);
|
||||
|
||||
const handleSubmitModal = async (data: CreateAjusteDto) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
await ajusteService.createAjusteManual(data);
|
||||
cargarDatos();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el ajuste.';
|
||||
setApiErrorMessage(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnularAjuste = async (idAjuste: number) => {
|
||||
if (window.confirm("¿Está seguro de que desea anular este ajuste? Esta acción no se puede deshacer.")) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
await ajusteService.anularAjuste(idAjuste);
|
||||
cargarDatos(); // Recargar para ver el cambio de estado
|
||||
} catch (err: any) {
|
||||
setApiErrorMessage(err.response?.data?.message || "Error al anular el ajuste.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <CircularProgress />;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Historial de Ajustes</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setModalOpen(true)} disabled={!puedeGestionar}>
|
||||
Nuevo Ajuste
|
||||
</Button>
|
||||
</Paper>
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>}
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Fecha</TableCell><TableCell>Tipo</TableCell><TableCell>Motivo</TableCell>
|
||||
<TableCell align="right">Monto</TableCell><TableCell>Estado</TableCell><TableCell>Usuario</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{ajustes.map(a => (
|
||||
<TableRow key={a.idAjuste} sx={{ '& .MuiTableCell-root': { color: a.estado === 'Anulado' ? 'text.disabled' : 'inherit' }, textDecoration: a.estado === 'Anulado' ? 'line-through' : 'none' }}>
|
||||
<TableCell>{a.fechaAlta}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={a.tipoAjuste} size="small" color={a.tipoAjuste === 'Credito' ? 'success' : 'error'} />
|
||||
</TableCell>
|
||||
<TableCell>{a.motivo}</TableCell>
|
||||
<TableCell align="right">${a.monto.toFixed(2)}</TableCell>
|
||||
<TableCell>{a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''}</TableCell>
|
||||
<TableCell>{a.nombreUsuarioAlta}</TableCell>
|
||||
<TableCell align="right">
|
||||
{a.estado === 'Pendiente' && puedeGestionar && (
|
||||
<Tooltip title="Anular Ajuste">
|
||||
<IconButton onClick={() => handleAnularAjuste(a.idAjuste)} size="small">
|
||||
<CancelIcon color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<AjusteFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSubmit={handleSubmitModal}
|
||||
idSuscriptor={idSuscriptor}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CuentaCorrienteSuscriptorTab;
|
||||
@@ -1,18 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
|
||||
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PaymentIcon from '@mui/icons-material/Payment';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import facturacionService from '../../services/Suscripciones/facturacionService';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
|
||||
import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal';
|
||||
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
|
||||
|
||||
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
|
||||
const meses = [
|
||||
@@ -35,37 +29,14 @@ const FacturacionPage: React.FC = () => {
|
||||
const [loadingProceso, setLoadingProceso] = useState(false);
|
||||
const [apiMessage, setApiMessage] = useState<string | null>(null);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [facturas, setFacturas] = useState<FacturaDto[]>([]);
|
||||
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
|
||||
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007");
|
||||
const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008");
|
||||
const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009");
|
||||
const [pagoModalOpen, setPagoModalOpen] = useState(false);
|
||||
const [selectedFactura, setSelectedFactura] = useState<FacturaDto | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
|
||||
|
||||
const cargarFacturasDelPeriodo = useCallback(async () => {
|
||||
if (!puedeGenerarFacturacion) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes);
|
||||
setFacturas(data);
|
||||
} catch (err) {
|
||||
setFacturas([]);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAnio, selectedMes, puedeGenerarFacturacion]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarFacturasDelPeriodo();
|
||||
}, [cargarFacturasDelPeriodo]);
|
||||
|
||||
const handleGenerarFacturacion = async () => {
|
||||
if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) {
|
||||
if (!window.confirm(`¿Está seguro de generar el cierre para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Se aplicarán los ajustes pendientes del mes anterior y se generarán los nuevos importes a cobrar.`)) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -74,7 +45,6 @@ const FacturacionPage: React.FC = () => {
|
||||
try {
|
||||
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
|
||||
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
|
||||
await cargarFacturasDelPeriodo();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
@@ -103,7 +73,6 @@ const FacturacionPage: React.FC = () => {
|
||||
link.parentNode?.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`);
|
||||
cargarFacturasDelPeriodo();
|
||||
} catch (err: any) {
|
||||
let message = 'Ocurrió un error al generar el archivo.';
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
@@ -119,52 +88,6 @@ const FacturacionPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaDto) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedFactura(factura);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedFactura(null);
|
||||
};
|
||||
|
||||
const handleOpenPagoModal = () => {
|
||||
setPagoModalOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleClosePagoModal = () => {
|
||||
setPagoModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmitPagoModal = async (data: CreatePagoDto) => {
|
||||
setApiError(null);
|
||||
try {
|
||||
await facturacionService.registrarPagoManual(data);
|
||||
setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`);
|
||||
cargarFacturasDelPeriodo();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.';
|
||||
setApiError(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async (idFactura: number) => {
|
||||
if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return;
|
||||
setApiMessage(null);
|
||||
setApiError(null);
|
||||
try {
|
||||
await facturacionService.enviarFacturaPorEmail(idFactura);
|
||||
setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`);
|
||||
} catch (err: any) {
|
||||
setApiError(err.response?.data?.message || 'Error al intentar enviar el email.');
|
||||
} finally {
|
||||
handleMenuClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
setArchivoSeleccionado(event.target.files[0]);
|
||||
@@ -187,7 +110,6 @@ const FacturacionPage: React.FC = () => {
|
||||
if (response.errores?.length > 0) {
|
||||
setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`);
|
||||
}
|
||||
cargarFacturasDelPeriodo(); // Recargar para ver los estados finales
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen
|
||||
? err.response.data.mensajeResumen
|
||||
@@ -203,15 +125,20 @@ const FacturacionPage: React.FC = () => {
|
||||
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
|
||||
}
|
||||
|
||||
if (!puedeGenerarFacturacion) {
|
||||
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6">1. Generación de Facturación</Typography>
|
||||
<Typography variant="h5" gutterBottom>Procesos Mensuales de Suscripciones</Typography>
|
||||
|
||||
<Paper sx={{ p: 1, mb: 1 }}>
|
||||
<Typography variant="h6">1. Generación de Cierre Mensual</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado.
|
||||
Este proceso calcula los importes a cobrar y envía automáticamente una notificación de "Aviso de Vencimiento" a cada suscriptor.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
|
||||
<FormControl sx={{ minWidth: 120 }} size="small">
|
||||
<InputLabel>Mes</InputLabel>
|
||||
<Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select>
|
||||
@@ -221,20 +148,23 @@ const FacturacionPage: React.FC = () => {
|
||||
<Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo}>Generar Facturación del Período</Button>
|
||||
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo || loadingProceso}>
|
||||
Generar Cierre del Período
|
||||
</Button>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
|
||||
<Paper sx={{ p: 1, mb: 1 }}>
|
||||
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
|
||||
<Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Paper sx={{ p: 1, mb: 1 }}>
|
||||
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada".
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Button
|
||||
component="label"
|
||||
role={undefined}
|
||||
@@ -262,58 +192,8 @@ const FacturacionPage: React.FC = () => {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>}
|
||||
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>}
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 4 }}>Facturas del Período</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell><TableCell>Suscriptor</TableCell><TableCell>Publicación</TableCell>
|
||||
<TableCell align="right">Importe</TableCell><TableCell>Estado</TableCell><TableCell>Nro. Factura</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>)
|
||||
: facturas.length === 0 ? (<TableRow><TableCell colSpan={7} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
|
||||
: (facturas.map(f => (
|
||||
<TableRow key={f.idFactura} hover>
|
||||
<TableCell>{f.idFactura}</TableCell>
|
||||
<TableCell>{f.nombreSuscriptor}</TableCell>
|
||||
<TableCell>{f.nombrePublicacion}</TableCell>
|
||||
<TableCell align="right">${f.importeFinal.toFixed(2)}</TableCell>
|
||||
<TableCell><Chip label={f.estado} size="small" color={f.estado === 'Pagada' ? 'success' : (f.estado === 'Rechazada' ? 'error' : 'default')} /></TableCell>
|
||||
<TableCell>{f.numeroFactura || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedFactura && puedeRegistrarPago && (
|
||||
<MenuItem onClick={handleOpenPagoModal}>
|
||||
<ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Registrar Pago Manual</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedFactura && puedeEnviarEmail && (
|
||||
<MenuItem
|
||||
onClick={() => handleSendEmail(selectedFactura.idFactura)}
|
||||
disabled={!selectedFactura.numeroFactura}
|
||||
>
|
||||
<ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Enviar Email</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<PagoManualModal open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} factura={selectedFactura} errorMessage={apiError} clearErrorMessage={() => setApiError(null)} />
|
||||
{apiError && <Alert severity="error" sx={{ my: 1 }}>{apiError}</Alert>}
|
||||
{apiMessage && <Alert severity="success" sx={{ my: 1 }}>{apiMessage}</Alert>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,10 +79,13 @@ const GestionarPromocionesPage: React.FC = () => {
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
};
|
||||
|
||||
const formatTipo = (tipo: string) => {
|
||||
if (tipo === 'MontoFijo') return 'Monto Fijo';
|
||||
if (tipo === 'Porcentaje') return 'Porcentaje';
|
||||
return tipo;
|
||||
const formatTipo = (tipo: PromocionDto['tipoEfecto']) => {
|
||||
switch(tipo) {
|
||||
case 'DescuentoMontoFijoTotal': return 'Monto Fijo';
|
||||
case 'DescuentoPorcentajeTotal': return 'Porcentaje';
|
||||
case 'BonificarEntregaDia': return 'Día Bonificado';
|
||||
default: return tipo;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
@@ -106,7 +109,7 @@ const GestionarPromocionesPage: React.FC = () => {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Tipo</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Efecto</TableCell>
|
||||
<TableCell align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell>
|
||||
<TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell>
|
||||
@@ -121,8 +124,12 @@ const GestionarPromocionesPage: React.FC = () => {
|
||||
promociones.map(p => (
|
||||
<TableRow key={p.idPromocion} hover>
|
||||
<TableCell>{p.descripcion}</TableCell>
|
||||
<TableCell>{formatTipo(p.tipoPromocion)}</TableCell>
|
||||
<TableCell align="right">{p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`}</TableCell>
|
||||
<TableCell>{formatTipo(p.tipoEfecto)}</TableCell>
|
||||
<TableCell align="right">
|
||||
{p.tipoEfecto === 'DescuentoPorcentajeTotal' ? `${p.valorEfecto}%` :
|
||||
p.tipoEfecto === 'DescuentoMontoFijoTotal' ? `$${p.valorEfecto.toFixed(2)}` :
|
||||
'-'}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(p.fechaInicio)}</TableCell>
|
||||
<TableCell>{formatDate(p.fechaFin)}</TableCell>
|
||||
<TableCell align="center">
|
||||
@@ -130,9 +137,11 @@ const GestionarPromocionesPage: React.FC = () => {
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Editar Promoción">
|
||||
<IconButton onClick={() => handleOpenModal(p)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<span>
|
||||
<IconButton onClick={() => handleOpenModal(p)} disabled={!puedeGestionar}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Archivo: Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx
|
||||
// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscripcionesDeClientePage.tsx
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Tooltip } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LoyaltyIcon from '@mui/icons-material/Loyalty';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import suscripcionService from '../../services/Suscripciones/suscripcionService';
|
||||
import suscriptorService from '../../services/Suscripciones/suscriptorService';
|
||||
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto';
|
||||
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
|
||||
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
|
||||
import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
|
||||
@@ -14,11 +18,12 @@ import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscri
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
|
||||
interface SuscripcionesTabProps {
|
||||
idSuscriptor: number;
|
||||
}
|
||||
const GestionarSuscripcionesDeClientePage: React.FC = () => {
|
||||
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
|
||||
const navigate = useNavigate();
|
||||
const idSuscriptor = Number(idSuscriptorStr);
|
||||
|
||||
const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => {
|
||||
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,28 +37,32 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
|
||||
const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
|
||||
|
||||
const cargarDatos = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setApiErrorMessage(null);
|
||||
if (isNaN(idSuscriptor)) {
|
||||
setError("ID de Suscriptor inválido."); setLoading(false); return;
|
||||
}
|
||||
setLoading(true); setApiErrorMessage(null); setError(null);
|
||||
try {
|
||||
const data = await suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor);
|
||||
setSuscripciones(data);
|
||||
const [suscriptorData, suscripcionesData] = await Promise.all([
|
||||
suscriptorService.getSuscriptorById(idSuscriptor),
|
||||
suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor)
|
||||
]);
|
||||
setSuscriptor(suscriptorData);
|
||||
setSuscripciones(suscripcionesData);
|
||||
} catch (err) {
|
||||
setError('Error al cargar las suscripciones del cliente.');
|
||||
setError('Error al cargar los datos.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idSuscriptor]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarDatos();
|
||||
}, [cargarDatos]);
|
||||
useEffect(() => { cargarDatos(); }, [cargarDatos]);
|
||||
|
||||
const handleOpenModal = (suscripcion?: SuscripcionDto) => {
|
||||
setEditingSuscripcion(suscripcion || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingSuscripcion(null);
|
||||
@@ -86,13 +95,18 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
};
|
||||
|
||||
if (loading) return <CircularProgress />;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">Suscripciones Contratadas</Typography>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
|
||||
Volver a Suscriptores
|
||||
</Button>
|
||||
<Typography variant="h5" gutterBottom>Gestionar Suscripciones de:</Typography>
|
||||
<Typography variant="h4" color="primary" gutterBottom>{suscriptor?.nombreCompleto || ''}</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{puedeGestionar && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>
|
||||
Nueva Suscripción
|
||||
@@ -169,4 +183,4 @@ const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuscripcionesTab;
|
||||
export default GestionarSuscripcionesDeClientePage;
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button, CircularProgress, Alert, Tabs, Tab } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import suscriptorService from '../../services/Suscripciones/suscriptorService';
|
||||
import type { SuscriptorDto } from '../../models/dtos/Suscripciones/SuscriptorDto';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import SuscripcionesTab from './SuscripcionesTab';
|
||||
import CuentaCorrienteSuscriptorTab from './CuentaCorrienteSuscriptorTab';
|
||||
|
||||
const GestionarSuscripcionesSuscriptorPage: React.FC = () => {
|
||||
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
|
||||
const navigate = useNavigate();
|
||||
const idSuscriptor = Number(idSuscriptorStr);
|
||||
|
||||
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const puedeVer = isSuperAdmin || tienePermiso("SU001");
|
||||
|
||||
const cargarSuscriptor = useCallback(async () => {
|
||||
if (isNaN(idSuscriptor)) {
|
||||
setError("ID de Suscriptor inválido."); setLoading(false); return;
|
||||
}
|
||||
if (!puedeVer) {
|
||||
setError("No tiene permiso para ver esta sección."); setLoading(false); return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor);
|
||||
setSuscriptor(suscriptorData);
|
||||
} catch (err) {
|
||||
setError('Error al cargar los datos del suscriptor.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idSuscriptor, puedeVer]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarSuscriptor();
|
||||
}, [cargarSuscriptor]);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>;
|
||||
if (!puedeVer) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/suscripciones/suscriptores')} sx={{ mb: 2 }}>
|
||||
Volver a Suscriptores
|
||||
</Button>
|
||||
<Typography variant="h4" gutterBottom>{suscriptor?.nombreCompleto || 'Cargando...'}</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
|
||||
Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 3 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange}>
|
||||
<Tab label="Suscripciones" />
|
||||
<Tab label="Cuenta Corriente / Ajustes" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ pt: 2 }}>
|
||||
{tabValue === 0 && (
|
||||
<SuscripcionesTab idSuscriptor={idSuscriptor} />
|
||||
)}
|
||||
{tabValue === 1 && (
|
||||
<CuentaCorrienteSuscriptorTab idSuscriptor={idSuscriptor} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionarSuscripcionesSuscriptorPage;
|
||||
@@ -1,5 +1,3 @@
|
||||
// Archivo: Frontend/src/pages/Suscripciones/GestionarSuscriptoresPage.tsx
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, CircularProgress, Alert, Chip, ListItemIcon, ListItemText, FormControlLabel } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -16,188 +14,220 @@ import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
|
||||
|
||||
const GestionarSuscriptoresPage: React.FC = () => {
|
||||
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filtroNombre, setFiltroNombre] = useState('');
|
||||
const [filtroNroDoc, setFiltroNroDoc] = useState('');
|
||||
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(15);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null);
|
||||
const [suscriptores, setSuscriptores] = useState<SuscriptorDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filtroNombre, setFiltroNombre] = useState('');
|
||||
const [filtroNroDoc, setFiltroNroDoc] = useState('');
|
||||
const [filtroSoloActivos, setFiltroSoloActivos] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingSuscriptor, setEditingSuscriptor] = useState<SuscriptorDto | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(15);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedRow, setSelectedRow] = useState<SuscriptorDto | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const puedeVer = isSuperAdmin || tienePermiso("SU001");
|
||||
const puedeCrear = isSuperAdmin || tienePermiso("SU002");
|
||||
const puedeModificar = isSuperAdmin || tienePermiso("SU003");
|
||||
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
|
||||
const puedeVer = isSuperAdmin || tienePermiso("SU001");
|
||||
const puedeCrear = isSuperAdmin || tienePermiso("SU002");
|
||||
const puedeModificar = isSuperAdmin || tienePermiso("SU003");
|
||||
const puedeActivarDesactivar = isSuperAdmin || tienePermiso("SU004");
|
||||
const puedeVerSuscripciones = isSuperAdmin || tienePermiso("SU005");
|
||||
const puedeVerCuentaCorriente = isSuperAdmin || tienePermiso("SU011");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cargarSuscriptores = useCallback(async () => {
|
||||
if (!puedeVer) {
|
||||
setError("No tiene permiso para ver esta sección.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos);
|
||||
setSuscriptores(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Error al cargar los suscriptores.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarSuscriptores();
|
||||
}, [cargarSuscriptores]);
|
||||
|
||||
const handleOpenModal = (suscriptor?: SuscriptorDto) => {
|
||||
setEditingSuscriptor(suscriptor || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingSuscriptor(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (id && editingSuscriptor) {
|
||||
await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto);
|
||||
} else {
|
||||
await suscriptorService.createSuscriptor(data as CreateSuscriptorDto);
|
||||
}
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message
|
||||
? err.response.data.message
|
||||
: 'Error al guardar el suscriptor.';
|
||||
setApiErrorMessage(message);
|
||||
throw err; // Re-lanzar para que el modal sepa que falló
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivo = async (suscriptor: SuscriptorDto) => {
|
||||
const action = suscriptor.activo ? 'desactivar' : 'activar';
|
||||
if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (suscriptor.activo) {
|
||||
await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor);
|
||||
} else {
|
||||
await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor);
|
||||
const cargarSuscriptores = useCallback(async () => {
|
||||
if (!puedeVer) {
|
||||
setError("No tiene permiso para ver esta sección.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
const data = await suscriptorService.getAllSuscriptores(filtroNombre, filtroNroDoc, filtroSoloActivos);
|
||||
setSuscriptores(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Error al cargar los suscriptores.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
|
||||
setApiErrorMessage(message);
|
||||
}
|
||||
}, [cargarSuscriptores]);
|
||||
|
||||
const handleOpenModal = (suscriptor?: SuscriptorDto) => {
|
||||
setEditingSuscriptor(suscriptor || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingSuscriptor(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateSuscriptorDto | UpdateSuscriptorDto, id?: number) => {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (id && editingSuscriptor) {
|
||||
await suscriptorService.updateSuscriptor(id, data as UpdateSuscriptorDto);
|
||||
} else {
|
||||
await suscriptorService.createSuscriptor(data as CreateSuscriptorDto);
|
||||
}
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
let message = 'Error al guardar el suscriptor.';
|
||||
if (axios.isAxiosError(err) && err.response?.data?.errors) {
|
||||
const validationErrors = err.response.data.errors;
|
||||
const errorMessages = Object.values(validationErrors).flat();
|
||||
message = errorMessages.join(' ');
|
||||
} else if (axios.isAxiosError(err) && err.response?.data?.message) {
|
||||
message = err.response.data.message;
|
||||
}
|
||||
setApiErrorMessage(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActivo = async (suscriptor: SuscriptorDto) => {
|
||||
const action = suscriptor.activo ? 'desactivar' : 'activar';
|
||||
if (window.confirm(`¿Está seguro de que desea ${action} a ${suscriptor.nombreCompleto}?`)) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
if (suscriptor.activo) {
|
||||
await suscriptorService.deactivateSuscriptor(suscriptor.idSuscriptor);
|
||||
} else {
|
||||
await suscriptorService.activateSuscriptor(suscriptor.idSuscriptor);
|
||||
}
|
||||
cargarSuscriptores();
|
||||
} catch (err: any) {
|
||||
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${action} el suscriptor.`;
|
||||
setApiErrorMessage(message);
|
||||
}
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedRow(suscriptor);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedRow(null);
|
||||
};
|
||||
|
||||
// --- INICIO DE LA CORRECCIÓN CLAVE ---
|
||||
const handleNavigateToSuscripciones = (idSuscriptor: number) => {
|
||||
// La ruta debe ser la ruta completa y final que renderiza el componente
|
||||
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`);
|
||||
handleMenuClose();
|
||||
};
|
||||
// --- FIN DE LA CORRECCIÓN CLAVE ---
|
||||
|
||||
const handleNavigateToCuentaCorriente = (idSuscriptor: number) => {
|
||||
navigate(`/suscripciones/suscriptor/${idSuscriptor}/cuenta-corriente`);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
if (!puedeVer) {
|
||||
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>;
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, suscriptor: SuscriptorDto) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedRow(suscriptor);
|
||||
};
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" />
|
||||
</Box>
|
||||
{puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>}
|
||||
</Paper>
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedRow(null);
|
||||
};
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
const handleNavigateToSuscripciones = (idSuscriptor: number) => {
|
||||
navigate(`/suscripciones/suscriptor/${idSuscriptor}/suscripciones`);
|
||||
handleMenuClose();
|
||||
};
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell>
|
||||
<TableCell>Forma de Pago</TableCell><TableCell>Mail</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell>
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{displayData.map((s) => (
|
||||
<TableRow key={s.idSuscriptor} hover>
|
||||
<TableCell>{s.nombreCompleto}</TableCell>
|
||||
<TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell>
|
||||
<TableCell>{s.direccion}</TableCell>
|
||||
<TableCell>{s.nombreFormaPagoPreferida}</TableCell>
|
||||
<TableCell>{s.email}</TableCell>
|
||||
<TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedRow && puedeModificar && (
|
||||
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Editar Datos</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedRow && puedeVerSuscripciones && (
|
||||
<MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}>
|
||||
<ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Ver Suscripciones</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedRow && puedeVerCuentaCorriente && (
|
||||
<MenuItem onClick={() => handleNavigateToCuentaCorriente(selectedRow.idSuscriptor)}>
|
||||
<ListItemIcon><AccountBalanceWalletIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Ver Cuenta Corriente</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedRow && puedeActivarDesactivar && (
|
||||
<MenuItem onClick={() => handleToggleActivo(selectedRow)}>
|
||||
{selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>}
|
||||
<ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
const displayData = suscriptores.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
if (!puedeVer) {
|
||||
return <Box sx={{ p: 2 }}><Alert severity="error">{error || "No tiene permiso para ver esta sección."}</Alert></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Gestionar Suscriptores</Typography>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
<TextField label="Filtrar por Nombre" variant="outlined" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<TextField label="Filtrar por Nro. Doc" variant="outlined" size="small" value={filtroNroDoc} onChange={(e) => setFiltroNroDoc(e.target.value)} sx={{ flexGrow: 1 }} />
|
||||
<FormControlLabel control={<Switch checked={filtroSoloActivos} onChange={(e) => setFiltroSoloActivos(e.target.checked)} />} label="Solo Activos" />
|
||||
<SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
|
||||
</Box>
|
||||
{puedeCrear && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>Nuevo Suscriptor</Button>}
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Nombre</TableCell><TableCell>Documento</TableCell><TableCell>Dirección</TableCell>
|
||||
<TableCell>Forma de Pago</TableCell><TableCell>Estado</TableCell><TableCell align="right">Acciones</TableCell>
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{displayData.map((s) => (
|
||||
<TableRow key={s.idSuscriptor} hover>
|
||||
<TableCell>{s.nombreCompleto}</TableCell>
|
||||
<TableCell>{s.tipoDocumento} {s.nroDocumento}</TableCell>
|
||||
<TableCell>{s.direccion}</TableCell>
|
||||
<TableCell>{s.nombreFormaPagoPreferida}</TableCell>
|
||||
<TableCell><Chip label={s.activo ? 'Activo' : 'Inactivo'} color={s.activo ? 'success' : 'default'} size="small" /></TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, s)}><MoreVertIcon /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination rowsPerPageOptions={[15, 25, 50]} component="div" count={suscriptores.length} rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedRow && puedeModificar && <MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><ListItemIcon><EditIcon fontSize="small" /></ListItemIcon><ListItemText>Editar</ListItemText></MenuItem>}
|
||||
{selectedRow && <MenuItem onClick={() => handleNavigateToSuscripciones(selectedRow.idSuscriptor)}><ListItemIcon><ArticleIcon fontSize="small" /></ListItemIcon><ListItemText>Ver Suscripciones</ListItemText></MenuItem>}
|
||||
{selectedRow && puedeActivarDesactivar && (
|
||||
<MenuItem onClick={() => handleToggleActivo(selectedRow)}>
|
||||
{selectedRow.activo ? <ListItemIcon><ToggleOffIcon fontSize="small" /></ListItemIcon> : <ListItemIcon><ToggleOnIcon fontSize="small" /></ListItemIcon>}
|
||||
<ListItemText>{selectedRow.activo ? 'Desactivar' : 'Activar'}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<SuscriptorFormModal open={modalOpen} onClose={handleCloseModal} onSubmit={handleSubmitModal} initialData={editingSuscriptor} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} />
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionarSuscriptoresPage;
|
||||
@@ -3,80 +3,76 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
|
||||
// Define las pestañas del módulo. Ajusta los permisos según sea necesario.
|
||||
const suscripcionesSubModules = [
|
||||
{ label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' },
|
||||
{ label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' },
|
||||
{ label: 'Consulta Pagos y Facturas', path: 'consulta-facturas', requiredPermission: 'SU006' },
|
||||
{ label: 'Cierre y Procesos', path: 'procesos', requiredPermission: 'SU006' },
|
||||
{ label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' },
|
||||
];
|
||||
|
||||
const SuscripcionesIndexPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
|
||||
|
||||
// Filtra los sub-módulos a los que el usuario tiene acceso
|
||||
const accessibleSubModules = suscripcionesSubModules.filter(
|
||||
(subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessibleSubModules.length === 0) {
|
||||
// Si no tiene acceso a ningún submódulo, no hacemos nada.
|
||||
// El enrutador principal debería manejar esto.
|
||||
return;
|
||||
}
|
||||
|
||||
const currentBasePath = '/suscripciones';
|
||||
const subPath = location.pathname.startsWith(`${currentBasePath}/`)
|
||||
? location.pathname.substring(currentBasePath.length + 1)
|
||||
: (location.pathname === currentBasePath ? accessibleSubModules[0]?.path : undefined);
|
||||
|
||||
const activeTabIndex = accessibleSubModules.findIndex(
|
||||
(subModule) => subModule.path === subPath
|
||||
);
|
||||
|
||||
if (activeTabIndex !== -1) {
|
||||
setSelectedSubTab(activeTabIndex);
|
||||
} else if (location.pathname === currentBasePath) {
|
||||
navigate(accessibleSubModules[0].path, { replace: true });
|
||||
} else {
|
||||
setSelectedSubTab(false);
|
||||
}
|
||||
}, [location.pathname, navigate, accessibleSubModules]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions();
|
||||
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
|
||||
|
||||
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
navigate(accessibleSubModules[newValue].path);
|
||||
};
|
||||
const accessibleSubModules = suscripcionesSubModules.filter(
|
||||
(subModule) => isSuperAdmin || tienePermiso(subModule.requiredPermission)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessibleSubModules.length === 0) return;
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const basePath = '/suscripciones';
|
||||
|
||||
// Encuentra la pestaña que mejor coincide con la ruta actual
|
||||
const activeTabIndex = accessibleSubModules.findIndex(subModule =>
|
||||
currentPath.startsWith(`${basePath}/${subModule.path}`)
|
||||
);
|
||||
|
||||
if (accessibleSubModules.length === 0) {
|
||||
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
|
||||
<Paper square elevation={1}>
|
||||
<Tabs
|
||||
value={selectedSubTab}
|
||||
onChange={handleSubTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="sub-módulos de suscripciones"
|
||||
>
|
||||
{accessibleSubModules.map((subModule) => (
|
||||
<Tab key={subModule.path} label={subModule.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Outlet />
|
||||
if (activeTabIndex !== -1) {
|
||||
setSelectedSubTab(activeTabIndex);
|
||||
} else if (currentPath === basePath && accessibleSubModules.length > 0) {
|
||||
// Si estamos en la raíz del módulo, redirigir a la primera pestaña accesible
|
||||
navigate(accessibleSubModules[0].path, { replace: true });
|
||||
} else {
|
||||
setSelectedSubTab(false); // Ninguna pestaña activa
|
||||
}
|
||||
}, [location.pathname, navigate, accessibleSubModules]);
|
||||
|
||||
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
navigate(accessibleSubModules[newValue].path);
|
||||
};
|
||||
|
||||
if (accessibleSubModules.length === 0) {
|
||||
return <Typography sx={{ p: 2 }}>No tiene permisos para acceder a este módulo.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Módulo de Suscripciones</Typography>
|
||||
<Paper square elevation={1}>
|
||||
<Tabs
|
||||
value={selectedSubTab}
|
||||
onChange={handleSubTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="sub-módulos de suscripciones"
|
||||
>
|
||||
{accessibleSubModules.map((subModule) => (
|
||||
<Tab key={subModule.path} label={subModule.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
{/* Aquí se renderizará el componente de la sub-ruta activa */}
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default SuscripcionesIndexPage;
|
||||
@@ -75,13 +75,16 @@ import ReporteControlDevolucionesPage from '../pages/Reportes/ReporteControlDevo
|
||||
import GestionarNovedadesCanillaPage from '../pages/Distribucion/GestionarNovedadesCanillaPage';
|
||||
import ReporteNovedadesCanillasPage from '../pages/Reportes/ReporteNovedadesCanillasPage';
|
||||
import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistMensualPage';
|
||||
import ReporteFacturasPublicidadPage from '../pages/Reportes/ReporteFacturasPublicidadPage';
|
||||
|
||||
// Suscripciones
|
||||
import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage';
|
||||
import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage';
|
||||
import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage';
|
||||
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
|
||||
import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage';
|
||||
import ConsultaFacturasPage from '../pages/Suscripciones/ConsultaFacturasPage';
|
||||
import FacturacionPage from '../pages/Suscripciones/FacturacionPage';
|
||||
import GestionarSuscripcionesDeClientePage from '../pages/Suscripciones/GestionarSuscripcionesDeClientePage';
|
||||
import CuentaCorrienteSuscriptorPage from '../pages/Suscripciones/CuentaCorrienteSuscriptorPage';
|
||||
|
||||
// Anonalías
|
||||
import AlertasPage from '../pages/Anomalia/AlertasPage';
|
||||
@@ -185,36 +188,44 @@ const AppRoutes = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* --- Módulo de Suscripciones --- */}
|
||||
{/* Módulo de Suscripciones */}
|
||||
<Route
|
||||
path="/suscripciones"
|
||||
path="suscripciones"
|
||||
element={
|
||||
<SectionProtectedRoute requiredPermission="SS007" sectionName="Suscripciones">
|
||||
<SuscripcionesIndexPage />
|
||||
{/* Este Outlet es para las sub-rutas anidadas */}
|
||||
<Outlet />
|
||||
</SectionProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="suscriptores" replace />} />
|
||||
<Route path="suscriptores" element={
|
||||
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscriptores">
|
||||
<GestionarSuscriptoresPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
<Route path="suscriptor/:idSuscriptor" element={
|
||||
<SectionProtectedRoute requiredPermission="SU001" sectionName="Suscripciones del Cliente">
|
||||
<GestionarSuscripcionesSuscriptorPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
<Route path="facturacion" element={
|
||||
<SectionProtectedRoute requiredPermission="SU006" sectionName="Facturación de Suscripciones">
|
||||
<FacturacionPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
<Route path="promociones" element={
|
||||
<SectionProtectedRoute requiredPermission="SU010" sectionName="Promociones">
|
||||
<GestionarPromocionesPage />
|
||||
</SectionProtectedRoute>
|
||||
} />
|
||||
{/* 1. Ruta para el layout con pestañas */}
|
||||
<Route
|
||||
element={<SuscripcionesIndexPage />}
|
||||
>
|
||||
<Route index element={<Navigate to="suscriptores" replace />} />
|
||||
<Route path="suscriptores" element={<GestionarSuscriptoresPage />} />
|
||||
<Route path="consulta-facturas" element={<ConsultaFacturasPage />} />
|
||||
<Route path="procesos" element={<FacturacionPage />} />
|
||||
<Route path="promociones" element={<GestionarPromocionesPage />} />
|
||||
</Route>
|
||||
|
||||
{/* 2. Rutas de detalle que NO usan el layout de pestañas */}
|
||||
<Route
|
||||
path="suscriptor/:idSuscriptor/suscripciones"
|
||||
element={
|
||||
<SectionProtectedRoute requiredPermission="SU005" sectionName="Gestionar Suscripciones de Cliente">
|
||||
<GestionarSuscripcionesDeClientePage />
|
||||
</SectionProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="suscriptor/:idSuscriptor/cuenta-corriente"
|
||||
element={
|
||||
<SectionProtectedRoute requiredPermission="SU011" sectionName="Cuenta Corriente del Suscriptor">
|
||||
<CuentaCorrienteSuscriptorPage />
|
||||
</SectionProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Módulo Contable (anidado) */}
|
||||
@@ -273,6 +284,11 @@ const AppRoutes = () => {
|
||||
<Route path="control-devoluciones" element={<ReporteControlDevolucionesPage />} />
|
||||
<Route path="novedades-canillas" element={<ReporteNovedadesCanillasPage />} />
|
||||
<Route path="listado-distribucion-mensual" element={<ReporteListadoDistMensualPage />} />
|
||||
<Route path="suscripciones-facturas-publicidad" element={
|
||||
<SectionProtectedRoute requiredPermission="RR010" sectionName="Reporte Facturas a Publicidad">
|
||||
<ReporteFacturasPublicidadPage />
|
||||
</SectionProtectedRoute>
|
||||
}/>
|
||||
</Route>
|
||||
|
||||
{/* Módulo de Radios (anidado) */}
|
||||
|
||||
@@ -445,6 +445,25 @@ const getListadoDistMensualPorPublicacionPdf = async (params: GetListadoDistMens
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getReporteFacturasPublicidadPdf = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => {
|
||||
const params = new URLSearchParams({ anio: String(anio), mes: String(mes) });
|
||||
const url = `/reportes/suscripciones/facturas-para-publicidad/pdf?${params.toString()}`;
|
||||
const response = await apiClient.get(url, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let fileName = `ReportePublicidad_Suscripciones_${anio}-${String(mes).padStart(2, '0')}.pdf`; // Fallback
|
||||
if (contentDisposition) {
|
||||
const fileNameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (fileNameMatch && fileNameMatch.length > 1) {
|
||||
fileName = fileNameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return { fileContent: response.data, fileName: fileName };
|
||||
};
|
||||
|
||||
const reportesService = {
|
||||
getExistenciaPapel,
|
||||
getExistenciaPapelPdf,
|
||||
@@ -487,7 +506,8 @@ const reportesService = {
|
||||
getListadoDistMensualDiarios,
|
||||
getListadoDistMensualDiariosPdf,
|
||||
getListadoDistMensualPorPublicacion,
|
||||
getListadoDistMensualPorPublicacionPdf,
|
||||
getListadoDistMensualPorPublicacionPdf,
|
||||
getReporteFacturasPublicidadPdf,
|
||||
};
|
||||
|
||||
export default reportesService;
|
||||
@@ -1,12 +1,26 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
|
||||
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
|
||||
import type { UpdateAjusteDto } from '../../models/dtos/Suscripciones/UpdateAjusteDto';
|
||||
|
||||
const API_URL_BY_SUSCRIPTOR = '/suscriptores';
|
||||
const API_URL_BASE = '/ajustes';
|
||||
|
||||
const getAjustesPorSuscriptor = async (idSuscriptor: number): Promise<AjusteDto[]> => {
|
||||
const response = await apiClient.get<AjusteDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`);
|
||||
const getAjustesPorSuscriptor = async (idSuscriptor: number, fechaDesde?: string, fechaHasta?: string): Promise<AjusteDto[]> => {
|
||||
// URLSearchParams nos ayuda a construir la query string de forma segura y limpia
|
||||
const params = new URLSearchParams();
|
||||
if (fechaDesde) {
|
||||
params.append('fechaDesde', fechaDesde);
|
||||
}
|
||||
if (fechaHasta) {
|
||||
params.append('fechaHasta', fechaHasta);
|
||||
}
|
||||
|
||||
// Si hay parámetros, los añadimos a la URL. Si no, la URL queda limpia.
|
||||
const queryString = params.toString();
|
||||
const url = `${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiClient.get<AjusteDto[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -19,8 +33,13 @@ const anularAjuste = async (idAjuste: number): Promise<void> => {
|
||||
await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`);
|
||||
};
|
||||
|
||||
const updateAjuste = async (idAjuste: number, data: UpdateAjusteDto): Promise<void> => {
|
||||
await apiClient.put(`${API_URL_BASE}/${idAjuste}`, data);
|
||||
};
|
||||
|
||||
export default {
|
||||
getAjustesPorSuscriptor,
|
||||
createAjusteManual,
|
||||
anularAjuste
|
||||
anularAjuste,
|
||||
updateAjuste,
|
||||
};
|
||||
@@ -1,29 +1,33 @@
|
||||
import apiClient from '../apiClient';
|
||||
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
|
||||
import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto';
|
||||
import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto';
|
||||
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
|
||||
import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto';
|
||||
import type { ResumenCuentaSuscriptorDto } from '../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
|
||||
|
||||
const API_URL = '/facturacion';
|
||||
const DEBITOS_URL = '/debitos';
|
||||
const PAGOS_URL = '/pagos';
|
||||
const FACTURAS_URL = '/facturas';
|
||||
|
||||
const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => {
|
||||
const formData = new FormData();
|
||||
formData.append('archivo', archivo);
|
||||
|
||||
const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => {
|
||||
const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`);
|
||||
const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor);
|
||||
if (estadoPago) params.append('estadoPago', estadoPago);
|
||||
if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion);
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiClient.get<ResumenCuentaSuscriptorDto[]>(url);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -36,7 +40,6 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
|
||||
const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let fileName = `debito_${anio}_${mes}.txt`;
|
||||
if (contentDisposition) {
|
||||
@@ -45,30 +48,35 @@ const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileCo
|
||||
fileName = fileNameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return { fileContent: response.data, fileName: fileName };
|
||||
};
|
||||
|
||||
const getPagosPorFactura = async (idFactura: number): Promise<PagoDto[]> => {
|
||||
const response = await apiClient.get<PagoDto[]>(`${FACTURAS_URL}/${idFactura}/pagos`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => {
|
||||
const response = await apiClient.post<PagoDto>(PAGOS_URL, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => {
|
||||
await apiClient.post(`${API_URL}/${idFactura}/enviar-email`);
|
||||
const actualizarNumeroFactura = async (idFactura: number, numeroFactura: string): Promise<void> => {
|
||||
await apiClient.put(`${API_URL}/${idFactura}/numero-factura`, `"${numeroFactura}"`, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
const enviarAvisoCuentaPorEmail = async (anio: number, mes: number, idSuscriptor: number): Promise<void> => {
|
||||
await apiClient.post(`${API_URL}/${anio}/${mes}/suscriptor/${idSuscriptor}/enviar-aviso`);
|
||||
};
|
||||
|
||||
const enviarFacturaPdfPorEmail = async (idFactura: number): Promise<void> => {
|
||||
await apiClient.post(`${API_URL}/${idFactura}/enviar-factura-pdf`);
|
||||
};
|
||||
|
||||
export default {
|
||||
procesarArchivoRespuesta,
|
||||
getFacturasPorPeriodo,
|
||||
getResumenesDeCuentaPorPeriodo,
|
||||
generarFacturacionMensual,
|
||||
generarArchivoDebito,
|
||||
getPagosPorFactura,
|
||||
registrarPagoManual,
|
||||
enviarFacturaPorEmail,
|
||||
actualizarNumeroFactura,
|
||||
enviarAvisoCuentaPorEmail,
|
||||
enviarFacturaPdfPorEmail,
|
||||
};
|
||||
@@ -3,12 +3,15 @@ import type { SuscripcionDto } from '../../models/dtos/Suscripciones/Suscripcion
|
||||
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
|
||||
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
|
||||
import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto';
|
||||
import type { PromocionAsignadaDto } from '../../models/dtos/Suscripciones/PromocionAsignadaDto';
|
||||
import type { AsignarPromocionDto } from '../../models/dtos/Suscripciones/AsignarPromocionDto';
|
||||
|
||||
const API_URL_BASE = '/suscripciones';
|
||||
const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada
|
||||
const API_URL_SUSCRIPTORES = '/suscriptores';
|
||||
|
||||
const getSuscripcionesPorSuscriptor = async (idSuscriptor: number): Promise<SuscripcionDto[]> => {
|
||||
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/suscripciones`);
|
||||
// La URL correcta es /suscriptores/{id}/suscripciones, no /suscripciones/suscriptor/...
|
||||
const response = await apiClient.get<SuscripcionDto[]>(`${API_URL_SUSCRIPTORES}/${idSuscriptor}/suscripciones`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -26,8 +29,8 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis
|
||||
await apiClient.put(`${API_URL_BASE}/${id}`, data);
|
||||
};
|
||||
|
||||
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => {
|
||||
const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
|
||||
const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionAsignadaDto[]> => {
|
||||
const response = await apiClient.get<PromocionAsignadaDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -36,8 +39,8 @@ const getPromocionesDisponibles = async (idSuscripcion: number): Promise<Promoci
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
|
||||
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`);
|
||||
const asignarPromocion = async (idSuscripcion: number, data: AsignarPromocionDto): Promise<void> => {
|
||||
await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones`, data);
|
||||
};
|
||||
|
||||
const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => {
|
||||
@@ -52,5 +55,5 @@ export default {
|
||||
getPromocionesAsignadas,
|
||||
getPromocionesDisponibles,
|
||||
asignarPromocion,
|
||||
quitarPromocion
|
||||
quitarPromocion,
|
||||
};
|
||||
Reference in New Issue
Block a user