Feat: Implementa ABM y anulación de ajustes manuales

Este commit introduce la funcionalidad completa para la gestión de
ajustes manuales (créditos/débitos) en la cuenta corriente de un
suscriptor, cerrando un requerimiento clave detectado en el análisis
del flujo de trabajo manual.

Backend:
- Se añade la tabla `susc_Ajustes` para registrar movimientos manuales.
- Se crean el Modelo, DTOs, Repositorio y Servicio (`AjusteService`)
  para el ABM completo de los ajustes.
- Se implementa la lógica para anular ajustes que se encuentren en estado
  "Pendiente", registrando el usuario y fecha de anulación para
  mantener la trazabilidad.
- Se integra la lógica de aplicación de ajustes pendientes en el
  `FacturacionService`, afectando el `ImporteFinal` de la factura
  generada.
- Se añaden los nuevos endpoints en `AjustesController` para crear,
  listar y anular ajustes.

Frontend:
- Se crea el componente `CuentaCorrienteSuscriptorTab` para mostrar
  el historial de ajustes de un cliente.
- Se desarrolla el modal `AjusteFormModal` que permite a los usuarios
  registrar nuevos créditos o débitos.
- Se integra una nueva pestaña "Cuenta Corriente / Ajustes" en la
  vista de gestión de un suscriptor.
- Se añade la funcionalidad de "Anular" en la tabla de ajustes,
  permitiendo a los usuarios corregir errores antes del ciclo de
  facturación.
This commit is contained in:
2025-08-01 14:38:15 -03:00
parent 9e248efc84
commit 9cfe9d012e
16 changed files with 857 additions and 144 deletions

View File

@@ -0,0 +1,110 @@
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';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '500px' },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24, p: 4,
};
interface AjusteFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateAjusteDto) => Promise<void>;
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 [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
if (open) {
setFormData({
idSuscriptor: idSuscriptor,
tipoAjuste: 'Credito', // Por defecto es un crédito (descuento)
monto: 0,
motivo: ''
});
setLocalErrors({});
}
}, [open, idSuscriptor]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
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.";
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: name === 'monto' ? parseFloat(value) : value }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSelectChange = (e: SelectChangeEvent<any>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
clearErrorMessage();
if (!validate()) return;
setLoading(true);
let success = false;
try {
await onSubmit(formData as CreateAjusteDto);
success = true;
} catch (error) {
success = false;
} finally {
setLoading(false);
if (success) onClose();
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6">Registrar Ajuste Manual</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<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>
</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} />
{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}>
{loading ? <CircularProgress size={24} /> : 'Guardar Ajuste'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default AjusteFormModal;

View File

@@ -0,0 +1,11 @@
export interface AjusteDto {
idAjuste: number;
idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
estado: 'Pendiente' | 'Aplicado' | 'Anulado';
idFacturaAplicado?: number | null;
fechaAlta: string; // "yyyy-MM-dd HH:mm"
nombreUsuarioAlta: string;
}

View File

@@ -0,0 +1,6 @@
export interface CreateAjusteDto {
idSuscriptor: number;
tipoAjuste: 'Credito' | 'Debito';
monto: number;
motivo: string;
}

View File

@@ -0,0 +1,125 @@
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;

View File

@@ -1,20 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button, Paper, IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert, Chip, Tooltip } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import { Box, Typography, Button, CircularProgress, Alert, Tabs, Tab } from '@mui/material';
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 SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import LoyaltyIcon from '@mui/icons-material/Loyalty';
import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal';
import SuscripcionesTab from './SuscripcionesTab';
import CuentaCorrienteSuscriptorTab from './CuentaCorrienteSuscriptorTab';
const GestionarSuscripcionesSuscriptorPage: React.FC = () => {
const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>();
@@ -22,173 +14,68 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => {
const idSuscriptor = Number(idSuscriptorStr);
const [suscriptor, setSuscriptor] = useState<SuscriptorDto | null>(null);
const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingSuscripcion, setEditingSuscripcion] = useState<SuscripcionDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const [promocionesModalOpen, setPromocionesModalOpen] = useState(false);
const [selectedSuscripcion, setSelectedSuscripcion] = useState<SuscripcionDto | null>(null);
const [tabValue, setTabValue] = useState(0);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso("SU001");
const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
const cargarDatos = useCallback(async () => {
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); setError(null); setApiErrorMessage(null);
setLoading(true);
try {
const [suscriptorData, suscripcionesData] = await Promise.all([
suscriptorService.getSuscriptorById(idSuscriptor),
suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor)
]);
const suscriptorData = await suscriptorService.getSuscriptorById(idSuscriptor);
setSuscriptor(suscriptorData);
setSuscripciones(suscripcionesData);
} catch (err) {
setError('Error al cargar los datos del suscriptor y sus suscripciones.');
setError('Error al cargar los datos del suscriptor.');
} finally {
setLoading(false);
}
}, [idSuscriptor, puedeVer]);
useEffect(() => { cargarDatos(); }, [cargarDatos]);
const handleOpenModal = (suscripcion?: SuscripcionDto) => {
setEditingSuscripcion(suscripcion || null);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingSuscripcion(null);
};
const handleSubmitModal = async (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => {
setApiErrorMessage(null);
try {
if (id && editingSuscripcion) {
await suscripcionService.updateSuscripcion(id, data as UpdateSuscripcionDto);
} else {
await suscripcionService.createSuscripcion(data as CreateSuscripcionDto);
}
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la suscripción.';
setApiErrorMessage(message);
throw err;
}
};
const handleOpenPromocionesModal = (suscripcion: SuscripcionDto) => {
setSelectedSuscripcion(suscripcion);
setPromocionesModalOpen(true);
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Indefinido';
// Asume que la fecha viene como "yyyy-MM-dd"
const parts = dateString.split('-');
return `${parts[2]}/${parts[1]}/${parts[0]}`;
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>
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>Suscripciones de: {suscriptor?.nombreCompleto || 'Cargando...'}</Typography>
<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>
{puedeGestionar && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ my: 2 }}>Nueva Suscripción</Button>}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 3 }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Suscripciones" />
<Tab label="Cuenta Corriente / Ajustes" />
</Tabs>
</Box>
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Publicación</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Días Entrega</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Inicio</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Fin</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Observaciones</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{suscripciones.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">Este cliente no tiene suscripciones.</TableCell></TableRow>
) : (
suscripciones.map((s) => (
<TableRow key={s.idSuscripcion} hover>
<TableCell>{s.nombrePublicacion}</TableCell>
<TableCell>
<Chip
label={s.estado}
color={s.estado === 'Activa' ? 'success' : s.estado === 'Pausada' ? 'warning' : 'default'}
size="small"
/>
</TableCell>
<TableCell>{s.diasEntrega.split(',').join(', ')}</TableCell>
<TableCell>{formatDate(s.fechaInicio)}</TableCell>
<TableCell>{formatDate(s.fechaFin)}</TableCell>
<TableCell>{s.observaciones || '-'}</TableCell>
<TableCell align="right">
<Tooltip title="Editar Suscripción">
<span>
<IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}>
<EditIcon />
</IconButton>
</span>
</Tooltip>
</TableCell>
<TableCell align="right">
<Tooltip title="Editar Suscripción">
<span><IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}><EditIcon /></IconButton></span>
</Tooltip>
<Tooltip title="Gestionar Promociones">
<span><IconButton onClick={() => handleOpenPromocionesModal(s)} disabled={!puedeGestionar}><LoyaltyIcon /></IconButton></span>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{idSuscriptor &&
<SuscripcionFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
idSuscriptor={idSuscriptor}
initialData={editingSuscripcion}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
}
<GestionarPromocionesSuscripcionModal
open={promocionesModalOpen}
onClose={() => setPromocionesModalOpen(false)}
suscripcion={selectedSuscripcion}
/>
<Box sx={{ pt: 2 }}>
{tabValue === 0 && (
<SuscripcionesTab idSuscriptor={idSuscriptor} />
)}
{tabValue === 1 && (
<CuentaCorrienteSuscriptorTab idSuscriptor={idSuscriptor} />
)}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,172 @@
// Archivo: Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx
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 EditIcon from '@mui/icons-material/Edit';
import LoyaltyIcon from '@mui/icons-material/Loyalty';
import suscripcionService from '../../services/Suscripciones/suscripcionService';
import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto';
import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto';
import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto';
import SuscripcionFormModal from '../../components/Modals/Suscripciones/SuscripcionFormModal';
import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
interface SuscripcionesTabProps {
idSuscriptor: number;
}
const SuscripcionesTab: React.FC<SuscripcionesTabProps> = ({ idSuscriptor }) => {
const [suscripciones, setSuscripciones] = useState<SuscripcionDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingSuscripcion, setEditingSuscripcion] = useState<SuscripcionDto | null>(null);
const [promocionesModalOpen, setPromocionesModalOpen] = useState(false);
const [selectedSuscripcion, setSelectedSuscripcion] = useState<SuscripcionDto | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGestionar = isSuperAdmin || tienePermiso("SU005");
const cargarDatos = useCallback(async () => {
setLoading(true);
setApiErrorMessage(null);
try {
const data = await suscripcionService.getSuscripcionesPorSuscriptor(idSuscriptor);
setSuscripciones(data);
} catch (err) {
setError('Error al cargar las suscripciones del cliente.');
} finally {
setLoading(false);
}
}, [idSuscriptor]);
useEffect(() => {
cargarDatos();
}, [cargarDatos]);
const handleOpenModal = (suscripcion?: SuscripcionDto) => {
setEditingSuscripcion(suscripcion || null);
setApiErrorMessage(null);
setModalOpen(true);
};
const handleCloseModal = () => {
setModalOpen(false);
setEditingSuscripcion(null);
};
const handleSubmitModal = async (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => {
setApiErrorMessage(null);
try {
if (id && editingSuscripcion) {
await suscripcionService.updateSuscripcion(id, data as UpdateSuscripcionDto);
} else {
await suscripcionService.createSuscripcion(data as CreateSuscripcionDto);
}
cargarDatos();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la suscripción.';
setApiErrorMessage(message);
throw err;
}
};
const handleOpenPromocionesModal = (suscripcion: SuscripcionDto) => {
setSelectedSuscripcion(suscripcion);
setPromocionesModalOpen(true);
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return 'Indefinido';
const parts = dateString.split('-');
return `${parts[2]}/${parts[1]}/${parts[0]}`;
};
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">Suscripciones Contratadas</Typography>
{puedeGestionar && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}>
Nueva Suscripción
</Button>
)}
</Paper>
{apiErrorMessage && <Alert severity="error" sx={{ mb: 2 }}>{apiErrorMessage}</Alert>}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Publicación</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Días Entrega</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Inicio</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Fin</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{suscripciones.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">Este cliente no tiene suscripciones.</TableCell></TableRow>
) : (
suscripciones.map((s) => (
<TableRow key={s.idSuscripcion} hover>
<TableCell>{s.nombrePublicacion}</TableCell>
<TableCell>
<Chip
label={s.estado}
color={s.estado === 'Activa' ? 'success' : s.estado === 'Pausada' ? 'warning' : 'default'}
size="small"
/>
</TableCell>
<TableCell>{s.diasEntrega.split(',').join(', ')}</TableCell>
<TableCell>{formatDate(s.fechaInicio)}</TableCell>
<TableCell>{formatDate(s.fechaFin)}</TableCell>
<TableCell align="right">
<Tooltip title="Editar Suscripción">
<span>
<IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}><EditIcon /></IconButton>
</span>
</Tooltip>
<Tooltip title="Gestionar Promociones">
<span>
<IconButton onClick={() => handleOpenPromocionesModal(s)} disabled={!puedeGestionar}><LoyaltyIcon /></IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<SuscripcionFormModal
open={modalOpen}
onClose={handleCloseModal}
onSubmit={handleSubmitModal}
idSuscriptor={idSuscriptor}
initialData={editingSuscripcion}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
<GestionarPromocionesSuscripcionModal
open={promocionesModalOpen}
onClose={() => setPromocionesModalOpen(false)}
suscripcion={selectedSuscripcion}
/>
</Box>
);
};
export default SuscripcionesTab;

View File

@@ -0,0 +1,26 @@
import apiClient from '../apiClient';
import type { AjusteDto } from '../../models/dtos/Suscripciones/AjusteDto';
import type { CreateAjusteDto } from '../../models/dtos/Suscripciones/CreateAjusteDto';
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`);
return response.data;
};
const createAjusteManual = async (data: CreateAjusteDto): Promise<AjusteDto> => {
const response = await apiClient.post<AjusteDto>(API_URL_BASE, data);
return response.data;
};
const anularAjuste = async (idAjuste: number): Promise<void> => {
await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`);
};
export default {
getAjustesPorSuscriptor,
createAjusteManual,
anularAjuste
};