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,78 @@
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Services.Suscripciones;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace GestionIntegral.Api.Controllers.Suscripciones
{
[Route("api/ajustes")]
[ApiController]
[Authorize]
public class AjustesController : ControllerBase
{
private readonly IAjusteService _ajusteService;
private readonly ILogger<AjustesController> _logger;
// Permiso a crear en BD
private const string PermisoGestionarAjustes = "SU011";
public AjustesController(IAjusteService ajusteService, ILogger<AjustesController> logger)
{
_ajusteService = ajusteService;
_logger = logger;
}
private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc);
private int? GetCurrentUserId()
{
if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId;
return null;
}
// GET: api/suscriptores/{idSuscriptor}/ajustes
[HttpGet("~/api/suscriptores/{idSuscriptor:int}/ajustes")]
[ProducesResponseType(typeof(IEnumerable<AjusteDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetAjustesPorSuscriptor(int idSuscriptor)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var ajustes = await _ajusteService.ObtenerAjustesPorSuscriptor(idSuscriptor);
return Ok(ajustes);
}
// POST: api/ajustes
[HttpPost]
[ProducesResponseType(typeof(AjusteDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateAjuste([FromBody] CreateAjusteDto createDto)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (dto, error) = await _ajusteService.CrearAjusteManual(createDto, userId.Value);
if (error != null) return BadRequest(new { message = error });
if (dto == null) return StatusCode(500, "Error al crear el ajuste.");
// Devolvemos el objeto creado con un 201
return StatusCode(201, dto);
}
// POST: api/ajustes/{id}/anular
[HttpPost("{id:int}/anular")]
public async Task<IActionResult> Anular(int id)
{
if (!TienePermiso(PermisoGestionarAjustes)) return Forbid();
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _ajusteService.AnularAjuste(id, userId.Value);
if (!exito) return BadRequest(new { message = error });
return Ok(new { message = "Ajuste anulado correctamente." });
}
}
}

View File

@@ -0,0 +1,93 @@
using Dapper;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public class AjusteRepository : IAjusteRepository
{
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<AjusteRepository> _logger;
public AjusteRepository(DbConnectionFactory factory, ILogger<AjusteRepository> logger)
{
_connectionFactory = factory;
_logger = logger;
}
public async Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction)
{
const string sql = @"
INSERT INTO dbo.susc_Ajustes (IdSuscriptor, TipoAjuste, Monto, Motivo, Estado, IdUsuarioAlta, FechaAlta)
OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @TipoAjuste, @Monto, @Motivo, 'Pendiente', @IdUsuarioAlta, GETDATE());";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QuerySingleOrDefaultAsync<Ajuste>(sql, nuevoAjuste, transaction);
}
public async Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor ORDER BY FechaAlta DESC;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor });
}
public async Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdSuscriptor = @IdSuscriptor AND Estado = 'Pendiente';";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
return await transaction.Connection.QueryAsync<Ajuste>(sql, new { IdSuscriptor = idSuscriptor }, transaction);
}
public async Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction)
{
if (!idsAjustes.Any()) return true;
const string sql = @"
UPDATE dbo.susc_Ajustes SET
Estado = 'Aplicado',
IdFacturaAplicado = @IdFactura
WHERE IdAjuste IN @IdsAjustes;";
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdsAjustes = idsAjustes, IdFactura = idFactura }, transaction);
return rowsAffected == idsAjustes.Count();
}
public async Task<Ajuste?> GetByIdAsync(int idAjuste)
{
const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdAjuste = @IdAjuste;";
using var connection = _connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<Ajuste>(sql, new { idAjuste });
}
public async Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction)
{
const string sql = @"
UPDATE dbo.susc_Ajustes SET
Estado = 'Anulado',
IdUsuarioAnulo = @IdUsuario,
FechaAnulacion = GETDATE()
WHERE IdAjuste = @IdAjuste AND Estado = 'Pendiente';"; // Solo se pueden anular los pendientes
if (transaction?.Connection == null)
{
throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula.");
}
var rows = await transaction.Connection.ExecuteAsync(sql, new { IdAjuste = idAjuste, IdUsuario = idUsuario }, transaction);
return rows == 1;
}
}
}

View File

@@ -0,0 +1,15 @@
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{
public interface IAjusteRepository
{
Task<Ajuste?> CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction);
Task<IEnumerable<Ajuste>> GetAjustesPorSuscriptorAsync(int idSuscriptor);
Task<IEnumerable<Ajuste>> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction);
Task<Ajuste?> GetByIdAsync(int idAjuste);
Task<bool> MarcarAjustesComoAplicadosAsync(IEnumerable<int> idsAjustes, int idFactura, IDbTransaction transaction);
Task<bool> AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction);
}
}

View File

@@ -0,0 +1,15 @@
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class AjusteDto
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int? IdFacturaAplicado { get; set; }
public string FechaAlta { get; set; } = string.Empty; // yyyy-MM-dd
public string NombreUsuarioAlta { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Suscripciones
{
public class CreateAjusteDto
{
[Required]
public int IdSuscriptor { get; set; }
[Required]
[RegularExpression("^(Credito|Debito)$", ErrorMessage = "El tipo de ajuste debe ser 'Credito' o 'Debito'.")]
public string TipoAjuste { get; set; } = string.Empty;
[Required]
[Range(0.01, 999999.99, ErrorMessage = "El monto debe ser un valor positivo.")]
public decimal Monto { get; set; }
[Required(ErrorMessage = "El motivo es obligatorio.")]
[StringLength(250)]
public string Motivo { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,17 @@
namespace GestionIntegral.Api.Models.Suscripciones
{
public class Ajuste
{
public int IdAjuste { get; set; }
public int IdSuscriptor { get; set; }
public string TipoAjuste { get; set; } = string.Empty;
public decimal Monto { get; set; }
public string Motivo { get; set; } = string.Empty;
public string Estado { get; set; } = string.Empty;
public int? IdFacturaAplicado { get; set; }
public int IdUsuarioAlta { get; set; }
public DateTime FechaAlta { get; set; }
public int? IdUsuarioAnulo { get; set; }
public DateTime? FechaAnulacion { get; set; }
}
}

View File

@@ -112,6 +112,7 @@ builder.Services.AddScoped<IFacturaRepository, FacturaRepository>();
builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>();
builder.Services.AddScoped<IPagoRepository, PagoRepository>();
builder.Services.AddScoped<IPromocionRepository, PromocionRepository>();
builder.Services.AddScoped<IAjusteRepository, AjusteRepository>();
builder.Services.AddScoped<IFormaPagoService, FormaPagoService>();
builder.Services.AddScoped<ISuscriptorService, SuscriptorService>();
@@ -120,6 +121,7 @@ builder.Services.AddScoped<IFacturacionService, FacturacionService>();
builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>();
builder.Services.AddScoped<IPagoService, PagoService>();
builder.Services.AddScoped<IPromocionService, PromocionService>();
builder.Services.AddScoped<IAjusteService, AjusteService>();
// --- Comunicaciones ---
builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings"));

View File

@@ -0,0 +1,123 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class AjusteService : IAjusteService
{
private readonly IAjusteRepository _ajusteRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IUsuarioRepository _usuarioRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<AjusteService> _logger;
public AjusteService(
IAjusteRepository ajusteRepository,
ISuscriptorRepository suscriptorRepository,
IUsuarioRepository usuarioRepository,
DbConnectionFactory connectionFactory,
ILogger<AjusteService> logger)
{
_ajusteRepository = ajusteRepository;
_suscriptorRepository = suscriptorRepository;
_usuarioRepository = usuarioRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private async Task<AjusteDto?> MapToDto(Ajuste ajuste)
{
if (ajuste == null) return null;
var usuario = await _usuarioRepository.GetByIdAsync(ajuste.IdUsuarioAlta);
return new AjusteDto
{
IdAjuste = ajuste.IdAjuste,
IdSuscriptor = ajuste.IdSuscriptor,
TipoAjuste = ajuste.TipoAjuste,
Monto = ajuste.Monto,
Motivo = ajuste.Motivo,
Estado = ajuste.Estado,
IdFacturaAplicado = ajuste.IdFacturaAplicado,
FechaAlta = ajuste.FechaAlta.ToString("yyyy-MM-dd HH:mm"),
NombreUsuarioAlta = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
}
public async Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor)
{
var ajustes = await _ajusteRepository.GetAjustesPorSuscriptorAsync(idSuscriptor);
var dtosTasks = ajustes.Select(a => MapToDto(a));
var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null)!;
}
public async Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario)
{
var suscriptor = await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor);
if (suscriptor == null)
{
return (null, "El suscriptor especificado no existe.");
}
var nuevoAjuste = new Ajuste
{
IdSuscriptor = createDto.IdSuscriptor,
TipoAjuste = createDto.TipoAjuste,
Monto = createDto.Monto,
Motivo = createDto.Motivo,
IdUsuarioAlta = idUsuario
};
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajusteCreado = await _ajusteRepository.CreateAsync(nuevoAjuste, transaction);
if (ajusteCreado == null) throw new DataException("Error al crear el registro de ajuste.");
transaction.Commit();
_logger.LogInformation("Ajuste manual ID {IdAjuste} creado para Suscriptor ID {IdSuscriptor} por Usuario ID {IdUsuario}", ajusteCreado.IdAjuste, ajusteCreado.IdSuscriptor, idUsuario);
var dto = await MapToDto(ajusteCreado);
return (dto, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al crear ajuste manual para Suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor);
return (null, "Error interno al registrar el ajuste.");
}
}
public async Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var ajuste = await _ajusteRepository.GetByIdAsync(idAjuste);
if (ajuste == null) return (false, "Ajuste no encontrado.");
if (ajuste.Estado != "Pendiente") return (false, $"No se puede anular un ajuste en estado '{ajuste.Estado}'.");
var exito = await _ajusteRepository.AnularAjusteAsync(idAjuste, idUsuario, transaction);
if (!exito) throw new DataException("No se pudo anular el ajuste.");
transaction.Commit();
_logger.LogInformation("Ajuste ID {IdAjuste} anulado por Usuario ID {IdUsuario}", idAjuste, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al anular ajuste ID {IdAjuste}", idAjuste);
return (false, "Error interno al anular el ajuste.");
}
}
}
}

View File

@@ -0,0 +1,11 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IAjusteService
{
Task<IEnumerable<AjusteDto>> ObtenerAjustesPorSuscriptor(int idSuscriptor);
Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario);
}
}

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
};