Fase 3:
- Backend API: Autenticación y autorización básicas con JWT implementadas. Cambio de contraseña funcional. Módulo "Tipos de Pago" (CRUD completo) implementado en el backend (Controlador, Servicio, Repositorio) usando Dapper, transacciones y con lógica de historial. Se incluyen permisos en el token JWT. - Frontend React: Estructura base con Vite, TypeScript, MUI. Contexto de autenticación (AuthContext) que maneja el estado del usuario y el token. Página de Login. Modal de Cambio de Contraseña (forzado y opcional). Hook usePermissions para verificar permisos. Página GestionarTiposPagoPage con tabla, paginación, filtro, modal para crear/editar, y menú de acciones, respetando permisos. Layout principal (MainLayout) con navegación por Tabs (funcionalidad básica de navegación). Estructura de enrutamiento (AppRoutes) que maneja rutas públicas, protegidas y anidadas para módulos.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
using GestionIntegral.Api.Dtos;
|
||||
using GestionIntegral.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization; // Para [Authorize]
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims; // Para leer claims del token
|
||||
|
||||
namespace GestionIntegral.Api.Controllers
|
||||
{
|
||||
@@ -50,7 +52,49 @@ namespace GestionIntegral.Api.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Añadir endpoint para cambiar clave [HttpPost("change-password")]
|
||||
// Probablemente requerirá [Authorize] para que solo usuarios logueados puedan usarlo.
|
||||
[HttpPost("change-password")]
|
||||
[Authorize] // <-- Solo usuarios autenticados pueden cambiar su clave
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)] // Éxito sin contenido
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)] // Si el token es inválido
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)] // Si el usuario del token no existe
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequestDto changePasswordRequest)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
// El [ApiController] y el [FromBody] ya validan DTOs (Required, StringLength, Compare)
|
||||
// y devuelven 400 automáticamente si falla.
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
// Obtener el ID del usuario desde el token JWT
|
||||
var userIdString = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); // "sub" es el claim estándar para ID
|
||||
if (!int.TryParse(userIdString, out int userId))
|
||||
{
|
||||
_logger.LogWarning("ChangePassword failed: Could not parse UserId from token.");
|
||||
// Esto no debería pasar si el token es válido y generado por nosotros
|
||||
return Unauthorized(new { message = "Token de usuario inválido." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _authService.ChangePasswordAsync(userId, changePasswordRequest);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// AuthService ya loggeó la razón específica (usuario no encontrado, clave actual inválida, etc.)
|
||||
// Devolvemos un BadRequest genérico para no dar pistas
|
||||
return BadRequest(new { message = "No se pudo cambiar la contraseña. Verifique la contraseña actual." });
|
||||
}
|
||||
|
||||
// Éxito
|
||||
return NoContent(); // Código 204: Éxito, sin contenido que devolver
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during password change for user ID {UserId}", userId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Ocurrió un error interno al cambiar la contraseña." });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using GestionIntegral.Api.Dtos.Contables; // Para los DTOs
|
||||
using GestionIntegral.Api.Services.Contables; // Para ITipoPagoService
|
||||
using Microsoft.AspNetCore.Authorization; // Para [Authorize]
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims; // Para obtener el IdUsuario del token
|
||||
|
||||
namespace GestionIntegral.Api.Controllers.Contables
|
||||
{
|
||||
[Route("api/[controller]")] // Ruta base: /api/tipospago
|
||||
[ApiController]
|
||||
[Authorize] // <-- Proteger todos los endpoints de este controlador por defecto
|
||||
public class TiposPagoController : ControllerBase
|
||||
{
|
||||
private readonly ITipoPagoService _tipoPagoService;
|
||||
private readonly ILogger<TiposPagoController> _logger;
|
||||
|
||||
public TiposPagoController(ITipoPagoService tipoPagoService, ILogger<TiposPagoController> logger)
|
||||
{
|
||||
_tipoPagoService = tipoPagoService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET: api/tipospago
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<TipoPagoDto>), StatusCodes.Status200OK)]
|
||||
// Podrías añadir un permiso específico si lo tienes, ej: [Authorize(Policy = "VerTiposPago")]
|
||||
// Por ahora, [Authorize] a nivel de controlador es suficiente si todos los métodos lo requieren.
|
||||
public async Task<IActionResult> GetAllTiposPago([FromQuery] string? nombre) // Filtro opcional por nombre
|
||||
{
|
||||
try
|
||||
{
|
||||
var tiposPago = await _tipoPagoService.ObtenerTodosAsync(nombre);
|
||||
return Ok(tiposPago);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener todos los Tipos de Pago.");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
// GET: api/tipospago/{id}
|
||||
[HttpGet("{id:int}", Name = "GetTipoPagoById")] // Darle un nombre a la ruta para CreatedAtRoute
|
||||
[ProducesResponseType(typeof(TipoPagoDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetTipoPagoById(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tipoPago = await _tipoPagoService.ObtenerPorIdAsync(id);
|
||||
if (tipoPago == null)
|
||||
{
|
||||
return NotFound(new { message = $"Tipo de pago con ID {id} no encontrado." });
|
||||
}
|
||||
return Ok(tipoPago);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener Tipo de Pago por ID: {Id}", id);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
// POST: api/tipospago
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TipoPagoDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
// Aquí podrías requerir un permiso más específico: [Authorize(Policy = "CrearTiposPago")]
|
||||
public async Task<IActionResult> CreateTipoPago([FromBody] CreateTipoPagoDto createDto)
|
||||
{
|
||||
if (!ModelState.IsValid) // Validaciones del DTO (Required, StringLength)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
var idUsuario = GetCurrentUserId();
|
||||
if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token.");
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var (tipoPagoCreado, error) = await _tipoPagoService.CrearAsync(createDto, idUsuario.Value);
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
// Error de lógica de negocio (ej: nombre duplicado)
|
||||
return BadRequest(new { message = error });
|
||||
}
|
||||
if (tipoPagoCreado == null) // Fallo inesperado en el repositorio/servicio
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear el tipo de pago.");
|
||||
}
|
||||
|
||||
// Devuelve 201 Created con la ubicación del nuevo recurso y el recurso mismo
|
||||
return CreatedAtRoute("GetTipoPagoById", new { id = tipoPagoCreado.IdTipoPago }, tipoPagoCreado);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al crear Tipo de Pago. Nombre: {Nombre}", createDto.Nombre);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: api/tipospago/{id}
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)] // Éxito sin contenido
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateTipoPago(int id, [FromBody] UpdateTipoPagoDto updateDto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
var idUsuario = GetCurrentUserId();
|
||||
if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token.");
|
||||
|
||||
try
|
||||
{
|
||||
var (exito, error) = await _tipoPagoService.ActualizarAsync(id, updateDto, idUsuario.Value);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
if (error == "Tipo de pago no encontrado.") return NotFound(new { message = error });
|
||||
return BadRequest(new { message = error }); // Otro error de lógica de negocio
|
||||
}
|
||||
return NoContent(); // Éxito
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al actualizar Tipo de Pago ID: {Id}", id);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: api/tipospago/{id}
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)] // Si está en uso
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteTipoPago(int id)
|
||||
{
|
||||
var idUsuario = GetCurrentUserId();
|
||||
if (idUsuario == null) return Unauthorized("No se pudo obtener el ID del usuario del token.");
|
||||
|
||||
try
|
||||
{
|
||||
var (exito, error) = await _tipoPagoService.EliminarAsync(id, idUsuario.Value);
|
||||
|
||||
if (!exito)
|
||||
{
|
||||
if (error == "Tipo de pago no encontrado.") return NotFound(new { message = error });
|
||||
return BadRequest(new { message = error }); // Ej: "En uso"
|
||||
}
|
||||
return NoContent(); // Éxito
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al eliminar Tipo de Pago ID: {Id}", id);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al procesar la solicitud.");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper para obtener el ID del usuario del token
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
_logger.LogWarning("No se pudo obtener el UserId del token JWT.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,13 @@ namespace GestionIntegral.Api.Data
|
||||
public class AuthRepository : IAuthRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<AuthRepository> _logger;
|
||||
|
||||
public AuthRepository(DbConnectionFactory connectionFactory)
|
||||
public AuthRepository(DbConnectionFactory connectionFactory, ILogger<AuthRepository> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
|
||||
}
|
||||
|
||||
public async Task<Usuario?> GetUserByUsernameAsync(string username)
|
||||
@@ -30,13 +33,81 @@ namespace GestionIntegral.Api.Data
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Loggear el error ex.Message
|
||||
Console.WriteLine($"Error fetching user: {ex.Message}");
|
||||
Console.WriteLine($"Error fetching user by username: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implementar métodos para cambiar clave (UPDATE seguro con parámetros)
|
||||
// y para crear usuario (INSERT seguro con parámetros, usando el hasher)
|
||||
public async Task<Usuario?> GetUserByIdAsync(int userId)
|
||||
{
|
||||
var sql = @"SELECT Id, [User], ClaveHash, ClaveSalt, Habilitada,
|
||||
SupAdmin, Nombre, Apellido, IdPerfil, VerLog, DebeCambiarClave
|
||||
FROM gral_Usuarios
|
||||
WHERE Id = @UserId";
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
var user = await connection.QuerySingleOrDefaultAsync<Usuario>(sql, new { UserId = userId });
|
||||
Console.WriteLine($"Repo - User {user?.Id} - DebeCambiarClave leído de BD: {user?.DebeCambiarClave}");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error fetching user by ID: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePasswordAsync(int userId, string newHash, string newSalt)
|
||||
{
|
||||
// Actualiza hash, salt y pone DebeCambiarClave a 0 (false)
|
||||
var sql = @"UPDATE dbo.gral_Usuarios
|
||||
SET ClaveHash = @HashedPassword, ClaveSalt = @Salt, DebeCambiarClave = 0
|
||||
WHERE Id = @UserId";
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
var parameters = new
|
||||
{
|
||||
HashedPassword = newHash,
|
||||
Salt = newSalt,
|
||||
UserId = userId
|
||||
};
|
||||
int rowsAffected = await connection.ExecuteAsync(sql, parameters);
|
||||
return rowsAffected == 1; // Devuelve true si se actualizó exactamente una fila
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error updating password for user {userId}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async Task<IEnumerable<string>> GetPermisosCodAccByPerfilIdAsync(int idPerfil)
|
||||
{
|
||||
// Esta consulta es similar a la que tenías en gestion.vb -> verPermisosPerfil
|
||||
var sql = @"
|
||||
SELECT p.codAcc
|
||||
FROM dbo.gral_Perfiles pf
|
||||
INNER JOIN dbo.gral_PermisosPerfiles pp ON pf.id = pp.idPerfil
|
||||
INNER JOIN dbo.gral_Permisos p ON pp.idPermiso = p.id
|
||||
WHERE pf.id = @IdPerfil;";
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
return await connection.QueryAsync<string>(sql, new { IdPerfil = idPerfil });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener códigos de acceso para el perfil ID: {IdPerfil}", idPerfil); // Asumiendo que tienes _logger en AuthRepository
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace GestionIntegral.Api.Data
|
||||
public interface IAuthRepository
|
||||
{
|
||||
Task<Usuario?> GetUserByUsernameAsync(string username);
|
||||
// Añadiremos métodos para cambiar clave, etc., más adelante
|
||||
Task<bool> UpdatePasswordAsync(int userId, string newHash, string newSalt);
|
||||
Task<Usuario?> GetUserByIdAsync(int userId); // Método útil para cambio de clav
|
||||
Task<IEnumerable<string>> GetPermisosCodAccByPerfilIdAsync(int idPerfil);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using GestionIntegral.Api.Models.Contables; // Para TipoPago y TipoPagoHistorico
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories
|
||||
{
|
||||
public interface ITipoPagoRepository
|
||||
{
|
||||
Task<IEnumerable<TipoPago>> GetAllAsync(string? nombreFilter);
|
||||
Task<TipoPago?> GetByIdAsync(int id);
|
||||
Task<TipoPago?> CreateAsync(TipoPago nuevoTipoPago, int idUsuario); // Devuelve el objeto creado o null si falla
|
||||
Task<bool> UpdateAsync(TipoPago tipoPagoAActualizar, int idUsuario);
|
||||
Task<bool> DeleteAsync(int id, int idUsuario); // Devuelve true si fue exitoso
|
||||
Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null);
|
||||
Task<bool> IsInUseAsync(int id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using Dapper;
|
||||
using GestionIntegral.Api.Models.Contables;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Data.Repositories // O GestionIntegral.Api.Repositories
|
||||
{
|
||||
public class TipoPagoRepository : ITipoPagoRepository
|
||||
{
|
||||
private readonly DbConnectionFactory _connectionFactory;
|
||||
private readonly ILogger<TipoPagoRepository> _logger; // Para logging
|
||||
|
||||
public TipoPagoRepository(DbConnectionFactory connectionFactory, ILogger<TipoPagoRepository> logger)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TipoPago>> GetAllAsync(string? nombreFilter)
|
||||
{
|
||||
// Construcción segura de la cláusula WHERE
|
||||
var sqlBuilder = new System.Text.StringBuilder("SELECT Id_TipoPago AS IdTipoPago, Nombre, Detalle FROM dbo.cue_dtTipopago WHERE 1=1");
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(nombreFilter))
|
||||
{
|
||||
sqlBuilder.Append(" AND Nombre LIKE @NombreFilter");
|
||||
parameters.Add("NombreFilter", $"%{nombreFilter}%");
|
||||
}
|
||||
sqlBuilder.Append(" ORDER BY Nombre;");
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
return await connection.QueryAsync<TipoPago>(sqlBuilder.ToString(), parameters);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener todos los Tipos de Pago. Filtro: {NombreFilter}", nombreFilter);
|
||||
return Enumerable.Empty<TipoPago>(); // Devolver lista vacía en caso de error
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TipoPago?> GetByIdAsync(int id)
|
||||
{
|
||||
var sql = "SELECT Id_TipoPago AS IdTipoPago, Nombre, Detalle FROM dbo.cue_dtTipopago WHERE Id_TipoPago = @Id";
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
return await connection.QuerySingleOrDefaultAsync<TipoPago>(sql, new { Id = id });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al obtener Tipo de Pago por ID: {IdTipoPago}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByNameAsync(string nombre, int? excludeId = null)
|
||||
{
|
||||
var sqlBuilder = new System.Text.StringBuilder("SELECT COUNT(1) FROM dbo.cue_dtTipopago WHERE Nombre = @Nombre");
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("Nombre", nombre);
|
||||
|
||||
if (excludeId.HasValue)
|
||||
{
|
||||
sqlBuilder.Append(" AND Id_TipoPago != @ExcludeId");
|
||||
parameters.Add("ExcludeId", excludeId.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
var count = await connection.ExecuteScalarAsync<int>(sqlBuilder.ToString(), parameters);
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en ExistsByNameAsync para Tipo de Pago con nombre: {Nombre}", nombre);
|
||||
return true; // Asumir que existe para prevenir duplicados si hay error de BD
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TipoPago?> CreateAsync(TipoPago nuevoTipoPago, int idUsuario)
|
||||
{
|
||||
var sqlInsertPrincipal = @"
|
||||
INSERT INTO dbo.cue_dtTipopago (Nombre, Detalle)
|
||||
VALUES (@Nombre, @Detalle);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);"; // Obtener el ID generado
|
||||
|
||||
var sqlInsertHistorico = @"
|
||||
INSERT INTO dbo.cue_dtTipopago_H (Id_TipoPago, Nombre, Detalle, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdTipoPago, @Nombre, @Detalle, @IdUsuario, @FechaMod, @TipoMod);";
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
connection.Open();
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Insertar en la tabla principal y obtener el ID
|
||||
int nuevoId = await connection.ExecuteScalarAsync<int>(
|
||||
sqlInsertPrincipal,
|
||||
new { nuevoTipoPago.Nombre, nuevoTipoPago.Detalle },
|
||||
transaction: transaction
|
||||
);
|
||||
|
||||
if (nuevoId > 0)
|
||||
{
|
||||
nuevoTipoPago.IdTipoPago = nuevoId; // Asignar el ID al objeto
|
||||
|
||||
// Insertar en la tabla de historial
|
||||
await connection.ExecuteAsync(sqlInsertHistorico, new
|
||||
{
|
||||
IdTipoPago = nuevoId, // Usar el ID obtenido
|
||||
nuevoTipoPago.Nombre,
|
||||
nuevoTipoPago.Detalle,
|
||||
IdUsuario = idUsuario,
|
||||
FechaMod = DateTime.Now,
|
||||
TipoMod = "Insertada"
|
||||
}, transaction: transaction);
|
||||
|
||||
transaction.Commit();
|
||||
return nuevoTipoPago; // Devolver el objeto completo con ID
|
||||
}
|
||||
else
|
||||
{
|
||||
transaction.Rollback();
|
||||
_logger.LogError("SCOPE_IDENTITY() devolvió 0 o menos después de insertar TipoPago. Nombre: {Nombre}", nuevoTipoPago.Nombre);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Exception exTrans)
|
||||
{
|
||||
transaction.Rollback();
|
||||
_logger.LogError(exTrans, "Error en transacción CreateAsync para TipoPago. Nombre: {Nombre}", nuevoTipoPago.Nombre);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error general en CreateAsync para TipoPago. Nombre: {Nombre}", nuevoTipoPago.Nombre);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(TipoPago tipoPagoAActualizar, int idUsuario)
|
||||
{
|
||||
// Primero, obtenemos el estado actual para el historial
|
||||
var tipoPagoActual = await GetByIdAsync(tipoPagoAActualizar.IdTipoPago);
|
||||
if (tipoPagoActual == null) return false; // No se encontró para actualizar
|
||||
|
||||
var sqlUpdate = @"
|
||||
UPDATE dbo.cue_dtTipopago
|
||||
SET Nombre = @Nombre, Detalle = @Detalle
|
||||
WHERE Id_TipoPago = @IdTipoPago;";
|
||||
|
||||
var sqlInsertHistorico = @"
|
||||
INSERT INTO dbo.cue_dtTipopago_H (Id_TipoPago, Nombre, Detalle, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdTipoPago, @NombreActual, @DetalleActual, @IdUsuario, @FechaMod, @TipoMod);";
|
||||
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
connection.Open();
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Insertar en historial CON LOS VALORES ANTERIORES
|
||||
await connection.ExecuteAsync(sqlInsertHistorico, new
|
||||
{
|
||||
IdTipoPago = tipoPagoActual.IdTipoPago,
|
||||
NombreActual = tipoPagoActual.Nombre, // Valor antes del update
|
||||
DetalleActual = tipoPagoActual.Detalle, // Valor antes del update
|
||||
IdUsuario = idUsuario,
|
||||
FechaMod = DateTime.Now,
|
||||
TipoMod = "Modificada"
|
||||
}, transaction: transaction);
|
||||
|
||||
// Actualizar la tabla principal
|
||||
var rowsAffected = await connection.ExecuteAsync(sqlUpdate, new
|
||||
{
|
||||
tipoPagoAActualizar.Nombre,
|
||||
tipoPagoAActualizar.Detalle,
|
||||
tipoPagoAActualizar.IdTipoPago
|
||||
}, transaction: transaction);
|
||||
|
||||
transaction.Commit();
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
catch (Exception exTrans)
|
||||
{
|
||||
transaction.Rollback();
|
||||
_logger.LogError(exTrans, "Error en transacción UpdateAsync para TipoPago ID: {IdTipoPago}", tipoPagoAActualizar.IdTipoPago);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error general en UpdateAsync para TipoPago ID: {IdTipoPago}", tipoPagoAActualizar.IdTipoPago);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id, int idUsuario)
|
||||
{
|
||||
var tipoPagoAEliminar = await GetByIdAsync(id);
|
||||
if (tipoPagoAEliminar == null) return false; // No existe
|
||||
|
||||
var sqlDelete = "DELETE FROM dbo.cue_dtTipopago WHERE Id_TipoPago = @Id";
|
||||
var sqlInsertHistorico = @"
|
||||
INSERT INTO dbo.cue_dtTipopago_H (Id_TipoPago, Nombre, Detalle, Id_Usuario, FechaMod, TipoMod)
|
||||
VALUES (@IdTipoPago, @Nombre, @Detalle, @IdUsuario, @FechaMod, @TipoMod);";
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
connection.Open();
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
try
|
||||
{
|
||||
// Insertar en historial ANTES de eliminar
|
||||
await connection.ExecuteAsync(sqlInsertHistorico, new
|
||||
{
|
||||
IdTipoPago = tipoPagoAEliminar.IdTipoPago,
|
||||
tipoPagoAEliminar.Nombre,
|
||||
tipoPagoAEliminar.Detalle,
|
||||
IdUsuario = idUsuario,
|
||||
FechaMod = DateTime.Now,
|
||||
TipoMod = "Eliminada"
|
||||
}, transaction: transaction);
|
||||
|
||||
var rowsAffected = await connection.ExecuteAsync(sqlDelete, new { Id = id }, transaction: transaction);
|
||||
transaction.Commit();
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
catch (Exception exTrans)
|
||||
{
|
||||
transaction.Rollback();
|
||||
_logger.LogError(exTrans, "Error en transacción DeleteAsync para TipoPago ID: {IdTipoPago}", id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error general en DeleteAsync para TipoPago ID: {IdTipoPago}", id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsInUseAsync(int id)
|
||||
{
|
||||
var sql = "SELECT COUNT(1) FROM dbo.cue_PagosDistribuidor WHERE Id_TipoPago = @IdTipoPago";
|
||||
try
|
||||
{
|
||||
using (var connection = _connectionFactory.CreateConnection())
|
||||
{
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { IdTipoPago = id });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error en IsInUseAsync para TipoPago ID: {IdTipoPago}", id);
|
||||
return true; // Asumir que está en uso si hay error para prevenir borrado incorrecto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Backend/GestionIntegral.Api/Models/Contables/TipoPago.cs
Normal file
9
Backend/GestionIntegral.Api/Models/Contables/TipoPago.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace GestionIntegral.Api.Models.Contables
|
||||
{
|
||||
public class TipoPago
|
||||
{
|
||||
public int IdTipoPago { get; set; } // Coincide con la PK de cue_dtTipopago
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public string? Detalle { get; set; } // Permite nulo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace GestionIntegral.Api.Models.Contables
|
||||
{
|
||||
public class TipoPagoHistorico
|
||||
{
|
||||
public int IdTipoPago { get; set; } // Este NO es IDENTITY
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public string? Detalle { get; set; }
|
||||
public int IdUsuario { get; set; }
|
||||
public DateTime FechaMod { get; set; }
|
||||
public string TipoMod { get; set; } = string.Empty; // "Insertada", "Modificada", "Eliminada"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos
|
||||
{
|
||||
public class ChangePasswordRequestDto
|
||||
{
|
||||
[Required]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(50, MinimumLength = 6)] // Validaciones
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Compare("NewPassword", ErrorMessage = "La nueva contraseña y la confirmación no coinciden.")] // Validación de confirmación
|
||||
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Contables
|
||||
{
|
||||
public class CreateTipoPagoDto
|
||||
{
|
||||
[Required(ErrorMessage = "El nombre del tipo de pago es obligatorio.")]
|
||||
[StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres.")]
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(150, ErrorMessage = "El detalle no puede exceder los 150 caracteres.")]
|
||||
public string? Detalle { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace GestionIntegral.Api.Dtos.Contables
|
||||
{
|
||||
public class TipoPagoDto
|
||||
{
|
||||
public int IdTipoPago { get; set; }
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public string? Detalle { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GestionIntegral.Api.Dtos.Contables
|
||||
{
|
||||
public class UpdateTipoPagoDto
|
||||
{
|
||||
[Required(ErrorMessage = "El nombre del tipo de pago es obligatorio.")]
|
||||
[StringLength(50, ErrorMessage = "El nombre no puede exceder los 50 caracteres.")]
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(150, ErrorMessage = "El detalle no puede exceder los 150 caracteres.")]
|
||||
public string? Detalle { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using System.Text;
|
||||
using GestionIntegral.Api.Data;
|
||||
using GestionIntegral.Api.Services;
|
||||
using GestionIntegral.Api.Services.Contables;
|
||||
using GestionIntegral.Api.Data.Repositories;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- Registros de Servicios ---
|
||||
builder.Services.AddSingleton<DbConnectionFactory>();
|
||||
builder.Services.AddScoped<PasswordHasherService>();
|
||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); // Asegúrate que el namespace sea correcto
|
||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<ITipoPagoRepository, TipoPagoRepository>();
|
||||
builder.Services.AddScoped<ITipoPagoService, TipoPagoService>();
|
||||
|
||||
// --- Configuración de Autenticación JWT ---
|
||||
var jwtSettings = builder.Configuration.GetSection("Jwt");
|
||||
|
||||
@@ -37,31 +37,32 @@ namespace GestionIntegral.Api.Services
|
||||
_logger.LogWarning("Login attempt failed: User {Username} not found.", loginRequest.Username);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.Habilitada)
|
||||
{
|
||||
_logger.LogWarning("Login attempt failed: User {Username} is disabled.", loginRequest.Username);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar contraseña usando el hash y salt de la BD
|
||||
bool isPasswordValid = _passwordHasher.VerifyPassword(loginRequest.Password, user.ClaveHash, user.ClaveSalt);
|
||||
|
||||
if (!isPasswordValid)
|
||||
{
|
||||
_logger.LogWarning("Login attempt failed: Invalid password for user {Username}.", loginRequest.Username);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generar Token JWT
|
||||
var token = GenerateJwtToken(user);
|
||||
// --- OBTENER PERMISOS ---
|
||||
IEnumerable<string> permisosDelUsuario = new List<string>();
|
||||
if (!user.SupAdmin && user.IdPerfil > 0) // Solo si no es SuperAdmin y tiene un perfil válido
|
||||
{
|
||||
permisosDelUsuario = await _authRepository.GetPermisosCodAccByPerfilIdAsync(user.IdPerfil);
|
||||
}
|
||||
// --- FIN OBTENER PERMISOS ---
|
||||
|
||||
// Determinar si debe cambiar clave (leyendo de la BD via el repo/modelo)
|
||||
var token = GenerateJwtToken(user, permisosDelUsuario); // Pasar permisos
|
||||
bool debeCambiar = user.DebeCambiarClave;
|
||||
|
||||
_logger.LogInformation("User {Username} logged in successfully.", loginRequest.Username);
|
||||
|
||||
// Crear y devolver la respuesta
|
||||
return new LoginResponseDto
|
||||
{
|
||||
Token = token,
|
||||
@@ -70,30 +71,96 @@ namespace GestionIntegral.Api.Services
|
||||
NombreCompleto = $"{user.Nombre} {user.Apellido}",
|
||||
EsSuperAdmin = user.SupAdmin,
|
||||
DebeCambiarClave = debeCambiar
|
||||
// No necesitamos pasar la lista de permisos aquí, ya irán en el token
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(int userId, ChangePasswordRequestDto changePasswordRequest)
|
||||
{
|
||||
// 1. Obtener el usuario actual (necesitamos su hash/salt actual)
|
||||
var user = await _authRepository.GetUserByIdAsync(userId);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("ChangePassword attempt failed: User with ID {UserId} not found.", userId);
|
||||
return false; // Usuario no encontrado
|
||||
}
|
||||
|
||||
// 2. Verificar la contraseña ACTUAL
|
||||
if (!_passwordHasher.VerifyPassword(changePasswordRequest.CurrentPassword, user.ClaveHash, user.ClaveSalt))
|
||||
{
|
||||
_logger.LogWarning("ChangePassword attempt failed: Invalid current password for user ID {UserId}.", userId);
|
||||
return false; // Contraseña actual incorrecta
|
||||
}
|
||||
|
||||
// 3. Verificar que la nueva contraseña no sea igual al nombre de usuario
|
||||
if (user.User == changePasswordRequest.NewPassword)
|
||||
{
|
||||
_logger.LogWarning("ChangePassword attempt failed: New password cannot be the same as username for user ID {UserId}.", userId);
|
||||
// Podrías lanzar una excepción o devolver un código de error específico
|
||||
// Por simplicidad, devolvemos false
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Generar nuevo hash y salt para la NUEVA contraseña
|
||||
(string newHash, string newSalt) = _passwordHasher.HashPassword(changePasswordRequest.NewPassword);
|
||||
|
||||
// 5. Actualizar en la base de datos (el repositorio también pone DebeCambiarClave = 0)
|
||||
bool success = await _authRepository.UpdatePasswordAsync(userId, newHash, newSalt);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("Password changed successfully for user ID {UserId}.", userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to update password in database for user ID {UserId}.", userId);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// --- GenerateJwtToken sin cambios ---
|
||||
private string GenerateJwtToken(Usuario user)
|
||||
private string GenerateJwtToken(Usuario user, IEnumerable<string> permisosCodAcc) // Recibir permisos
|
||||
{
|
||||
var jwtSettings = _configuration.GetSection("Jwt");
|
||||
var key = Encoding.ASCII.GetBytes(jwtSettings["Key"]
|
||||
?? throw new ArgumentNullException("Jwt:Key", "JWT Key not configured"));
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Name, user.User),
|
||||
new Claim(JwtRegisteredClaimNames.GivenName, user.Nombre),
|
||||
new Claim(JwtRegisteredClaimNames.FamilyName, user.Apellido),
|
||||
new Claim(ClaimTypes.Role, user.SupAdmin ? "SuperAdmin" : $"Perfil_{user.IdPerfil}"),
|
||||
new Claim("idPerfil", user.IdPerfil.ToString()), // Añadir IdPerfil como claim explícito
|
||||
new Claim("debeCambiarClave", user.DebeCambiarClave.ToString()), // Añadir flag como claim
|
||||
new Claim("idPerfil", user.IdPerfil.ToString()),
|
||||
new Claim("debeCambiarClave", user.DebeCambiarClave.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
}),
|
||||
};
|
||||
|
||||
// Añadir rol basado en SupAdmin o IdPerfil
|
||||
if (user.SupAdmin)
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, "SuperAdmin"));
|
||||
// Opcional: Si SuperAdmin tiene todos los permisos, podrías añadir un claim especial
|
||||
// claims.Add(new Claim("permission", "*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Role, $"Perfil_{user.IdPerfil}"));
|
||||
// Añadir cada código de permiso como un claim "permission"
|
||||
if (permisosCodAcc != null)
|
||||
{
|
||||
foreach (var codAcc in permisosCodAcc)
|
||||
{
|
||||
claims.Add(new Claim("permission", codAcc)); // Usar "permission" como tipo de claim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddHours(Convert.ToInt32(jwtSettings["DurationInHours"] ?? "1")),
|
||||
Issuer = jwtSettings["Issuer"],
|
||||
Audience = jwtSettings["Audience"],
|
||||
@@ -103,7 +170,5 @@ namespace GestionIntegral.Api.Services
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
// TODO: Implementar ChangePasswordAsync
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using GestionIntegral.Api.Dtos.Contables;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Contables
|
||||
{
|
||||
public interface ITipoPagoService
|
||||
{
|
||||
Task<IEnumerable<TipoPagoDto>> ObtenerTodosAsync(string? nombreFilter);
|
||||
Task<TipoPagoDto?> ObtenerPorIdAsync(int id);
|
||||
Task<(TipoPagoDto? TipoPago, string? Error)> CrearAsync(CreateTipoPagoDto createDto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateTipoPagoDto updateDto, int idUsuario);
|
||||
Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using GestionIntegral.Api.Data.Repositories; // Para ITipoPagoRepository
|
||||
using GestionIntegral.Api.Dtos.Contables;
|
||||
using GestionIntegral.Api.Models.Contables; // Para TipoPago
|
||||
using GestionIntegral.Api.Services.Contables;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace GestionIntegral.Api.Services.Contables
|
||||
{
|
||||
public class TipoPagoService : ITipoPagoService
|
||||
{
|
||||
private readonly ITipoPagoRepository _tipoPagoRepository;
|
||||
private readonly ILogger<TipoPagoService> _logger;
|
||||
|
||||
public TipoPagoService(ITipoPagoRepository tipoPagoRepository, ILogger<TipoPagoService> logger)
|
||||
{
|
||||
_tipoPagoRepository = tipoPagoRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TipoPagoDto>> ObtenerTodosAsync(string? nombreFilter)
|
||||
{
|
||||
var tiposPago = await _tipoPagoRepository.GetAllAsync(nombreFilter);
|
||||
// Mapeo de Entidad a DTO
|
||||
return tiposPago.Select(tp => new TipoPagoDto
|
||||
{
|
||||
IdTipoPago = tp.IdTipoPago,
|
||||
Nombre = tp.Nombre,
|
||||
Detalle = tp.Detalle
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<TipoPagoDto?> ObtenerPorIdAsync(int id)
|
||||
{
|
||||
var tipoPago = await _tipoPagoRepository.GetByIdAsync(id);
|
||||
if (tipoPago == null) return null;
|
||||
|
||||
// Mapeo de Entidad a DTO
|
||||
return new TipoPagoDto
|
||||
{
|
||||
IdTipoPago = tipoPago.IdTipoPago,
|
||||
Nombre = tipoPago.Nombre,
|
||||
Detalle = tipoPago.Detalle
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<(TipoPagoDto? TipoPago, string? Error)> CrearAsync(CreateTipoPagoDto createDto, int idUsuario)
|
||||
{
|
||||
// Validación: Nombre no puede estar duplicado
|
||||
if (await _tipoPagoRepository.ExistsByNameAsync(createDto.Nombre))
|
||||
{
|
||||
return (null, "El nombre del tipo de pago ya existe.");
|
||||
}
|
||||
|
||||
var nuevoTipoPago = new TipoPago
|
||||
{
|
||||
Nombre = createDto.Nombre,
|
||||
Detalle = createDto.Detalle
|
||||
};
|
||||
|
||||
var tipoPagoCreado = await _tipoPagoRepository.CreateAsync(nuevoTipoPago, idUsuario);
|
||||
|
||||
if (tipoPagoCreado == null)
|
||||
{
|
||||
_logger.LogError("Falló la creación del Tipo de Pago en el repositorio para el nombre: {Nombre}", createDto.Nombre);
|
||||
return (null, "Error al crear el tipo de pago en la base de datos.");
|
||||
}
|
||||
|
||||
// Mapeo de Entidad creada (con ID) a DTO
|
||||
var tipoPagoDto = new TipoPagoDto
|
||||
{
|
||||
IdTipoPago = tipoPagoCreado.IdTipoPago,
|
||||
Nombre = tipoPagoCreado.Nombre,
|
||||
Detalle = tipoPagoCreado.Detalle
|
||||
};
|
||||
|
||||
return (tipoPagoDto, null); // Éxito
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateTipoPagoDto updateDto, int idUsuario)
|
||||
{
|
||||
// Verificar si el tipo de pago existe
|
||||
var tipoPagoExistente = await _tipoPagoRepository.GetByIdAsync(id);
|
||||
if (tipoPagoExistente == null)
|
||||
{
|
||||
return (false, "Tipo de pago no encontrado.");
|
||||
}
|
||||
|
||||
// Validación: Nombre no puede estar duplicado (excluyendo el ID actual)
|
||||
if (await _tipoPagoRepository.ExistsByNameAsync(updateDto.Nombre, id))
|
||||
{
|
||||
return (false, "El nombre del tipo de pago ya existe para otro registro.");
|
||||
}
|
||||
|
||||
// Mapeo de DTO a Entidad para actualizar
|
||||
tipoPagoExistente.Nombre = updateDto.Nombre;
|
||||
tipoPagoExistente.Detalle = updateDto.Detalle;
|
||||
|
||||
var actualizado = await _tipoPagoRepository.UpdateAsync(tipoPagoExistente, idUsuario);
|
||||
|
||||
if (!actualizado)
|
||||
{
|
||||
_logger.LogError("Falló la actualización del Tipo de Pago en el repositorio para el ID: {Id}", id);
|
||||
return (false, "Error al actualizar el tipo de pago en la base de datos.");
|
||||
}
|
||||
return (true, null); // Éxito
|
||||
}
|
||||
|
||||
public async Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario)
|
||||
{
|
||||
// Verificar si el tipo de pago existe
|
||||
var tipoPagoExistente = await _tipoPagoRepository.GetByIdAsync(id);
|
||||
if (tipoPagoExistente == null)
|
||||
{
|
||||
return (false, "Tipo de pago no encontrado.");
|
||||
}
|
||||
|
||||
// Validación: No se puede eliminar si está en uso
|
||||
if (await _tipoPagoRepository.IsInUseAsync(id))
|
||||
{
|
||||
return (false, "No se puede eliminar. El tipo de pago está siendo utilizado en pagos registrados.");
|
||||
}
|
||||
|
||||
var eliminado = await _tipoPagoRepository.DeleteAsync(id, idUsuario);
|
||||
if (!eliminado)
|
||||
{
|
||||
_logger.LogError("Falló la eliminación del Tipo de Pago en el repositorio para el ID: {Id}", id);
|
||||
return (false, "Error al eliminar el tipo de pago de la base de datos.");
|
||||
}
|
||||
return (true, null); // Éxito
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@ namespace GestionIntegral.Api.Services
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<LoginResponseDto?> LoginAsync(LoginRequestDto loginRequest);
|
||||
// Añadiremos cambio de clave, etc.
|
||||
Task<bool> ChangePasswordAsync(int userId, ChangePasswordRequestDto changePasswordRequest);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("GestionIntegral.Api")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9b1de95404118dad24e3e848866c48fce6e0c08e")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+da7b544372d72fd6e4a82a3d95626e9cd273f4a4")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("GestionIntegral.Api")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("GestionIntegral.Api")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
640
Frontend/package-lock.json
generated
640
Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,14 @@
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/material": "^7.0.2",
|
||||
"axios": "^1.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/jwt-decode": "^2.2.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
|
||||
205
Frontend/src/components/ChangePasswordModal.tsx
Normal file
205
Frontend/src/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import authService from '../services/authService';
|
||||
import type { ChangePasswordRequestDto } from '../models/dtos/ChangePasswordRequestDto';
|
||||
import axios from 'axios';
|
||||
|
||||
import { Modal, Box, Typography, TextField, Button, Alert, CircularProgress, Backdrop } from '@mui/material';
|
||||
|
||||
const style = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
open: boolean;
|
||||
onClose: (success: boolean) => void;
|
||||
isFirstLogin?: boolean;
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ open, onClose, isFirstLogin }) => {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Ya no necesitamos passwordChangeCompleted ni contextIsFirstLogin aquí
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmNewPassword('');
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLoading(false); // Asegurarse de resetear loading también
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Esta función se llama al hacer clic en el botón Cancelar
|
||||
const handleCancelClick = () => {
|
||||
onClose(false); // Notifica al padre (MainLayout) que se canceló
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setError('La nueva contraseña y la confirmación no coinciden.');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError('La nueva contraseña debe tener al menos 6 caracteres.');
|
||||
return;
|
||||
}
|
||||
if (user && user.username === newPassword) {
|
||||
setError('La nueva contraseña no puede ser igual al nombre de usuario.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const changePasswordData: ChangePasswordRequestDto = {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmNewPassword,
|
||||
};
|
||||
|
||||
try {
|
||||
await authService.changePassword(changePasswordData);
|
||||
setSuccess('Contraseña cambiada exitosamente.');
|
||||
setTimeout(() => {
|
||||
onClose(true); // Notifica al padre (MainLayout) que fue exitoso
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
console.error("Change password error:", err);
|
||||
let errorMessage = 'Ocurrió un error inesperado al cambiar la contraseña.';
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
errorMessage = err.response.data?.message || errorMessage;
|
||||
if (err.response.status === 401) {
|
||||
logout(); // Desloguear si el token es inválido
|
||||
onClose(false); // Notificar cierre sin éxito
|
||||
}
|
||||
}
|
||||
setError(errorMessage);
|
||||
setLoading(false); // Asegurarse de quitar loading en caso de error
|
||||
}
|
||||
// No poner setLoading(false) en el finally si quieres que el botón siga deshabilitado durante el success
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={(_event, reason) => { // onClose del Modal de MUI (para backdrop y Escape)
|
||||
if (reason === "backdropClick" && isFirstLogin) {
|
||||
return; // No permitir cerrar con backdrop si es el primer login
|
||||
}
|
||||
onClose(false); // Llamar a la prop onClose (que va a handleModalClose en MainLayout)
|
||||
}}
|
||||
disableEscapeKeyDown={isFirstLogin} // Deshabilitar Escape si es primer login
|
||||
aria-labelledby="change-password-modal-title"
|
||||
aria-describedby="change-password-modal-description"
|
||||
closeAfterTransition
|
||||
slots={{ backdrop: Backdrop }}
|
||||
slotProps={{
|
||||
backdrop: {
|
||||
timeout: 500,
|
||||
sx: { backdropFilter: 'blur(3px)' }
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="change-password-modal-title" variant="h6" component="h2">
|
||||
Cambiar Contraseña
|
||||
</Typography>
|
||||
{isFirstLogin && (
|
||||
<Alert severity="warning" sx={{ mt: 2, width: '100%' }}>
|
||||
Por seguridad, debes cambiar tu contraseña inicial.
|
||||
</Alert>
|
||||
)}
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1, width: '100%' }}>
|
||||
{/* ... TextFields ... */}
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="currentPassword"
|
||||
label="Contraseña Actual"
|
||||
type="password"
|
||||
id="currentPasswordModal"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={loading || !!success}
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="newPassword"
|
||||
label="Nueva Contraseña"
|
||||
type="password"
|
||||
id="newPasswordModal"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading || !!success}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="confirmNewPassword"
|
||||
label="Confirmar Nueva Contraseña"
|
||||
type="password"
|
||||
id="confirmNewPasswordModal"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
disabled={loading || !!success}
|
||||
error={newPassword !== confirmNewPassword && confirmNewPassword !== ''}
|
||||
helperText={newPassword !== confirmNewPassword && confirmNewPassword !== '' ? 'Las contraseñas no coinciden' : ''}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mt: 2, width: '100%' }}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Un solo grupo de botones */}
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{/* El botón de cancelar llama a handleCancelClick */}
|
||||
{/* Se podría ocultar si isFirstLogin es true y no queremos que el usuario cancele */}
|
||||
{/* {!isFirstLogin && ( */}
|
||||
<Button onClick={handleCancelClick} disabled={loading || !!success} color="secondary">
|
||||
{isFirstLogin ? "Cancelar y Salir" : "Cancelar"}
|
||||
</Button>
|
||||
{/* )} */}
|
||||
<Button type="submit" variant="contained" disabled={loading || !!success}>
|
||||
{loading ? <CircularProgress size={24} /> : 'Cambiar Contraseña'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordModal;
|
||||
129
Frontend/src/components/Modals/TipoPagoFormModal.tsx
Normal file
129
Frontend/src/components/Modals/TipoPagoFormModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material';
|
||||
import type { TipoPago } from '../../models/Entities/TipoPago';
|
||||
import type { CreateTipoPagoDto } from '../../models/dtos/tiposPago/CreateTipoPagoDto';
|
||||
|
||||
const modalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
interface TipoPagoFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateTipoPagoDto | (CreateTipoPagoDto & { idTipoPago: number })) => Promise<void>; // Puede ser para crear o actualizar
|
||||
initialData?: TipoPago | null; // Datos para editar
|
||||
errorMessage?: string | null;
|
||||
clearErrorMessage: () => void;
|
||||
}
|
||||
|
||||
const TipoPagoFormModal: React.FC<TipoPagoFormModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initialData,
|
||||
errorMessage,
|
||||
clearErrorMessage
|
||||
}) => {
|
||||
const [nombre, setNombre] = useState('');
|
||||
const [detalle, setDetalle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
|
||||
const isEditing = Boolean(initialData);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setNombre(initialData?.nombre || '');
|
||||
setDetalle(initialData?.detalle || '');
|
||||
setLocalError(null); // Limpiar errores locales al abrir
|
||||
clearErrorMessage(); // Limpiar errores del padre
|
||||
}
|
||||
}, [open, initialData, clearErrorMessage]);
|
||||
|
||||
const handleInputChange = () => {
|
||||
if (localError) setLocalError(null);
|
||||
if (errorMessage) clearErrorMessage();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLocalError(null);
|
||||
clearErrorMessage();
|
||||
|
||||
if (!nombre.trim()) {
|
||||
setLocalError('El nombre es obligatorio.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const dataToSubmit: CreateTipoPagoDto = { nombre, detalle: detalle || undefined };
|
||||
if (isEditing && initialData) {
|
||||
await onSubmit({ ...dataToSubmit, idTipoPago: initialData.idTipoPago });
|
||||
} else {
|
||||
await onSubmit(dataToSubmit);
|
||||
}
|
||||
onClose(); // Cerrar modal en éxito
|
||||
} catch (error: any) {
|
||||
// El error de la API ya se debería manejar en el componente padre
|
||||
// y pasarse a través de 'errorMessage', pero podemos loggear aquí si es un error inesperado
|
||||
console.error("Error en submit de TipoPagoFormModal:", error);
|
||||
// No seteamos localError aquí si el padre maneja 'errorMessage'
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Typography variant="h6" component="h2">
|
||||
{isEditing ? 'Editar Tipo de Pago' : 'Agregar Nuevo Tipo de Pago'}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
label="Nombre"
|
||||
fullWidth
|
||||
required
|
||||
value={nombre}
|
||||
onChange={(e) => { setNombre(e.target.value); handleInputChange(); }}
|
||||
margin="normal"
|
||||
error={!!localError && nombre.trim() === ''}
|
||||
helperText={localError && nombre.trim() === '' ? localError : ''}
|
||||
disabled={loading}
|
||||
/>
|
||||
<TextField
|
||||
label="Detalle (Opcional)"
|
||||
fullWidth
|
||||
value={detalle}
|
||||
onChange={(e) => { setDetalle(e.target.value); handleInputChange();}}
|
||||
margin="normal"
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errorMessage && <Alert severity="error" sx={{ mt: 1 }}>{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} /> : (isEditing ? 'Guardar Cambios' : 'Agregar')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TipoPagoFormModal;
|
||||
@@ -1,13 +1,41 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react'; // Importar como tipo
|
||||
import React, { createContext, useState, useContext, type ReactNode, useEffect } from 'react';
|
||||
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
// Interfaz para los datos del usuario que guardaremos en el contexto
|
||||
export interface UserContextData {
|
||||
userId: number;
|
||||
username: string;
|
||||
nombreCompleto: string;
|
||||
esSuperAdmin: boolean;
|
||||
debeCambiarClave: boolean;
|
||||
idPerfil: number;
|
||||
permissions: string[]; // Guardamos los codAcc
|
||||
}
|
||||
|
||||
// Interfaz para el payload decodificado del JWT
|
||||
interface DecodedJwtPayload {
|
||||
sub: string; // User ID (viene como string)
|
||||
name: string; // Username
|
||||
given_name?: string; // Nombre (estándar, pero verifica tu token)
|
||||
family_name?: string; // Apellido (estándar, pero verifica tu token)
|
||||
role: string | string[]; // Puede ser uno o varios roles
|
||||
idPerfil: string; // (viene como string)
|
||||
debeCambiarClave: string; // (viene como string "True" o "False")
|
||||
permission?: string | string[]; // Nuestros claims de permiso (codAcc)
|
||||
[key: string]: any; // Para otros claims
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: LoginResponseDto | null;
|
||||
user: UserContextData | null; // Usar el tipo extendido
|
||||
token: string | null;
|
||||
isLoading: boolean; // Para saber si aún está verificando el token inicial
|
||||
login: (userData: LoginResponseDto) => void;
|
||||
isLoading: boolean;
|
||||
showForcedPasswordChangeModal: boolean;
|
||||
isPasswordChangeForced: boolean;
|
||||
setShowForcedPasswordChangeModal: (show: boolean) => void;
|
||||
passwordChangeCompleted: () => void;
|
||||
login: (apiLoginResponse: LoginResponseDto) => void; // Recibe el DTO de la API
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -15,37 +43,68 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<LoginResponseDto | null>(null);
|
||||
const [user, setUser] = useState<UserContextData | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true); // Empieza cargando
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [showForcedPasswordChangeModal, setShowForcedPasswordChangeModal] = useState<boolean>(false);
|
||||
const [isPasswordChangeForced, setIsPasswordChangeForced] = useState<boolean>(false);
|
||||
|
||||
// Efecto para verificar token al cargar la app
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
const storedUser = localStorage.getItem('authUser'); // Guardamos el usuario también
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
const processTokenAndSetUser = (jwtToken: string) => {
|
||||
try {
|
||||
// Aquí podrías añadir lógica para validar si el token aún es válido (ej: decodificarlo)
|
||||
// Por ahora, simplemente asumimos que si está, es válido.
|
||||
const parsedUser: LoginResponseDto = JSON.parse(storedUser);
|
||||
setToken(storedToken);
|
||||
setUser(parsedUser);
|
||||
const decodedToken = jwtDecode<DecodedJwtPayload>(jwtToken);
|
||||
|
||||
// Verificar expiración (opcional, pero buena práctica aquí también)
|
||||
const currentTime = Date.now() / 1000;
|
||||
if (decodedToken.exp && decodedToken.exp < currentTime) {
|
||||
console.warn("Token expirado al procesar.");
|
||||
logout(); // Llama a logout que limpia todo
|
||||
return;
|
||||
}
|
||||
|
||||
let permissions: string[] = [];
|
||||
if (decodedToken.permission) {
|
||||
permissions = Array.isArray(decodedToken.permission) ? decodedToken.permission : [decodedToken.permission];
|
||||
}
|
||||
|
||||
const userForContext: UserContextData = {
|
||||
userId: parseInt(decodedToken.sub, 10),
|
||||
username: decodedToken.name,
|
||||
nombreCompleto: `${decodedToken.given_name || ''} ${decodedToken.family_name || ''}`.trim(),
|
||||
esSuperAdmin: decodedToken.role === "SuperAdmin" || (Array.isArray(decodedToken.role) && decodedToken.role.includes("SuperAdmin")),
|
||||
debeCambiarClave: decodedToken.debeCambiarClave?.toLowerCase() === 'true',
|
||||
idPerfil: decodedToken.idPerfil ? parseInt(decodedToken.idPerfil, 10) : 0,
|
||||
permissions: permissions,
|
||||
};
|
||||
|
||||
setToken(jwtToken);
|
||||
setUser(userForContext);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem('authToken', jwtToken);
|
||||
localStorage.setItem('authUser', JSON.stringify(userForContext)); // Guardar el usuario procesado
|
||||
|
||||
// Lógica para el modal de cambio de clave
|
||||
if (userForContext.debeCambiarClave) {
|
||||
setShowForcedPasswordChangeModal(true);
|
||||
setIsPasswordChangeForced(true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error parsing stored user data", error);
|
||||
logout(); // Limpia si hay error al parsear
|
||||
console.error("Error al decodificar o procesar token:", error);
|
||||
logout(); // Limpiar estado si el token es inválido
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const storedToken = localStorage.getItem('authToken');
|
||||
if (storedToken) {
|
||||
processTokenAndSetUser(storedToken);
|
||||
}
|
||||
setIsLoading(false); // Termina la carga inicial
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = (userData: LoginResponseDto) => {
|
||||
localStorage.setItem('authToken', userData.Token);
|
||||
localStorage.setItem('authUser', JSON.stringify(userData)); // Guardar datos de usuario
|
||||
setToken(userData.Token);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
const login = (apiLoginResponse: LoginResponseDto) => {
|
||||
processTokenAndSetUser(apiLoginResponse.token); // Procesar el token recibido
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -54,16 +113,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setShowForcedPasswordChangeModal(false);
|
||||
setIsPasswordChangeForced(false);
|
||||
};
|
||||
|
||||
const passwordChangeCompleted = () => {
|
||||
setShowForcedPasswordChangeModal(false);
|
||||
setIsPasswordChangeForced(false);
|
||||
// Importante: Si el cambio de clave afecta el claim "debeCambiarClave" en el token,
|
||||
// idealmente el backend debería devolver un *nuevo token* después del cambio de clave.
|
||||
// Si no lo hace, el token actual aún dirá que debe cambiar clave.
|
||||
// Una solución simple es actualizar el estado local del usuario:
|
||||
if (user) {
|
||||
const updatedUser = { ...user, debeCambiarClave: false };
|
||||
setUser(updatedUser);
|
||||
localStorage.setItem('authUser', JSON.stringify(updatedUser));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, user, token, isLoading, login, logout }}>
|
||||
<AuthContext.Provider value={{
|
||||
isAuthenticated, user, token, isLoading,
|
||||
showForcedPasswordChangeModal, isPasswordChangeForced,
|
||||
setShowForcedPasswordChangeModal, passwordChangeCompleted,
|
||||
login, logout
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook personalizado para usar el contexto fácilmente
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
|
||||
26
Frontend/src/hooks/usePermissions.ts
Normal file
26
Frontend/src/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/hooks/usePermissions.ts
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export const usePermissions = () => {
|
||||
const { user } = useAuth(); // user aquí es de tipo UserContextData | null
|
||||
|
||||
const tienePermiso = (codigoPermisoRequerido: string): boolean => {
|
||||
if (!user) { // Si no hay usuario logueado
|
||||
return false;
|
||||
}
|
||||
if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos
|
||||
return true;
|
||||
}
|
||||
// Verificar si la lista de permisos del usuario incluye el código requerido
|
||||
return user.permissions?.includes(codigoPermisoRequerido) ?? false;
|
||||
};
|
||||
|
||||
// También puede exportar el objeto user completo si se necesita en otros lugares
|
||||
// o propiedades específicas como idPerfil, esSuperAdmin.
|
||||
return {
|
||||
tienePermiso,
|
||||
isSuperAdmin: user?.esSuperAdmin ?? false,
|
||||
idPerfil: user?.idPerfil ?? 0,
|
||||
currentUser: user
|
||||
};
|
||||
};
|
||||
@@ -1,46 +1,152 @@
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react'; // Importar como tipo
|
||||
import { Box, AppBar, Toolbar, Typography, Button } from '@mui/material';
|
||||
import React, { type ReactNode, useState, useEffect } from 'react';
|
||||
import { Box, AppBar, Toolbar, Typography, Button, Tabs, Tab, Paper } from '@mui/material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChangePasswordModal from '../components/ChangePasswordModal';
|
||||
import { useNavigate, useLocation } from 'react-router-dom'; // Para manejar la navegación y la ruta actual
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode; // Para renderizar las páginas hijas
|
||||
children: ReactNode; // Esto será el <Outlet /> que renderiza las páginas del módulo
|
||||
}
|
||||
|
||||
// Definir los módulos y sus rutas base
|
||||
const modules = [
|
||||
{ label: 'Inicio', path: '/' },
|
||||
{ label: 'Distribución', path: '/distribucion' }, // Asumiremos rutas base como /distribucion, /contables, etc.
|
||||
{ label: 'Contables', path: '/contables' },
|
||||
{ label: 'Impresión', path: '/impresion' },
|
||||
{ label: 'Reportes', path: '/reportes' },
|
||||
{ label: 'Radios', path: '/radios' },
|
||||
{ label: 'Usuarios', path: '/usuarios' },
|
||||
];
|
||||
|
||||
const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
|
||||
const { user, logout } = useAuth();
|
||||
const {
|
||||
user,
|
||||
logout,
|
||||
showForcedPasswordChangeModal,
|
||||
isPasswordChangeForced,
|
||||
passwordChangeCompleted,
|
||||
setShowForcedPasswordChangeModal,
|
||||
isAuthenticated
|
||||
} = useAuth();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation(); // Para obtener la ruta actual
|
||||
|
||||
// Estado para el tab seleccionado
|
||||
const [selectedTab, setSelectedTab] = useState<number | false>(false);
|
||||
|
||||
// Efecto para sincronizar el tab seleccionado con la ruta actual
|
||||
useEffect(() => {
|
||||
const currentModulePath = modules.findIndex(module =>
|
||||
location.pathname === module.path || (module.path !== '/' && location.pathname.startsWith(module.path + '/'))
|
||||
);
|
||||
if (currentModulePath !== -1) {
|
||||
setSelectedTab(currentModulePath);
|
||||
} else if (location.pathname === '/') {
|
||||
setSelectedTab(0); // Seleccionar "Inicio" si es la raíz
|
||||
} else {
|
||||
setSelectedTab(false); // Ningún tab coincide (podría ser una sub-ruta no principal)
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleModalClose = (passwordChangedSuccessfully: boolean) => {
|
||||
// ... (lógica de handleModalClose existente) ...
|
||||
if (passwordChangedSuccessfully) {
|
||||
passwordChangeCompleted();
|
||||
} else {
|
||||
if (isPasswordChangeForced) {
|
||||
logout();
|
||||
} else {
|
||||
setShowForcedPasswordChangeModal(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setSelectedTab(newValue);
|
||||
navigate(modules[newValue].path); // Navegar a la ruta base del módulo
|
||||
};
|
||||
|
||||
// Si el modal de cambio de clave forzado está activo, no mostramos la navegación principal aún.
|
||||
// El modal se superpone.
|
||||
if (showForcedPasswordChangeModal && isPasswordChangeForced) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<ChangePasswordModal
|
||||
open={showForcedPasswordChangeModal}
|
||||
onClose={handleModalClose}
|
||||
isFirstLogin={isPasswordChangeForced}
|
||||
/>
|
||||
{/* Podrías querer un fondo o layout mínimo aquí si el modal no es pantalla completa */}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Gestión Integral
|
||||
Sistema de Gestión - El Día
|
||||
</Typography>
|
||||
{user && <Typography sx={{ mr: 2 }}>Hola, {user.Username}</Typography> }
|
||||
{user && <Typography sx={{ mr: 2 }}>Hola, {user.nombreCompleto}</Typography>}
|
||||
{isAuthenticated && !isPasswordChangeForced && (
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => setShowForcedPasswordChangeModal(true)} // Ahora abre el modal
|
||||
>
|
||||
Cambiar Contraseña
|
||||
</Button>
|
||||
)}
|
||||
<Button color="inherit" onClick={logout}>Cerrar Sesión</Button>
|
||||
</Toolbar>
|
||||
{/* Aquí iría el MaterialTabControl o similar para la navegación principal */}
|
||||
{/* Navegación Principal por Módulos */}
|
||||
<Paper square elevation={0} > {/* Usamos Paper para un fondo consistente para los Tabs */}
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onChange={handleTabChange}
|
||||
indicatorColor="secondary" // O "primary"
|
||||
textColor="inherit" // O "primary" / "secondary"
|
||||
variant="scrollable" // Permite scroll si hay muchos tabs
|
||||
scrollButtons="auto" // Muestra botones de scroll si es necesario
|
||||
aria-label="módulos principales"
|
||||
sx={{ backgroundColor: 'primary.main', color: 'white' }} // Color de fondo para los tabs
|
||||
>
|
||||
{modules.map((module) => (
|
||||
<Tab key={module.path} label={module.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
</AppBar>
|
||||
|
||||
{/* Contenido del Módulo (renderizado por <Outlet /> en AppRoutes) */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3, // Padding
|
||||
// Puedes añadir color de fondo si lo deseas
|
||||
// backgroundColor: (theme) => theme.palette.background.default,
|
||||
// overflowY: 'auto' // Si el contenido del módulo es muy largo
|
||||
}}
|
||||
>
|
||||
{/* El contenido de la página actual se renderizará aquí */}
|
||||
{children}
|
||||
</Box>
|
||||
{/* Aquí podría ir un Footer o StatusStrip */}
|
||||
|
||||
<Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}>
|
||||
<Typography variant="body2">
|
||||
{/* Replicar info del StatusStrip original */}
|
||||
Usuario: {user?.Username} | Acceso: {user?.EsSuperAdmin ? 'Super Admin' : 'Perfil...'} | Versión: {/** Obtener versión **/}
|
||||
Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Admin' : `Perfil ID ${user?.userId}`} | Versión: {/* TODO: Obtener versión */}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Modal para cambio de clave opcional (no forzado) */}
|
||||
{/* Si showForcedPasswordChangeModal es true pero isPasswordChangeForced es false,
|
||||
se mostrará aquí también. */}
|
||||
<ChangePasswordModal
|
||||
open={showForcedPasswordChangeModal}
|
||||
onClose={handleModalClose}
|
||||
isFirstLogin={isPasswordChangeForced} // Esto controla el comportamiento del modal
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
5
Frontend/src/models/Entities/TipoPago.ts
Normal file
5
Frontend/src/models/Entities/TipoPago.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface TipoPago {
|
||||
idTipoPago: number;
|
||||
nombre: string;
|
||||
detalle?: string; // El detalle es opcional
|
||||
}
|
||||
6
Frontend/src/models/dtos/ChangePasswordRequestDto.ts
Normal file
6
Frontend/src/models/dtos/ChangePasswordRequestDto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// src/models/dtos/ChangePasswordRequestDto.ts
|
||||
export interface ChangePasswordRequestDto {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// src/models/dtos/LoginResponseDto.ts
|
||||
export interface LoginResponseDto {
|
||||
Token: string;
|
||||
UserId: number;
|
||||
Username: string;
|
||||
NombreCompleto: string;
|
||||
EsSuperAdmin: boolean;
|
||||
DebeCambiarClave: boolean;
|
||||
token: string;
|
||||
userId: number;
|
||||
username: string;
|
||||
nombreCompleto: string;
|
||||
esSuperAdmin: boolean;
|
||||
debeCambiarClave: boolean;
|
||||
// Añade otros campos si los definiste en el DTO C#
|
||||
}
|
||||
4
Frontend/src/models/dtos/tiposPago/CreateTipoPagoDto.ts
Normal file
4
Frontend/src/models/dtos/tiposPago/CreateTipoPagoDto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface CreateTipoPagoDto {
|
||||
nombre: string;
|
||||
detalle?: string;
|
||||
}
|
||||
4
Frontend/src/models/dtos/tiposPago/UpdateTipoPagoDto.ts
Normal file
4
Frontend/src/models/dtos/tiposPago/UpdateTipoPagoDto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface UpdateTipoPagoDto {
|
||||
nombre: string;
|
||||
detalle?: string;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Typography, Container } from '@mui/material';
|
||||
// import { useLocation } from 'react-router-dom'; // Para obtener el estado 'firstLogin'
|
||||
|
||||
const ChangePasswordPage: React.FC = () => {
|
||||
// const location = useLocation();
|
||||
// const isFirstLogin = location.state?.firstLogin;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Cambiar Contraseña
|
||||
</Typography>
|
||||
{/* {isFirstLogin && <Alert severity="warning">Debes cambiar tu contraseña inicial.</Alert>} */}
|
||||
{/* Aquí irá el formulario de cambio de contraseña */}
|
||||
<Typography variant="body1">
|
||||
Formulario de cambio de contraseña irá aquí...
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordPage;
|
||||
22
Frontend/src/pages/ChangePasswordPagePlaceholder.tsx
Normal file
22
Frontend/src/pages/ChangePasswordPagePlaceholder.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Typography, Container, Button } from '@mui/material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ChangePasswordPagePlaceholder: React.FC = () => {
|
||||
const { setShowForcedPasswordChangeModal } = useAuth();
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Cambiar Contraseña (Página)
|
||||
</Typography>
|
||||
<Typography>
|
||||
La funcionalidad de cambio de contraseña ahora se maneja principalmente a través de un modal.
|
||||
</Typography>
|
||||
<Button onClick={() => setShowForcedPasswordChangeModal(true)}>
|
||||
Abrir Modal de Cambio de Contraseña
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordPagePlaceholder;
|
||||
70
Frontend/src/pages/Contables/ContablesIndexPage.tsx
Normal file
70
Frontend/src/pages/Contables/ContablesIndexPage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// src/pages/contables/ContablesIndexPage.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
// Define las sub-pestañas del módulo Contables
|
||||
const contablesSubModules = [
|
||||
{ label: 'Tipos de Pago', path: 'tipos-pago' }, // Se convertirá en /contables/tipos-pago
|
||||
// { label: 'Pagos', path: 'pagos' }, // Ejemplo de otra sub-pestaña futura
|
||||
// { label: 'Créditos/Débitos', path: 'creditos-debitos' },
|
||||
];
|
||||
|
||||
const ContablesIndexPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentBasePath = '/contables';
|
||||
const subPath = location.pathname.startsWith(currentBasePath + '/')
|
||||
? location.pathname.substring(currentBasePath.length + 1)
|
||||
: (location.pathname === currentBasePath ? contablesSubModules[0]?.path : undefined);
|
||||
|
||||
const activeTabIndex = contablesSubModules.findIndex(
|
||||
(subModule) => subModule.path === subPath
|
||||
);
|
||||
|
||||
if (activeTabIndex !== -1) {
|
||||
setSelectedSubTab(activeTabIndex);
|
||||
} else {
|
||||
if (location.pathname === currentBasePath && contablesSubModules.length > 0) {
|
||||
navigate(contablesSubModules[0].path, { replace: true });
|
||||
setSelectedSubTab(0);
|
||||
} else {
|
||||
setSelectedSubTab(false);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setSelectedSubTab(newValue);
|
||||
navigate(contablesSubModules[newValue].path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Módulo Contable</Typography>
|
||||
<Paper square elevation={1}>
|
||||
<Tabs
|
||||
value={selectedSubTab}
|
||||
onChange={handleSubTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="sub-módulos contables"
|
||||
>
|
||||
{contablesSubModules.map((subModule) => (
|
||||
<Tab key={subModule.path} label={subModule.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<Outlet /> {/* Aquí se renderizarán GestionarTiposPagoPage, etc. */}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContablesIndexPage;
|
||||
242
Frontend/src/pages/Contables/GestionarTiposPagoPage.tsx
Normal file
242
Frontend/src/pages/Contables/GestionarTiposPagoPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
// src/pages/configuracion/GestionarTiposPagoPage.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
|
||||
CircularProgress, Alert
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import tipoPagoService from '../../services/tipoPagoService';
|
||||
import type { TipoPago } from '../../models/Entities/TipoPago';
|
||||
import type { CreateTipoPagoDto } from '../../models/dtos/tiposPago/CreateTipoPagoDto';
|
||||
import type { UpdateTipoPagoDto } from '../../models/dtos/tiposPago/UpdateTipoPagoDto';
|
||||
import TipoPagoFormModal from '../../components/Modals/TipoPagoFormModal';
|
||||
import axios from 'axios';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
|
||||
const GestionarTiposPagoPage: React.FC = () => {
|
||||
const [tiposPago, setTiposPago] = useState<TipoPago[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filtroNombre, setFiltroNombre] = useState('');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingTipoPago, setEditingTipoPago] = useState<TipoPago | null>(null);
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
|
||||
// Para el menú contextual de cada fila
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [selectedTipoPagoRow, setSelectedTipoPagoRow] = useState<TipoPago | null>(null);
|
||||
|
||||
const { tienePermiso, isSuperAdmin } = usePermissions(); // Obtener también isSuperAdmin
|
||||
|
||||
const puedeCrear = isSuperAdmin || tienePermiso("CT002");
|
||||
const puedeModificar = isSuperAdmin || tienePermiso("CT003");
|
||||
const puedeEliminar = isSuperAdmin || tienePermiso("CT004");
|
||||
|
||||
|
||||
const cargarTiposPago = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await tipoPagoService.getAllTiposPago(filtroNombre);
|
||||
setTiposPago(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Error al cargar los tipos de pago.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filtroNombre]);
|
||||
|
||||
useEffect(() => {
|
||||
cargarTiposPago();
|
||||
}, [cargarTiposPago]);
|
||||
|
||||
const handleOpenModal = (tipoPago?: TipoPago) => {
|
||||
setEditingTipoPago(tipoPago || null);
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditingTipoPago(null);
|
||||
};
|
||||
|
||||
const handleSubmitModal = async (data: CreateTipoPagoDto | UpdateTipoPagoDto) => {
|
||||
setApiErrorMessage(null); // Limpiar error previo
|
||||
try {
|
||||
if (editingTipoPago && 'idTipoPago' in data) { // Es Update
|
||||
await tipoPagoService.updateTipoPago(editingTipoPago.idTipoPago, data as UpdateTipoPagoDto);
|
||||
} else { // Es Create
|
||||
await tipoPagoService.createTipoPago(data as CreateTipoPagoDto);
|
||||
}
|
||||
cargarTiposPago(); // Recargar lista
|
||||
// onClose se llama desde el modal en caso de éxito
|
||||
} catch (err: any) {
|
||||
console.error("Error en submit modal (padre):", err);
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
setApiErrorMessage(err.response.data?.message || 'Error al guardar.');
|
||||
} else {
|
||||
setApiErrorMessage('Ocurrió un error inesperado al guardar.');
|
||||
}
|
||||
throw err; // Re-lanzar para que el modal sepa que hubo error y no se cierre
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('¿Está seguro de que desea eliminar este tipo de pago?')) {
|
||||
setApiErrorMessage(null);
|
||||
try {
|
||||
await tipoPagoService.deleteTipoPago(id);
|
||||
cargarTiposPago();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
setApiErrorMessage(err.response.data?.message || 'Error al eliminar.');
|
||||
} else {
|
||||
setApiErrorMessage('Ocurrió un error inesperado al eliminar.');
|
||||
}
|
||||
}
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, tipoPago: TipoPago) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedTipoPagoRow(tipoPago);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedTipoPagoRow(null);
|
||||
};
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const displayData = tiposPago.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Gestionar Tipos de Pago
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Filtrar por Nombre"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={filtroNombre}
|
||||
onChange={(e) => setFiltroNombre(e.target.value)}
|
||||
// sx={{ flexGrow: 1 }} // Opcional, para que ocupe más espacio
|
||||
/>
|
||||
{/* El botón de búsqueda se activa al cambiar el texto, pero puedes añadir uno explícito */}
|
||||
{/* <Button variant="contained" onClick={cargarTiposPago}>Buscar</Button> */}
|
||||
</Box>
|
||||
{puedeCrear && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenModal()}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Agregar Nuevo Tipo
|
||||
</Button>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
|
||||
{!loading && !error && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Nombre</TableCell>
|
||||
<TableCell>Detalle</TableCell>
|
||||
<TableCell align="right">Acciones</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{displayData.length === 0 && !loading ? (
|
||||
<TableRow><TableCell colSpan={3} align="center">No se encontraron tipos de pago.</TableCell></TableRow>
|
||||
) : (
|
||||
displayData.map((tipo) => (
|
||||
<TableRow key={tipo.idTipoPago}>
|
||||
<TableCell>{tipo.nombre}</TableCell>
|
||||
<TableCell>{tipo.detalle || '-'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
onClick={(e) => handleMenuOpen(e, tipo)}
|
||||
disabled={!puedeModificar && !puedeEliminar}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
component="div"
|
||||
count={tiposPago.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
labelRowsPerPage="Filas por página:"
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
{puedeModificar && (
|
||||
<MenuItem onClick={() => { handleOpenModal(selectedTipoPagoRow!); handleMenuClose(); }}>
|
||||
Modificar
|
||||
</MenuItem>
|
||||
)}
|
||||
{puedeEliminar && (
|
||||
<MenuItem onClick={() => handleDelete(selectedTipoPagoRow!.idTipoPago)}>
|
||||
Eliminar
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Si no tiene ningún permiso, el menú podría estar vacío o no mostrarse */}
|
||||
{(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
|
||||
</Menu>
|
||||
|
||||
<TipoPagoFormModal
|
||||
open={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSubmit={handleSubmitModal}
|
||||
initialData={editingTipoPago}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GestionarTiposPagoPage;
|
||||
7
Frontend/src/pages/Distribucion/CanillasPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/CanillasPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const CanillasPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Canillas</Typography>;
|
||||
};
|
||||
export default CanillasPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const CtrlDevolucionesPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión del Control de Devoluciones</Typography>;
|
||||
};
|
||||
export default CtrlDevolucionesPage;
|
||||
88
Frontend/src/pages/Distribucion/DistribucionIndexPage.tsx
Normal file
88
Frontend/src/pages/Distribucion/DistribucionIndexPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
// src/pages/distribucion/DistribucionIndexPage.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Tabs, Tab, Paper, Typography } from '@mui/material';
|
||||
import { Outlet, useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
// Define las sub-pestañas del módulo Distribución
|
||||
// El path es relativo a la ruta base del módulo (ej: /distribucion)
|
||||
const distribucionSubModules = [
|
||||
{ label: 'E/S Canillas', path: 'es-canillas' }, // Se convertirá en /distribucion/es-canillas
|
||||
{ label: 'Ctrl. Devoluciones', path: 'control-devoluciones' },
|
||||
{ label: 'E/S Distribuidores', path: 'es-distribuidores' },
|
||||
{ label: 'Salidas Otros Dest.', path: 'salidas-otros-destinos' },
|
||||
{ label: 'Canillas', path: 'canillas' },
|
||||
{ label: 'Distribuidores', path: 'distribuidores' },
|
||||
{ label: 'Publicaciones', path: 'publicaciones' },
|
||||
{ label: 'Otros Destinos', path: 'otros-destinos' },
|
||||
{ label: 'Zonas', path: 'zonas' },
|
||||
{ label: 'Empresas', path: 'empresas' },
|
||||
];
|
||||
|
||||
const DistribucionIndexPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [selectedSubTab, setSelectedSubTab] = useState<number | false>(false);
|
||||
|
||||
// Sincronizar el sub-tab con la URL actual
|
||||
useEffect(() => {
|
||||
// location.pathname será algo como /distribucion/canillas
|
||||
// Necesitamos extraer la última parte para compararla con los paths de subSubModules
|
||||
const currentBasePath = '/distribucion'; // Ruta base de este módulo
|
||||
const subPath = location.pathname.startsWith(currentBasePath + '/')
|
||||
? location.pathname.substring(currentBasePath.length + 1)
|
||||
: (location.pathname === currentBasePath ? distribucionSubModules[0]?.path : undefined); // Si es /distribucion, selecciona el primero
|
||||
|
||||
const activeTabIndex = distribucionSubModules.findIndex(
|
||||
(subModule) => subModule.path === subPath
|
||||
);
|
||||
|
||||
if (activeTabIndex !== -1) {
|
||||
setSelectedSubTab(activeTabIndex);
|
||||
} else {
|
||||
// Si no coincide ninguna sub-ruta, pero estamos en /distribucion, ir al primer tab
|
||||
if (location.pathname === currentBasePath && distribucionSubModules.length > 0) {
|
||||
navigate(distribucionSubModules[0].path, { replace: true }); // Navegar a la primera sub-ruta
|
||||
setSelectedSubTab(0);
|
||||
} else {
|
||||
setSelectedSubTab(false); // Ningún sub-tab activo
|
||||
}
|
||||
}
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
const handleSubTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setSelectedSubTab(newValue);
|
||||
navigate(distribucionSubModules[newValue].path); // Navega a la sub-ruta (ej: 'canillas')
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>Módulo de Distribución</Typography>
|
||||
<Paper square elevation={1}>
|
||||
<Tabs
|
||||
value={selectedSubTab}
|
||||
onChange={handleSubTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="sub-módulos de distribución"
|
||||
>
|
||||
{distribucionSubModules.map((subModule) => (
|
||||
// Usar RouterLink para que el tab se comporte como un enlace y actualice la URL
|
||||
// La navegación real la manejamos con navigate en handleSubTabChange
|
||||
// para poder actualizar el estado del tab seleccionado.
|
||||
// Podríamos usar `component={RouterLink} to={subModule.path}` también,
|
||||
// pero manejarlo con navigate da más control sobre el estado.
|
||||
<Tab key={subModule.path} label={subModule.label} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ pt: 2 }}> {/* Padding para el contenido de la sub-pestaña */}
|
||||
{/* Outlet renderizará el componente de la sub-ruta activa (ej: CanillasPage) */}
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DistribucionIndexPage;
|
||||
7
Frontend/src/pages/Distribucion/DistribuidoresPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/DistribuidoresPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const DistribuidoresPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Distribuidores</Typography>;
|
||||
};
|
||||
export default DistribuidoresPage;
|
||||
7
Frontend/src/pages/Distribucion/ESCanillasPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/ESCanillasPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const ESCanillasPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de E/S de Canillas</Typography>;
|
||||
};
|
||||
export default ESCanillasPage;
|
||||
7
Frontend/src/pages/Distribucion/ESDistribuidoresPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/ESDistribuidoresPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const ESDistribuidoresPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de E/S de Distribuidores</Typography>;
|
||||
};
|
||||
export default ESDistribuidoresPage;
|
||||
7
Frontend/src/pages/Distribucion/EmpresasPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/EmpresasPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const EmpresasPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Empresas</Typography>;
|
||||
};
|
||||
export default EmpresasPage;
|
||||
7
Frontend/src/pages/Distribucion/OtrosDestinosPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/OtrosDestinosPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const OtrosDestinosPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Otros Destinos</Typography>;
|
||||
};
|
||||
export default OtrosDestinosPage;
|
||||
7
Frontend/src/pages/Distribucion/PublicacionesPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/PublicacionesPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const PublicacionesPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Publicaciones</Typography>;
|
||||
};
|
||||
export default PublicacionesPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const SalidastrosDestinosPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Salidas a Otros Destinos</Typography>;
|
||||
};
|
||||
export default SalidastrosDestinosPage;
|
||||
7
Frontend/src/pages/Distribucion/ZonasPage.tsx
Normal file
7
Frontend/src/pages/Distribucion/ZonasPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
const ZonasPage: React.FC = () => {
|
||||
return <Typography variant="h6">Página de Gestión de Zonas</Typography>;
|
||||
};
|
||||
export default ZonasPage;
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios'; // Importar axios
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import apiClient from '../services/apiClient'; // Nuestro cliente axios
|
||||
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto'; // Usar type
|
||||
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto'; // Usar type
|
||||
|
||||
// Importaciones de Material UI
|
||||
import { Container, TextField, Button, Typography, Box, Alert } from '@mui/material';
|
||||
import authService from '../services/authService';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -15,7 +13,6 @@ const LoginPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -25,25 +22,17 @@ const LoginPage: React.FC = () => {
|
||||
const loginData: LoginRequestDto = { Username: username, Password: password };
|
||||
|
||||
try {
|
||||
const response = await apiClient.post<LoginResponseDto>('/auth/login', loginData);
|
||||
login(response.data); // Guardar token y estado de usuario en el contexto
|
||||
|
||||
// TODO: Verificar si response.data.DebeCambiarClave es true y redirigir
|
||||
// a '/change-password' si es necesario.
|
||||
// if (response.data.DebeCambiarClave) {
|
||||
// navigate('/change-password', { state: { firstLogin: true } }); // Pasar estado si es necesario
|
||||
// } else {
|
||||
navigate('/'); // Redirigir a la página principal
|
||||
// }
|
||||
|
||||
const response = await authService.login(loginData);
|
||||
login(response);
|
||||
} catch (err: any) {
|
||||
console.error("Login error:", err);
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
// Intenta obtener el mensaje de error de la API, si no, usa uno genérico
|
||||
setError(err.response.data?.message || 'Error al iniciar sesión. Verifique sus credenciales.');
|
||||
} else {
|
||||
setError('Ocurrió un error inesperado.');
|
||||
}
|
||||
// Importante: NO llamar a navigate('/') aquí en el catch,
|
||||
// porque el estado isAuthenticated no habrá cambiado a true
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,125 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
// src/routes/AppRoutes.tsx
|
||||
import React, { type JSX } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import LoginPage from '../pages/LoginPage';
|
||||
import HomePage from '../pages/HomePage'; // Crearemos esta página simple
|
||||
import HomePage from '../pages/HomePage';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChangePasswordPage from '../pages/ChangePasswordPage'; // Crearemos esta
|
||||
import MainLayout from '../layouts/MainLayout'; // Crearemos este
|
||||
import MainLayout from '../layouts/MainLayout';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
// Componente para proteger rutas
|
||||
// Distribución
|
||||
import DistribucionIndexPage from '../pages/Distribucion/DistribucionIndexPage';
|
||||
import ESCanillasPage from '../pages/Distribucion/ESCanillasPage';
|
||||
import ControlDevolucionesPage from '../pages/Distribucion/ControlDevolucionesPage';
|
||||
import ESDistribuidoresPage from '../pages/Distribucion/ESDistribuidoresPage';
|
||||
import SalidasOtrosDestinosPage from '../pages/Distribucion/SalidasOtrosDestinosPage';
|
||||
import CanillasPage from '../pages/Distribucion/CanillasPage';
|
||||
import DistribuidoresPage from '../pages/Distribucion/DistribuidoresPage';
|
||||
import PublicacionesPage from '../pages/Distribucion/PublicacionesPage';
|
||||
import OtrosDestinosPage from '../pages/Distribucion/OtrosDestinosPage';
|
||||
import ZonasPage from '../pages/Distribucion/ZonasPage';
|
||||
import EmpresasPage from '../pages/Distribucion/EmpresasPage';
|
||||
|
||||
// Contables
|
||||
import ContablesIndexPage from '../pages/Contables/ContablesIndexPage';
|
||||
import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage'; // Asumiendo que lo moviste aquí
|
||||
|
||||
// --- ProtectedRoute y PublicRoute SIN CAMBIOS ---
|
||||
const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
// Muestra algo mientras verifica el token (ej: un spinner)
|
||||
return <div>Cargando...</div>;
|
||||
// console.log("ProtectedRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
|
||||
if (isLoading) return null;
|
||||
if (!isAuthenticated) {
|
||||
// console.log("ProtectedRoute: Not authenticated, redirecting to /login");
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
// console.log("ProtectedRoute: Authenticated, rendering children");
|
||||
return children;
|
||||
};
|
||||
|
||||
// Componente para rutas públicas (redirige si ya está logueado)
|
||||
const PublicRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Cargando...</div>;
|
||||
// console.log("PublicRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
|
||||
if (isLoading) return null;
|
||||
if (isAuthenticated) {
|
||||
// console.log("PublicRoute: Authenticated, redirecting to /");
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return !isAuthenticated ? children : <Navigate to="/" replace />;
|
||||
// console.log("PublicRoute: Not authenticated, rendering children");
|
||||
return children;
|
||||
};
|
||||
// --- Fin Protected/Public ---
|
||||
|
||||
const MainLayoutWrapper: React.FC = () => (
|
||||
<MainLayout>
|
||||
<Outlet />
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
// Placeholder simple
|
||||
const PlaceholderPage: React.FC<{ moduleName: string }> = ({ moduleName }) => (
|
||||
<Typography variant="h5" sx={{ p:2 }}>Página Principal del Módulo: {moduleName}</Typography>
|
||||
);
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Rutas Públicas */}
|
||||
<Routes> {/* Un solo <Routes> de nivel superior */}
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route path="/change-password" element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>} /> {/* Asumimos que se accede logueado */}
|
||||
|
||||
{/* Rutas Protegidas dentro del Layout Principal */}
|
||||
{/* Rutas Protegidas que usan el MainLayout */}
|
||||
<Route
|
||||
path="/*" // Captura todas las demás rutas
|
||||
path="/" // La ruta padre para todas las secciones protegidas
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout> {/* Layout que tendrá la navegación principal */}
|
||||
{/* Aquí irán las rutas de los módulos */}
|
||||
<Routes>
|
||||
<Route index element={<HomePage />} /> {/* Página por defecto al loguearse */}
|
||||
{/* <Route path="/usuarios" element={<GestionUsuariosPage />} /> */}
|
||||
{/* <Route path="/zonas" element={<GestionZonasPage />} /> */}
|
||||
{/* ... otras rutas de módulos ... */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} /> {/* Redirige rutas no encontradas al home */}
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
<MainLayoutWrapper/>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
>
|
||||
{/* Rutas hijas que se renderizarán en el Outlet de MainLayoutWrapper */}
|
||||
<Route index element={<HomePage />} /> {/* Para la ruta exacta "/" */}
|
||||
|
||||
{/* Módulo de Distribución (anidado) */}
|
||||
<Route path="distribucion" element={<DistribucionIndexPage />}>
|
||||
<Route index element={<Navigate to="es-canillas" replace />} />
|
||||
<Route path="es-canillas" element={<ESCanillasPage />} />
|
||||
<Route path="control-devoluciones" element={<ControlDevolucionesPage />} />
|
||||
<Route path="es-distribuidores" element={<ESDistribuidoresPage />} />
|
||||
<Route path="salidas-otros-destinos" element={<SalidasOtrosDestinosPage />} />
|
||||
<Route path="canillas" element={<CanillasPage />} />
|
||||
<Route path="distribuidores" element={<DistribuidoresPage />} />
|
||||
<Route path="publicaciones" element={<PublicacionesPage />} />
|
||||
<Route path="otros-destinos" element={<OtrosDestinosPage />} />
|
||||
<Route path="zonas" element={<ZonasPage />} />
|
||||
<Route path="empresas" element={<EmpresasPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Módulo Contable (anidado) */}
|
||||
<Route path="contables" element={<ContablesIndexPage />}>
|
||||
<Route index element={<Navigate to="tipos-pago" replace />} />
|
||||
<Route path="tipos-pago" element={<GestionarTiposPagoPage />} />
|
||||
{/* Futuras sub-rutas de contables aquí */}
|
||||
</Route>
|
||||
|
||||
{/* Otros Módulos Principales (estos son "finales", no tienen más hijos) */}
|
||||
<Route path="impresion" element={<PlaceholderPage moduleName="Impresión" />} />
|
||||
<Route path="reportes" element={<PlaceholderPage moduleName="Reportes" />} />
|
||||
<Route path="radios" element={<PlaceholderPage moduleName="Radios" />} />
|
||||
{/* <Route path="usuarios" element={<PlaceholderPage moduleName="Usuarios" />} /> */}
|
||||
|
||||
{/* Ruta catch-all DENTRO del layout protegido */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route> {/* Cierre de la ruta padre "/" */}
|
||||
|
||||
{/* Podrías tener un catch-all global aquí si una ruta no coincide EN ABSOLUTO,
|
||||
pero el path="*" dentro de la ruta "/" ya debería manejar la mayoría de los casos
|
||||
después de un login exitoso.
|
||||
Si un usuario no autenticado intenta una ruta inválida, ProtectedRoute lo manda a /login.
|
||||
*/}
|
||||
{/* <Route path="*" element={<Navigate to="/login" replace />} /> */}
|
||||
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
21
Frontend/src/services/authService.ts
Normal file
21
Frontend/src/services/authService.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto';
|
||||
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto';
|
||||
import type { ChangePasswordRequestDto } from '../models/dtos/ChangePasswordRequestDto'; // Importar DTO
|
||||
|
||||
const login = async (credentials: LoginRequestDto): Promise<LoginResponseDto> => {
|
||||
const response = await apiClient.post<LoginResponseDto>('/auth/login', credentials);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const changePassword = async (data: ChangePasswordRequestDto): Promise<void> => {
|
||||
// No esperamos datos de vuelta, solo éxito (204) o error
|
||||
await apiClient.post('/auth/change-password', data);
|
||||
};
|
||||
|
||||
const authService = {
|
||||
login,
|
||||
changePassword, // Exportar la nueva función
|
||||
};
|
||||
|
||||
export default authService;
|
||||
43
Frontend/src/services/tipoPagoService.ts
Normal file
43
Frontend/src/services/tipoPagoService.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { TipoPago } from '../models/Entities/TipoPago';
|
||||
import type { CreateTipoPagoDto } from '../models/dtos/tiposPago/CreateTipoPagoDto';
|
||||
import type { UpdateTipoPagoDto } from '../models/dtos/tiposPago/UpdateTipoPagoDto';
|
||||
|
||||
const getAllTiposPago = async (nombreFilter?: string): Promise<TipoPago[]> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (nombreFilter) {
|
||||
params.nombre = nombreFilter;
|
||||
}
|
||||
const response = await apiClient.get<TipoPago[]>('/tipospago', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const getTipoPagoById = async (id: number): Promise<TipoPago> => {
|
||||
const response = await apiClient.get<TipoPago>(`/tipospago/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const createTipoPago = async (data: CreateTipoPagoDto): Promise<TipoPago> => {
|
||||
const response = await apiClient.post<TipoPago>('/tipospago', data);
|
||||
return response.data; // La API devuelve el objeto creado (201 Created)
|
||||
};
|
||||
|
||||
const updateTipoPago = async (id: number, data: UpdateTipoPagoDto): Promise<void> => {
|
||||
// PUT no suele devolver contenido en éxito (204 No Content)
|
||||
await apiClient.put(`/tipospago/${id}`, data);
|
||||
};
|
||||
|
||||
const deleteTipoPago = async (id: number): Promise<void> => {
|
||||
// DELETE no suele devolver contenido en éxito (204 No Content)
|
||||
await apiClient.delete(`/tipospago/${id}`);
|
||||
};
|
||||
|
||||
const tipoPagoService = {
|
||||
getAllTiposPago,
|
||||
getTipoPagoById,
|
||||
createTipoPago,
|
||||
updateTipoPago,
|
||||
deleteTipoPago,
|
||||
};
|
||||
|
||||
export default tipoPagoService;
|
||||
Reference in New Issue
Block a user