- 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:
2025-05-07 13:41:18 -03:00
parent da7b544372
commit 5c4b961073
49 changed files with 2552 additions and 491 deletions

View File

@@ -1,6 +1,8 @@
using GestionIntegral.Api.Dtos; using GestionIntegral.Api.Dtos;
using GestionIntegral.Api.Services; using GestionIntegral.Api.Services;
using Microsoft.AspNetCore.Authorization; // Para [Authorize]
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims; // Para leer claims del token
namespace GestionIntegral.Api.Controllers namespace GestionIntegral.Api.Controllers
{ {
@@ -50,7 +52,49 @@ namespace GestionIntegral.Api.Controllers
} }
} }
// TODO: Añadir endpoint para cambiar clave [HttpPost("change-password")] [HttpPost("change-password")]
// Probablemente requerirá [Authorize] para que solo usuarios logueados puedan usarlo. [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." });
}
}
} }
} }

View File

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

View File

@@ -7,10 +7,13 @@ namespace GestionIntegral.Api.Data
public class AuthRepository : IAuthRepository public class AuthRepository : IAuthRepository
{ {
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<AuthRepository> _logger;
public AuthRepository(DbConnectionFactory connectionFactory) public AuthRepository(DbConnectionFactory connectionFactory, ILogger<AuthRepository> logger)
{ {
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
_logger = logger;
} }
public async Task<Usuario?> GetUserByUsernameAsync(string username) public async Task<Usuario?> GetUserByUsernameAsync(string username)
@@ -30,13 +33,81 @@ namespace GestionIntegral.Api.Data
} }
catch (Exception ex) catch (Exception ex)
{ {
// Loggear el error ex.Message Console.WriteLine($"Error fetching user by username: {ex.Message}");
Console.WriteLine($"Error fetching user: {ex.Message}");
return null; return null;
} }
} }
// TODO: Implementar métodos para cambiar clave (UPDATE seguro con parámetros) public async Task<Usuario?> GetUserByIdAsync(int userId)
// y para crear usuario (INSERT seguro con parámetros, usando el hasher) {
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>();
}
}
} }
} }

View File

@@ -5,6 +5,8 @@ namespace GestionIntegral.Api.Data
public interface IAuthRepository public interface IAuthRepository
{ {
Task<Usuario?> GetUserByUsernameAsync(string username); 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);
} }
} }

View File

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

View File

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

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,18 @@ using Microsoft.IdentityModel.Tokens;
using System.Text; using System.Text;
using GestionIntegral.Api.Data; using GestionIntegral.Api.Data;
using GestionIntegral.Api.Services; using GestionIntegral.Api.Services;
using GestionIntegral.Api.Services.Contables;
using GestionIntegral.Api.Data.Repositories;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- Registros de Servicios --- // --- Registros de Servicios ---
builder.Services.AddSingleton<DbConnectionFactory>(); builder.Services.AddSingleton<DbConnectionFactory>();
builder.Services.AddScoped<PasswordHasherService>(); 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<IAuthService, AuthService>();
builder.Services.AddScoped<ITipoPagoRepository, TipoPagoRepository>();
builder.Services.AddScoped<ITipoPagoService, TipoPagoService>();
// --- Configuración de Autenticación JWT --- // --- Configuración de Autenticación JWT ---
var jwtSettings = builder.Configuration.GetSection("Jwt"); var jwtSettings = builder.Configuration.GetSection("Jwt");

View File

@@ -37,31 +37,32 @@ namespace GestionIntegral.Api.Services
_logger.LogWarning("Login attempt failed: User {Username} not found.", loginRequest.Username); _logger.LogWarning("Login attempt failed: User {Username} not found.", loginRequest.Username);
return null; return null;
} }
if (!user.Habilitada) if (!user.Habilitada)
{ {
_logger.LogWarning("Login attempt failed: User {Username} is disabled.", loginRequest.Username); _logger.LogWarning("Login attempt failed: User {Username} is disabled.", loginRequest.Username);
return null; return null;
} }
// Verificar contraseña usando el hash y salt de la BD
bool isPasswordValid = _passwordHasher.VerifyPassword(loginRequest.Password, user.ClaveHash, user.ClaveSalt); bool isPasswordValid = _passwordHasher.VerifyPassword(loginRequest.Password, user.ClaveHash, user.ClaveSalt);
if (!isPasswordValid) if (!isPasswordValid)
{ {
_logger.LogWarning("Login attempt failed: Invalid password for user {Username}.", loginRequest.Username); _logger.LogWarning("Login attempt failed: Invalid password for user {Username}.", loginRequest.Username);
return null; return null;
} }
// Generar Token JWT // --- OBTENER PERMISOS ---
var token = GenerateJwtToken(user); 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; bool debeCambiar = user.DebeCambiarClave;
_logger.LogInformation("User {Username} logged in successfully.", loginRequest.Username); _logger.LogInformation("User {Username} logged in successfully.", loginRequest.Username);
// Crear y devolver la respuesta
return new LoginResponseDto return new LoginResponseDto
{ {
Token = token, Token = token,
@@ -70,30 +71,96 @@ namespace GestionIntegral.Api.Services
NombreCompleto = $"{user.Nombre} {user.Apellido}", NombreCompleto = $"{user.Nombre} {user.Apellido}",
EsSuperAdmin = user.SupAdmin, EsSuperAdmin = user.SupAdmin,
DebeCambiarClave = debeCambiar 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 --- // --- GenerateJwtToken sin cambios ---
private string GenerateJwtToken(Usuario user) private string GenerateJwtToken(Usuario user, IEnumerable<string> permisosCodAcc) // Recibir permisos
{ {
var jwtSettings = _configuration.GetSection("Jwt"); var jwtSettings = _configuration.GetSection("Jwt");
var key = Encoding.ASCII.GetBytes(jwtSettings["Key"] var key = Encoding.ASCII.GetBytes(jwtSettings["Key"]
?? throw new ArgumentNullException("Jwt:Key", "JWT Key not configured")); ?? throw new ArgumentNullException("Jwt:Key", "JWT Key not configured"));
var tokenHandler = new JwtSecurityTokenHandler(); var claims = new List<Claim>
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{ {
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Name, user.User), new Claim(JwtRegisteredClaimNames.Name, user.User),
new Claim(JwtRegisteredClaimNames.GivenName, user.Nombre), new Claim(JwtRegisteredClaimNames.GivenName, user.Nombre),
new Claim(JwtRegisteredClaimNames.FamilyName, user.Apellido), new Claim(JwtRegisteredClaimNames.FamilyName, user.Apellido),
new Claim(ClaimTypes.Role, user.SupAdmin ? "SuperAdmin" : $"Perfil_{user.IdPerfil}"), new Claim("idPerfil", user.IdPerfil.ToString()),
new Claim("idPerfil", user.IdPerfil.ToString()), // Añadir IdPerfil como claim explícito new Claim("debeCambiarClave", user.DebeCambiarClave.ToString()),
new Claim("debeCambiarClave", user.DebeCambiarClave.ToString()), // Añadir flag como claim
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().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")), Expires = DateTime.UtcNow.AddHours(Convert.ToInt32(jwtSettings["DurationInHours"] ?? "1")),
Issuer = jwtSettings["Issuer"], Issuer = jwtSettings["Issuer"],
Audience = jwtSettings["Audience"], Audience = jwtSettings["Audience"],
@@ -103,7 +170,5 @@ namespace GestionIntegral.Api.Services
var token = tokenHandler.CreateToken(tokenDescriptor); var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token); return tokenHandler.WriteToken(token);
} }
// TODO: Implementar ChangePasswordAsync
} }
} }

View File

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

View File

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

View File

@@ -5,6 +5,6 @@ namespace GestionIntegral.Api.Services
public interface IAuthService public interface IAuthService
{ {
Task<LoginResponseDto?> LoginAsync(LoginRequestDto loginRequest); Task<LoginResponseDto?> LoginAsync(LoginRequestDto loginRequest);
// Añadiremos cambio de clave, etc. Task<bool> ChangePasswordAsync(int userId, ChangePasswordRequestDto changePasswordRequest);
} }
} }

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("GestionIntegral.Api")] [assembly: System.Reflection.AssemblyCompanyAttribute("GestionIntegral.Api")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [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.AssemblyProductAttribute("GestionIntegral.Api")]
[assembly: System.Reflection.AssemblyTitleAttribute("GestionIntegral.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("GestionIntegral.Api")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,14 @@
"@mui/icons-material": "^7.0.2", "@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2", "@mui/material": "^7.0.2",
"axios": "^1.9.0", "axios": "^1.9.0",
"jwt-decode": "^4.0.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.5.3" "react-router-dom": "^7.5.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@types/jwt-decode": "^2.2.1",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",

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

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

View File

@@ -1,13 +1,41 @@
import React, { createContext, useState, useContext, useEffect } from 'react'; import React, { createContext, useState, useContext, type ReactNode, useEffect } from 'react';
import type { ReactNode } from 'react'; // Importar como tipo
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto'; 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 { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
user: LoginResponseDto | null; user: UserContextData | null; // Usar el tipo extendido
token: string | null; token: string | null;
isLoading: boolean; // Para saber si aún está verificando el token inicial isLoading: boolean;
login: (userData: LoginResponseDto) => void; showForcedPasswordChangeModal: boolean;
isPasswordChangeForced: boolean;
setShowForcedPasswordChangeModal: (show: boolean) => void;
passwordChangeCompleted: () => void;
login: (apiLoginResponse: LoginResponseDto) => void; // Recibe el DTO de la API
logout: () => void; logout: () => void;
} }
@@ -15,37 +43,68 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false); 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 [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 const processTokenAndSetUser = (jwtToken: string) => {
useEffect(() => {
const storedToken = localStorage.getItem('authToken');
const storedUser = localStorage.getItem('authUser'); // Guardamos el usuario también
if (storedToken && storedUser) {
try { try {
// Aquí podrías añadir lógica para validar si el token aún es válido (ej: decodificarlo) const decodedToken = jwtDecode<DecodedJwtPayload>(jwtToken);
// Por ahora, simplemente asumimos que si está, es válido.
const parsedUser: LoginResponseDto = JSON.parse(storedUser); // Verificar expiración (opcional, pero buena práctica aquí también)
setToken(storedToken); const currentTime = Date.now() / 1000;
setUser(parsedUser); 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); 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) { } catch (error) {
console.error("Error parsing stored user data", error); console.error("Error al decodificar o procesar token:", error);
logout(); // Limpia si hay error al parsear 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) => { const login = (apiLoginResponse: LoginResponseDto) => {
localStorage.setItem('authToken', userData.Token); processTokenAndSetUser(apiLoginResponse.token); // Procesar el token recibido
localStorage.setItem('authUser', JSON.stringify(userData)); // Guardar datos de usuario
setToken(userData.Token);
setUser(userData);
setIsAuthenticated(true);
}; };
const logout = () => { const logout = () => {
@@ -54,16 +113,36 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setToken(null); setToken(null);
setUser(null); setUser(null);
setIsAuthenticated(false); 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 ( return (
<AuthContext.Provider value={{ isAuthenticated, user, token, isLoading, login, logout }}> <AuthContext.Provider value={{
isAuthenticated, user, token, isLoading,
showForcedPasswordChangeModal, isPasswordChangeForced,
setShowForcedPasswordChangeModal, passwordChangeCompleted,
login, logout
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
// Hook personalizado para usar el contexto fácilmente
export const useAuth = (): AuthContextType => { export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {

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

View File

@@ -1,46 +1,152 @@
import React from 'react'; import React, { type ReactNode, useState, useEffect } from 'react';
import type { ReactNode } from 'react'; // Importar como tipo import { Box, AppBar, Toolbar, Typography, Button, Tabs, Tab, Paper } from '@mui/material';
import { Box, AppBar, Toolbar, Typography, Button } from '@mui/material';
import { useAuth } from '../contexts/AuthContext'; 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 { 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 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}> <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static"> <AppBar position="static">
<Toolbar> <Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Gestión Integral Sistema de Gestión - El Día
</Typography> </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> <Button color="inherit" onClick={logout}>Cerrar Sesión</Button>
</Toolbar> </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> </AppBar>
{/* Contenido del Módulo (renderizado por <Outlet /> en AppRoutes) */}
<Box <Box
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
p: 3, // Padding p: 3, // Padding
// Puedes añadir color de fondo si lo deseas // overflowY: 'auto' // Si el contenido del módulo es muy largo
// backgroundColor: (theme) => theme.palette.background.default,
}} }}
> >
{/* El contenido de la página actual se renderizará aquí */}
{children} {children}
</Box> </Box>
{/* Aquí podría ir un Footer o StatusStrip */}
<Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}> <Box component="footer" sx={{ p: 1, mt: 'auto', backgroundColor: 'primary.dark', color: 'white', textAlign: 'center' }}>
<Typography variant="body2"> <Typography variant="body2">
{/* Replicar info del StatusStrip original */} Usuario: {user?.username} | Acceso: {user?.esSuperAdmin ? 'Super Admin' : `Perfil ID ${user?.userId}`} | Versión: {/* TODO: Obtener versión */}
Usuario: {user?.Username} | Acceso: {user?.EsSuperAdmin ? 'Super Admin' : 'Perfil...'} | Versión: {/** Obtener versión **/}
</Typography> </Typography>
</Box> </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> </Box>
); );
}; };

View File

@@ -0,0 +1,5 @@
export interface TipoPago {
idTipoPago: number;
nombre: string;
detalle?: string; // El detalle es opcional
}

View File

@@ -0,0 +1,6 @@
// src/models/dtos/ChangePasswordRequestDto.ts
export interface ChangePasswordRequestDto {
currentPassword: string;
newPassword: string;
confirmNewPassword: string;
}

View File

@@ -1,10 +1,10 @@
// src/models/dtos/LoginResponseDto.ts // src/models/dtos/LoginResponseDto.ts
export interface LoginResponseDto { export interface LoginResponseDto {
Token: string; token: string;
UserId: number; userId: number;
Username: string; username: string;
NombreCompleto: string; nombreCompleto: string;
EsSuperAdmin: boolean; esSuperAdmin: boolean;
DebeCambiarClave: boolean; debeCambiarClave: boolean;
// Añade otros campos si los definiste en el DTO C# // Añade otros campos si los definiste en el DTO C#
} }

View File

@@ -0,0 +1,4 @@
export interface CreateTipoPagoDto {
nombre: string;
detalle?: string;
}

View File

@@ -0,0 +1,4 @@
export interface UpdateTipoPagoDto {
nombre: string;
detalle?: string;
}

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

View File

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

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

View File

@@ -1,13 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import axios from 'axios'; // Importar axios import axios from 'axios'; // Importar axios
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import apiClient from '../services/apiClient'; // Nuestro cliente axios
import type { LoginRequestDto } from '../models/dtos/LoginRequestDto'; // Usar type import type { LoginRequestDto } from '../models/dtos/LoginRequestDto'; // Usar type
import type { LoginResponseDto } from '../models/dtos/LoginResponseDto'; // Usar type
// Importaciones de Material UI // Importaciones de Material UI
import { Container, TextField, Button, Typography, Box, Alert } from '@mui/material'; import { Container, TextField, Button, Typography, Box, Alert } from '@mui/material';
import authService from '../services/authService';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -15,7 +13,6 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -25,25 +22,17 @@ const LoginPage: React.FC = () => {
const loginData: LoginRequestDto = { Username: username, Password: password }; const loginData: LoginRequestDto = { Username: username, Password: password };
try { try {
const response = await apiClient.post<LoginResponseDto>('/auth/login', loginData); const response = await authService.login(loginData);
login(response.data); // Guardar token y estado de usuario en el contexto login(response);
// 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
// }
} catch (err: any) { } catch (err: any) {
console.error("Login error:", err); console.error("Login error:", err);
if (axios.isAxiosError(err) && err.response) { 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.'); setError(err.response.data?.message || 'Error al iniciar sesión. Verifique sus credenciales.');
} else { } else {
setError('Ocurrió un error inesperado.'); setError('Ocurrió un error inesperado.');
} }
// Importante: NO llamar a navigate('/') aquí en el catch,
// porque el estado isAuthenticated no habrá cambiado a true
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -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 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 { useAuth } from '../contexts/AuthContext';
import ChangePasswordPage from '../pages/ChangePasswordPage'; // Crearemos esta import MainLayout from '../layouts/MainLayout';
import MainLayout from '../layouts/MainLayout'; // Crearemos este 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 ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
// console.log("ProtectedRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
if (isLoading) { if (isLoading) return null;
// Muestra algo mientras verifica el token (ej: un spinner) if (!isAuthenticated) {
return <div>Cargando...</div>; // console.log("ProtectedRoute: Not authenticated, redirecting to /login");
return <Navigate to="/login" replace />;
} }
// console.log("ProtectedRoute: Authenticated, rendering children");
return isAuthenticated ? children : <Navigate to="/login" replace />; return children;
}; };
// Componente para rutas públicas (redirige si ya está logueado)
const PublicRoute: React.FC<{ children: JSX.Element }> = ({ children }) => { const PublicRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
// console.log("PublicRoute Check:", { path: window.location.pathname, isAuthenticated, isLoading });
if (isLoading) { if (isLoading) return null;
return <div>Cargando...</div>; if (isAuthenticated) {
// console.log("PublicRoute: Authenticated, redirecting to /");
return <Navigate to="/" replace />;
} }
// console.log("PublicRoute: Not authenticated, rendering children");
return !isAuthenticated ? children : <Navigate to="/" replace />; 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 = () => { const AppRoutes = () => {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes> {/* Un solo <Routes> de nivel superior */}
{/* Rutas Públicas */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} /> <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 <Route
path="/*" // Captura todas las demás rutas path="/" // La ruta padre para todas las secciones protegidas
element={ element={
<ProtectedRoute> <ProtectedRoute>
<MainLayout> {/* Layout que tendrá la navegación principal */} <MainLayoutWrapper/>
{/* 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>
</ProtectedRoute> </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> </Routes>
</BrowserRouter> </BrowserRouter>
); );
}; };
export default AppRoutes; export default AppRoutes;

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

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