From 9cfe9d012eb82347821ed0911314e9ae562676ee Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 1 Aug 2025 14:38:15 -0300 Subject: [PATCH] =?UTF-8?q?Feat:=20Implementa=20ABM=20y=20anulaci=C3=B3n?= =?UTF-8?q?=20de=20ajustes=20manuales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Suscripciones/AjustesController.cs | 78 ++++++++ .../Suscripciones/AjusteRepository.cs | 93 ++++++++++ .../Suscripciones/IAjusteRepository.cs | 15 ++ .../Models/Dtos/Suscripciones/AjusteDto.cs | 15 ++ .../Dtos/Suscripciones/CreateAjusteDto.cs | 22 +++ .../Models/Suscripciones/Ajuste.cs | 17 ++ Backend/GestionIntegral.Api/Program.cs | 2 + .../Services/Suscripciones/AjusteService.cs | 123 ++++++++++++ .../Services/Suscripciones/IAjusteService.cs | 11 ++ .../Modals/Suscripciones/AjusteFormModal.tsx | 110 +++++++++++ .../models/dtos/Suscripciones/AjusteDto.ts | 11 ++ .../dtos/Suscripciones/CreateAjusteDto.ts | 6 + .../CuentaCorrienteSuscriptorTab.tsx | 125 +++++++++++++ .../GestionarSuscripcionesSuscriptorPage.tsx | 175 ++++-------------- .../pages/Suscripciones/SuscripcionesTab.tsx | 172 +++++++++++++++++ .../services/Suscripciones/ajusteService.ts | 26 +++ 16 files changed, 857 insertions(+), 144 deletions(-) create mode 100644 Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs create mode 100644 Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx create mode 100644 Frontend/src/models/dtos/Suscripciones/AjusteDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/CreateAjusteDto.ts create mode 100644 Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx create mode 100644 Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx create mode 100644 Frontend/src/services/Suscripciones/ajusteService.ts diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs new file mode 100644 index 0000000..2ce443e --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/AjustesController.cs @@ -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 _logger; + + // Permiso a crear en BD + private const string PermisoGestionarAjustes = "SU011"; + + public AjustesController(IAjusteService ajusteService, ILogger 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), StatusCodes.Status200OK)] + public async Task 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 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 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." }); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs new file mode 100644 index 0000000..e0f572e --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/AjusteRepository.cs @@ -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 _logger; + + public AjusteRepository(DbConnectionFactory factory, ILogger logger) + { + _connectionFactory = factory; + _logger = logger; + } + + public async Task 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(sql, nuevoAjuste, transaction); + } + + public async Task> 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(sql, new { IdSuscriptor = idSuscriptor }); + } + + public async Task> 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(sql, new { IdSuscriptor = idSuscriptor }, transaction); + } + + public async Task MarcarAjustesComoAplicadosAsync(IEnumerable 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 GetByIdAsync(int idAjuste) + { + const string sql = "SELECT * FROM dbo.susc_Ajustes WHERE IdAjuste = @IdAjuste;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, new { idAjuste }); + } + + public async Task 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; + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs new file mode 100644 index 0000000..93b83a3 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IAjusteRepository.cs @@ -0,0 +1,15 @@ +using GestionIntegral.Api.Models.Suscripciones; +using System.Data; + +namespace GestionIntegral.Api.Data.Repositories.Suscripciones +{ + public interface IAjusteRepository + { + Task CreateAsync(Ajuste nuevoAjuste, IDbTransaction transaction); + Task> GetAjustesPorSuscriptorAsync(int idSuscriptor); + Task> GetAjustesPendientesPorSuscriptorAsync(int idSuscriptor, IDbTransaction transaction); + Task GetByIdAsync(int idAjuste); + Task MarcarAjustesComoAplicadosAsync(IEnumerable idsAjustes, int idFactura, IDbTransaction transaction); + Task AnularAjusteAsync(int idAjuste, int idUsuario, IDbTransaction transaction); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs new file mode 100644 index 0000000..a020b32 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/AjusteDto.cs @@ -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; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs new file mode 100644 index 0000000..1c972d7 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreateAjusteDto.cs @@ -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; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs new file mode 100644 index 0000000..573f76a --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Ajuste.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index fe0c8e1..f55f883 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -112,6 +112,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -120,6 +121,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- Comunicaciones --- builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs new file mode 100644 index 0000000..ba9951c --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/AjusteService.cs @@ -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 _logger; + + public AjusteService( + IAjusteRepository ajusteRepository, + ISuscriptorRepository suscriptorRepository, + IUsuarioRepository usuarioRepository, + DbConnectionFactory connectionFactory, + ILogger logger) + { + _ajusteRepository = ajusteRepository; + _suscriptorRepository = suscriptorRepository; + _usuarioRepository = usuarioRepository; + _connectionFactory = connectionFactory; + _logger = logger; + } + + private async Task 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> 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."); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs new file mode 100644 index 0000000..c3da41c --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IAjusteService.cs @@ -0,0 +1,11 @@ +using GestionIntegral.Api.Dtos.Suscripciones; + +namespace GestionIntegral.Api.Services.Suscripciones +{ + public interface IAjusteService + { + Task> ObtenerAjustesPorSuscriptor(int idSuscriptor); + Task<(AjusteDto? Ajuste, string? Error)> CrearAjusteManual(CreateAjusteDto createDto, int idUsuario); + Task<(bool Exito, string? Error)> AnularAjuste(int idAjuste, int idUsuario); + } +} \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx b/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx new file mode 100644 index 0000000..07e0d18 --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/AjusteFormModal.tsx @@ -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; + idSuscriptor: number; + errorMessage?: string | null; + clearErrorMessage: () => void; +} + +const AjusteFormModal: React.FC = ({ open, onClose, onSubmit, idSuscriptor, errorMessage, clearErrorMessage }) => { + const [formData, setFormData] = useState>({}); + 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) => { + 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) => { + 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) => { + 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 ( + + + Registrar Ajuste Manual + + + Tipo de Ajuste + + + $ }} inputProps={{ step: "0.01" }} /> + + + {errorMessage && {errorMessage}} + + + + + + + + + ); +}; + +export default AjusteFormModal; \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts b/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts new file mode 100644 index 0000000..129f322 --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/AjusteDto.ts @@ -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; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/CreateAjusteDto.ts b/Frontend/src/models/dtos/Suscripciones/CreateAjusteDto.ts new file mode 100644 index 0000000..ab70cca --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/CreateAjusteDto.ts @@ -0,0 +1,6 @@ +export interface CreateAjusteDto { + idSuscriptor: number; + tipoAjuste: 'Credito' | 'Debito'; + monto: number; + motivo: string; +} \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx new file mode 100644 index 0000000..fd0bff2 --- /dev/null +++ b/Frontend/src/pages/Suscripciones/CuentaCorrienteSuscriptorTab.tsx @@ -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 = ({ idSuscriptor }) => { + const [ajustes, setAjustes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [apiErrorMessage, setApiErrorMessage] = useState(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 ; + if (error) return {error}; + + return ( + + + Historial de Ajustes + + + {apiErrorMessage && {apiErrorMessage}} + + + + FechaTipoMotivo + MontoEstadoUsuario + Acciones + + + {ajustes.map(a => ( + + {a.fechaAlta} + + + + {a.motivo} + ${a.monto.toFixed(2)} + {a.estado}{a.idFacturaAplicado ? ` (Fact. #${a.idFacturaAplicado})` : ''} + {a.nombreUsuarioAlta} + + {a.estado === 'Pendiente' && puedeGestionar && ( + + handleAnularAjuste(a.idAjuste)} size="small"> + + + + )} + + + ))} + +
+
+ setModalOpen(false)} + onSubmit={handleSubmitModal} + idSuscriptor={idSuscriptor} + errorMessage={apiErrorMessage} + clearErrorMessage={() => setApiErrorMessage(null)} + /> +
+ ); +}; + +export default CuentaCorrienteSuscriptorTab; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx b/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx index caa7a95..2d8decb 100644 --- a/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx +++ b/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx @@ -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(null); - const [suscripciones, setSuscripciones] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [modalOpen, setModalOpen] = useState(false); - const [editingSuscripcion, setEditingSuscripcion] = useState(null); - const [apiErrorMessage, setApiErrorMessage] = useState(null); - - const [promocionesModalOpen, setPromocionesModalOpen] = useState(false); - const [selectedSuscripcion, setSelectedSuscripcion] = useState(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 ; - if (error) return {error}; - if (!puedeVer) return Acceso Denegado. + if (error) return {error}; + if (!puedeVer) return Acceso Denegado.; return ( - Suscripciones de: {suscriptor?.nombreCompleto || 'Cargando...'} + {suscriptor?.nombreCompleto || 'Cargando...'} Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion} - {puedeGestionar && } + + + + + + - {apiErrorMessage && {apiErrorMessage}} - - - - - - Publicación - Estado - Días Entrega - Inicio - Fin - Observaciones - Acciones - - - - {suscripciones.length === 0 ? ( - Este cliente no tiene suscripciones. - ) : ( - suscripciones.map((s) => ( - - {s.nombrePublicacion} - - - - {s.diasEntrega.split(',').join(', ')} - {formatDate(s.fechaInicio)} - {formatDate(s.fechaFin)} - {s.observaciones || '-'} - - - - handleOpenModal(s)} disabled={!puedeGestionar}> - - - - - - - - handleOpenModal(s)} disabled={!puedeGestionar}> - - - handleOpenPromocionesModal(s)} disabled={!puedeGestionar}> - - - - - )) - )} - -
-
- - {idSuscriptor && - setApiErrorMessage(null)} - /> - } - setPromocionesModalOpen(false)} - suscripcion={selectedSuscripcion} - /> + + {tabValue === 0 && ( + + )} + {tabValue === 1 && ( + + )} +
); }; diff --git a/Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx b/Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx new file mode 100644 index 0000000..b3ca94a --- /dev/null +++ b/Frontend/src/pages/Suscripciones/SuscripcionesTab.tsx @@ -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 = ({ idSuscriptor }) => { + const [suscripciones, setSuscripciones] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editingSuscripcion, setEditingSuscripcion] = useState(null); + const [promocionesModalOpen, setPromocionesModalOpen] = useState(false); + const [selectedSuscripcion, setSelectedSuscripcion] = useState(null); + const [apiErrorMessage, setApiErrorMessage] = useState(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 ; + if (error) return {error}; + + return ( + + + Suscripciones Contratadas + {puedeGestionar && ( + + )} + + + {apiErrorMessage && {apiErrorMessage}} + + + + + + Publicación + Estado + Días Entrega + Inicio + Fin + Acciones + + + + {suscripciones.length === 0 ? ( + Este cliente no tiene suscripciones. + ) : ( + suscripciones.map((s) => ( + + {s.nombrePublicacion} + + + + {s.diasEntrega.split(',').join(', ')} + {formatDate(s.fechaInicio)} + {formatDate(s.fechaFin)} + + + + handleOpenModal(s)} disabled={!puedeGestionar}> + + + + + handleOpenPromocionesModal(s)} disabled={!puedeGestionar}> + + + + + )) + )} + +
+
+ + setApiErrorMessage(null)} + /> + + setPromocionesModalOpen(false)} + suscripcion={selectedSuscripcion} + /> +
+ ); +}; + +export default SuscripcionesTab; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/ajusteService.ts b/Frontend/src/services/Suscripciones/ajusteService.ts new file mode 100644 index 0000000..06d8f00 --- /dev/null +++ b/Frontend/src/services/Suscripciones/ajusteService.ts @@ -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 => { + const response = await apiClient.get(`${API_URL_BY_SUSCRIPTOR}/${idSuscriptor}/ajustes`); + return response.data; +}; + +const createAjusteManual = async (data: CreateAjusteDto): Promise => { + const response = await apiClient.post(API_URL_BASE, data); + return response.data; +}; + +const anularAjuste = async (idAjuste: number): Promise => { + await apiClient.post(`${API_URL_BASE}/${idAjuste}/anular`); +}; + +export default { + getAjustesPorSuscriptor, + createAjusteManual, + anularAjuste +}; \ No newline at end of file