feat: Implementación CRUD Canillitas, Distribuidores y Precios de Publicación
Backend API:
- Canillitas (`dist_dtCanillas`):
- Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Lógica para manejo de `Accionista`, `Baja`, `FechaBaja`.
- Auditoría en `dist_dtCanillas_H`.
- Validación de legajo único y lógica de empresa vs accionista.
- Distribuidores (`dist_dtDistribuidores`):
- Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Auditoría en `dist_dtDistribuidores_H`.
- Creación de saldos iniciales para el nuevo distribuidor en todas las empresas.
- Verificación de NroDoc único y Nombre opcionalmente único.
- Precios de Publicación (`dist_Precios`):
- Implementado CRUD básico (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Endpoints anidados bajo `/publicaciones/{idPublicacion}/precios`.
- Lógica de negocio para cerrar período de precio anterior al crear uno nuevo.
- Lógica de negocio para reabrir período de precio anterior al eliminar el último.
- Auditoría en `dist_Precios_H`.
- Auditoría en Eliminación de Publicaciones:
- Extendido `PublicacionService.EliminarAsync` para eliminar en cascada registros de precios, recargos, porcentajes de pago (distribuidores y canillitas) y secciones de publicación.
- Repositorios correspondientes (`PrecioRepository`, `RecargoZonaRepository`, `PorcPagoRepository`, `PorcMonCanillaRepository`, `PubliSeccionRepository`) actualizados con métodos `DeleteByPublicacionIdAsync` que registran en sus respectivas tablas `_H` (si existen y se implementó la lógica).
- Asegurada la correcta propagación del `idUsuario` para la auditoría en cascada.
- Correcciones de Nulabilidad:
- Ajustados los métodos `MapToDto` y su uso en `CanillaService` y `PublicacionService` para manejar correctamente tipos anulables.
Frontend React:
- Canillitas:
- `canillaService.ts`.
- `CanillaFormModal.tsx` con selectores para Zona y Empresa, y lógica de Accionista.
- `GestionarCanillitasPage.tsx` con filtros, paginación, y acciones (editar, toggle baja).
- Distribuidores:
- `distribuidorService.ts`.
- `DistribuidorFormModal.tsx` con múltiples campos y selector de Zona.
- `GestionarDistribuidoresPage.tsx` con filtros, paginación, y acciones (editar, eliminar).
- Precios de Publicación:
- `precioService.ts`.
- `PrecioFormModal.tsx` para crear/editar períodos de precios (VigenciaD, VigenciaH opcional, precios por día).
- `GestionarPreciosPublicacionPage.tsx` accesible desde la gestión de publicaciones, para listar y gestionar los períodos de precios de una publicación específica.
- Layout:
- Reemplazado el uso de `Grid` por `Box` con Flexbox en `CanillaFormModal`, `GestionarCanillitasPage` (filtros), `DistribuidorFormModal` y `PrecioFormModal` para resolver problemas de tipos y mejorar la consistencia del layout de formularios.
- Navegación:
- Actualizadas las rutas y pestañas para los nuevos módulos y sub-módulos.
2025-05-20 12:38:55 -03:00
using Dapper ;
using GestionIntegral.Api.Models.Usuarios ;
using Microsoft.Extensions.Logging ;
using System.Collections.Generic ;
using System.Data ;
using System.Linq ;
using System.Text ;
using System.Threading.Tasks ;
namespace GestionIntegral.Api.Data.Repositories.Usuarios
{
public class PerfilRepository : IPerfilRepository
{
private readonly DbConnectionFactory _connectionFactory ;
private readonly ILogger < PerfilRepository > _logger ;
public PerfilRepository ( DbConnectionFactory connectionFactory , ILogger < PerfilRepository > logger )
{
_connectionFactory = connectionFactory ;
_logger = logger ;
}
public async Task < IEnumerable < Perfil > > GetAllAsync ( string? nombreFilter )
{
var sqlBuilder = new StringBuilder ( "SELECT id AS Id, perfil AS NombrePerfil, descPerfil AS Descripcion FROM dbo.gral_Perfiles WHERE 1=1" ) ;
var parameters = new DynamicParameters ( ) ;
if ( ! string . IsNullOrWhiteSpace ( nombreFilter ) )
{
sqlBuilder . Append ( " AND perfil LIKE @NombreFilter" ) ;
parameters . Add ( "NombreFilter" , $"%{nombreFilter}%" ) ;
}
sqlBuilder . Append ( " ORDER BY perfil;" ) ;
try
{
using var connection = _connectionFactory . CreateConnection ( ) ;
return await connection . QueryAsync < Perfil > ( sqlBuilder . ToString ( ) , parameters ) ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error al obtener todos los Perfiles. Filtro: {NombreFilter}" , nombreFilter ) ;
return Enumerable . Empty < Perfil > ( ) ;
}
}
public async Task < Perfil ? > GetByIdAsync ( int id )
{
const string sql = "SELECT id AS Id, perfil AS NombrePerfil, descPerfil AS Descripcion FROM dbo.gral_Perfiles WHERE id = @Id" ;
try
{
using var connection = _connectionFactory . CreateConnection ( ) ;
return await connection . QuerySingleOrDefaultAsync < Perfil > ( sql , new { Id = id } ) ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error al obtener Perfil por ID: {IdPerfil}" , id ) ;
return null ;
}
}
public async Task < bool > ExistsByNameAsync ( string nombrePerfil , int? excludeId = null )
{
var sqlBuilder = new StringBuilder ( "SELECT COUNT(1) FROM dbo.gral_Perfiles WHERE perfil = @NombrePerfil" ) ;
var parameters = new DynamicParameters ( ) ;
parameters . Add ( "NombrePerfil" , nombrePerfil ) ;
if ( excludeId . HasValue )
{
sqlBuilder . Append ( " AND id != @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 Perfil con nombre: {NombrePerfil}" , nombrePerfil ) ;
return true ;
}
}
public async Task < bool > IsInUseAsync ( int id )
{
const string sqlCheckUsuarios = "SELECT TOP 1 1 FROM dbo.gral_Usuarios WHERE IdPerfil = @IdPerfil" ;
const string sqlCheckPermisos = "SELECT TOP 1 1 FROM dbo.gral_PermisosPerfiles WHERE idPerfil = @IdPerfil" ;
try
{
using var connection = _connectionFactory . CreateConnection ( ) ;
var inUsuarios = await connection . ExecuteScalarAsync < int? > ( sqlCheckUsuarios , new { IdPerfil = id } ) ;
if ( inUsuarios . HasValue & & inUsuarios . Value = = 1 ) return true ;
var inPermisos = await connection . ExecuteScalarAsync < int? > ( sqlCheckPermisos , new { IdPerfil = id } ) ;
return inPermisos . HasValue & & inPermisos . Value = = 1 ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error en IsInUseAsync para Perfil ID: {IdPerfil}" , id ) ;
return true ;
}
}
public async Task < Perfil ? > CreateAsync ( Perfil nuevoPerfil , int idUsuario , IDbTransaction transaction )
{
const string sqlInsert = @ "
INSERT INTO dbo . gral_Perfiles ( perfil , descPerfil )
OUTPUT INSERTED . id AS Id , INSERTED . perfil AS NombrePerfil , INSERTED . descPerfil AS Descripcion
VALUES ( @NombrePerfil , @Descripcion ) ; ";
const string sqlInsertHistorico = @ "
INSERT INTO dbo . gral_Perfiles_H ( idPerfil , perfil , descPerfil , Id_Usuario , FechaMod , TipoMod )
VALUES ( @IdPerfilHist , @NombrePerfilHist , @DescripcionHist , @IdUsuarioHist , @FechaModHist , @TipoModHist ) ; ";
var connection = transaction . Connection ! ; // La conexión debe venir de la transacción
var insertedPerfil = await connection . QuerySingleAsync < Perfil > (
sqlInsert ,
new { nuevoPerfil . NombrePerfil , nuevoPerfil . Descripcion } ,
transaction : transaction ) ;
if ( insertedPerfil = = null | | insertedPerfil . Id < = 0 )
{
throw new DataException ( "No se pudo obtener el ID del perfil insertado." ) ;
}
await connection . ExecuteAsync ( sqlInsertHistorico , new
{
IdPerfilHist = insertedPerfil . Id , // Correcto
NombrePerfilHist = insertedPerfil . NombrePerfil ,
DescripcionHist = insertedPerfil . Descripcion ,
IdUsuarioHist = idUsuario ,
FechaModHist = DateTime . Now ,
TipoModHist = "Insertada"
} , transaction : transaction ) ;
return insertedPerfil ;
}
public async Task < bool > UpdateAsync ( Perfil perfilAActualizar , int idUsuario , IDbTransaction transaction )
{
var connection = transaction . Connection ! ;
// Obtener el estado actual PARA EL HISTORIAL DENTRO DE LA MISMA TRANSACCIÓN
var perfilActual = await connection . QuerySingleOrDefaultAsync < Perfil > (
"SELECT id AS Id, perfil AS NombrePerfil, descPerfil AS Descripcion FROM dbo.gral_Perfiles WHERE id = @Id" ,
new { Id = perfilAActualizar . Id } ,
transaction ) ;
if ( perfilActual = = null )
{
// Esto no debería pasar si el servicio verifica la existencia antes, pero es una salvaguarda.
throw new KeyNotFoundException ( $"Perfil con ID {perfilAActualizar.Id} no encontrado para actualizar." ) ;
}
const string sqlUpdate = "UPDATE dbo.gral_Perfiles SET perfil = @NombrePerfil, descPerfil = @Descripcion WHERE id = @Id;" ;
const string sqlInsertHistorico = @ "
INSERT INTO dbo . gral_Perfiles_H ( idPerfil , perfil , descPerfil , Id_Usuario , FechaMod , TipoMod )
VALUES ( @IdPerfilHist , @NombrePerfilHist , @DescripcionHist , @IdUsuarioHist , @FechaModHist , @TipoModHist ) ; ";
// Insertar en historial con los valores ANTES de la modificación
await connection . ExecuteAsync ( sqlInsertHistorico , new
{
IdPerfilHist = perfilActual . Id ,
NombrePerfilHist = perfilActual . NombrePerfil ,
DescripcionHist = perfilActual . Descripcion ,
IdUsuarioHist = idUsuario ,
FechaModHist = DateTime . Now ,
TipoModHist = "Modificada"
} , transaction : transaction ) ;
// Actualizar la tabla principal
var rowsAffected = await connection . ExecuteAsync ( sqlUpdate , perfilAActualizar , transaction : transaction ) ;
return rowsAffected = = 1 ;
}
public async Task < bool > DeleteAsync ( int id , int idUsuario , IDbTransaction transaction )
{
var connection = transaction . Connection ! ;
// Obtener el estado actual PARA EL HISTORIAL DENTRO DE LA MISMA TRANSACCIÓN
var perfilActual = await connection . QuerySingleOrDefaultAsync < Perfil > (
"SELECT id AS Id, perfil AS NombrePerfil, descPerfil AS Descripcion FROM dbo.gral_Perfiles WHERE id = @Id" ,
new { Id = id } ,
transaction ) ;
if ( perfilActual = = null )
{
throw new KeyNotFoundException ( $"Perfil con ID {id} no encontrado para eliminar." ) ;
}
const string sqlDelete = "DELETE FROM dbo.gral_Perfiles WHERE id = @Id" ;
const string sqlInsertHistorico = @ "
INSERT INTO dbo . gral_Perfiles_H ( idPerfil , perfil , descPerfil , Id_Usuario , FechaMod , TipoMod )
VALUES ( @IdPerfilHist , @NombrePerfilHist , @DescripcionHist , @IdUsuarioHist , @FechaModHist , @TipoModHist ) ; ";
// Insertar en historial con los valores ANTES de la eliminación
await connection . ExecuteAsync ( sqlInsertHistorico , new
{
IdPerfilHist = perfilActual . Id ,
NombrePerfilHist = perfilActual . NombrePerfil ,
DescripcionHist = perfilActual . Descripcion ,
IdUsuarioHist = idUsuario ,
FechaModHist = DateTime . Now ,
TipoModHist = "Eliminada"
} , transaction : transaction ) ;
// Eliminar de la tabla principal
var rowsAffected = await connection . ExecuteAsync ( sqlDelete , new { Id = id } , transaction : transaction ) ;
return rowsAffected = = 1 ;
}
public async Task < IEnumerable < int > > GetPermisoIdsByPerfilIdAsync ( int idPerfil )
{
const string sql = "SELECT idPermiso FROM dbo.gral_PermisosPerfiles WHERE idPerfil = @IdPerfil" ;
try
{
using var connection = _connectionFactory . CreateConnection ( ) ;
return await connection . QueryAsync < int > ( sql , new { IdPerfil = idPerfil } ) ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error al obtener IDs de permisos para el Perfil ID: {IdPerfil}" , idPerfil ) ;
return Enumerable . Empty < int > ( ) ;
}
}
public async Task UpdatePermisosByPerfilIdAsync ( int idPerfil , IEnumerable < int > nuevosPermisosIds , IDbTransaction transaction )
{
var connection = transaction . Connection ! ;
// 1. Eliminar todos los permisos existentes para este perfil (dentro de la transacción)
const string sqlDelete = "DELETE FROM dbo.gral_PermisosPerfiles WHERE idPerfil = @IdPerfil" ;
await connection . ExecuteAsync ( sqlDelete , new { IdPerfil = idPerfil } , transaction : transaction ) ;
// 2. Insertar los nuevos permisos (si hay alguno)
if ( nuevosPermisosIds ! = null & & nuevosPermisosIds . Any ( ) )
{
const string sqlInsert = "INSERT INTO dbo.gral_PermisosPerfiles (idPerfil, idPermiso) VALUES (@IdPerfil, @IdPermiso)" ;
// Dapper puede manejar una lista de objetos para inserciones múltiples
var permisosParaInsertar = nuevosPermisosIds . Select ( idPermiso = > new { IdPerfil = idPerfil , IdPermiso = idPermiso } ) ;
await connection . ExecuteAsync ( sqlInsert , permisosParaInsertar , transaction : transaction ) ;
}
}
2025-06-12 19:36:21 -03:00
public async Task LogPermisoAsignacionHistorialAsync ( int idPerfil , int idPermiso , int idUsuario , string tipoMod , IDbTransaction transaction )
{
const string sql = @ "
INSERT INTO dbo . gral_PermisosPerfiles_H ( idPerfil , idPermiso , Id_Usuario , FechaMod , TipoMod )
VALUES ( @IdPerfil , @IdPermiso , @IdUsuario , @FechaMod , @TipoMod ) ; ";
await transaction . Connection ! . ExecuteAsync ( sql , new
{
IdPerfil = idPerfil ,
IdPermiso = idPermiso ,
IdUsuario = idUsuario ,
FechaMod = DateTime . Now ,
TipoMod = tipoMod
} , transaction : transaction ) ;
}
public async Task < IEnumerable < ( PerfilHistorico Historial , string NombreUsuarioModifico ) > > GetHistorialAsync (
DateTime ? fechaDesde , DateTime ? fechaHasta ,
int? idUsuarioModifico , string? tipoModificacion ,
int? idPerfilOriginal )
{
using var connection = _connectionFactory . CreateConnection ( ) ;
var sqlBuilder = new StringBuilder ( @ "
SELECT
h . idPerfil , h . perfil , h . descPerfil , - - Campos de gral_Perfiles_H
h . Id_Usuario , h . FechaMod , h . TipoMod ,
u . Nombre + ' ' + u . Apellido AS NombreUsuarioModifico
FROM dbo . gral_Perfiles_H h
JOIN dbo . gral_Usuarios u ON h . Id_Usuario = u . Id
WHERE 1 = 1 ");
var parameters = new DynamicParameters ( ) ;
if ( fechaDesde . HasValue ) { sqlBuilder . Append ( " AND h.FechaMod >= @FechaDesdeParam" ) ; parameters . Add ( "FechaDesdeParam" , fechaDesde . Value . Date ) ; }
if ( fechaHasta . HasValue ) { sqlBuilder . Append ( " AND h.FechaMod <= @FechaHastaParam" ) ; parameters . Add ( "FechaHastaParam" , fechaHasta . Value . Date . AddDays ( 1 ) . AddTicks ( - 1 ) ) ; }
if ( idUsuarioModifico . HasValue ) { sqlBuilder . Append ( " AND h.Id_Usuario = @IdUsuarioModificoParam" ) ; parameters . Add ( "IdUsuarioModificoParam" , idUsuarioModifico . Value ) ; }
if ( ! string . IsNullOrWhiteSpace ( tipoModificacion ) ) { sqlBuilder . Append ( " AND h.TipoMod = @TipoModParam" ) ; parameters . Add ( "TipoModParam" , tipoModificacion ) ; }
if ( idPerfilOriginal . HasValue ) { sqlBuilder . Append ( " AND h.idPerfil = @IdPerfilOriginalParam" ) ; parameters . Add ( "IdPerfilOriginalParam" , idPerfilOriginal . Value ) ; }
sqlBuilder . Append ( " ORDER BY h.FechaMod DESC;" ) ;
try
{
var result = await connection . QueryAsync < PerfilHistorico , string , ( PerfilHistorico , string ) > (
sqlBuilder . ToString ( ) ,
( hist , userName ) = > ( hist , userName ) ,
parameters ,
splitOn : "NombreUsuarioModifico"
) ;
return result ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error al obtener historial de Perfiles." ) ;
return Enumerable . Empty < ( PerfilHistorico , string ) > ( ) ;
}
}
public async Task < IEnumerable < ( PermisosPerfilesHistorico Historial , string NombreUsuarioModifico , string NombrePerfil , string DescPermiso , string CodAccPermiso ) > > GetPermisosAsignadosHistorialAsync (
DateTime ? fechaDesde , DateTime ? fechaHasta ,
int? idUsuarioModifico , string? tipoModificacion ,
int? idPerfilAfectado , int? idPermisoAfectado )
{
using var connection = _connectionFactory . CreateConnection ( ) ;
var sqlBuilder = new StringBuilder ( @ "
SELECT
h . IdHist , h . idPerfil , h . idPermiso ,
h . Id_Usuario , h . FechaMod , h . TipoMod ,
u . Nombre + ' ' + u . Apellido AS NombreUsuarioModifico ,
pf . perfil AS NombrePerfil ,
p . descPermiso AS DescPermiso ,
p . codAcc AS CodAccPermiso
FROM dbo . gral_PermisosPerfiles_H h
JOIN dbo . gral_Usuarios u ON h . Id_Usuario = u . Id
JOIN dbo . gral_Perfiles pf ON h . idPerfil = pf . id
JOIN dbo . gral_Permisos p ON h . idPermiso = p . id
WHERE 1 = 1 ");
var parameters = new DynamicParameters ( ) ;
if ( fechaDesde . HasValue ) { sqlBuilder . Append ( " AND h.FechaMod >= @FechaDesdeParam" ) ; parameters . Add ( "FechaDesdeParam" , fechaDesde . Value . Date ) ; }
if ( fechaHasta . HasValue ) { sqlBuilder . Append ( " AND h.FechaMod <= @FechaHastaParam" ) ; parameters . Add ( "FechaHastaParam" , fechaHasta . Value . Date . AddDays ( 1 ) . AddTicks ( - 1 ) ) ; }
if ( idUsuarioModifico . HasValue ) { sqlBuilder . Append ( " AND h.Id_Usuario = @IdUsuarioModificoParam" ) ; parameters . Add ( "IdUsuarioModificoParam" , idUsuarioModifico . Value ) ; }
if ( ! string . IsNullOrWhiteSpace ( tipoModificacion ) ) { sqlBuilder . Append ( " AND h.TipoMod = @TipoModParam" ) ; parameters . Add ( "TipoModParam" , tipoModificacion ) ; }
if ( idPerfilAfectado . HasValue ) { sqlBuilder . Append ( " AND h.idPerfil = @IdPerfilAfectadoParam" ) ; parameters . Add ( "IdPerfilAfectadoParam" , idPerfilAfectado . Value ) ; }
if ( idPermisoAfectado . HasValue ) { sqlBuilder . Append ( " AND h.idPermiso = @IdPermisoAfectadoParam" ) ; parameters . Add ( "IdPermisoAfectadoParam" , idPermisoAfectado . Value ) ; }
sqlBuilder . Append ( " ORDER BY h.FechaMod DESC;" ) ;
try
{
var result = await connection . QueryAsync < PermisosPerfilesHistorico , string , string , string , string , ( PermisosPerfilesHistorico , string , string , string , string ) > (
sqlBuilder . ToString ( ) ,
( hist , userMod , perf , desc , cod ) = > ( hist , userMod , perf , desc , cod ) ,
parameters ,
splitOn : "NombreUsuarioModifico,NombrePerfil,DescPermiso,CodAccPermiso"
) ;
return result ;
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error al obtener historial de Asignación de Permisos." ) ;
return Enumerable . Empty < ( PermisosPerfilesHistorico , string , string , string , string ) > ( ) ;
}
}
}
feat: Implementación CRUD Canillitas, Distribuidores y Precios de Publicación
Backend API:
- Canillitas (`dist_dtCanillas`):
- Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Lógica para manejo de `Accionista`, `Baja`, `FechaBaja`.
- Auditoría en `dist_dtCanillas_H`.
- Validación de legajo único y lógica de empresa vs accionista.
- Distribuidores (`dist_dtDistribuidores`):
- Implementado CRUD completo (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Auditoría en `dist_dtDistribuidores_H`.
- Creación de saldos iniciales para el nuevo distribuidor en todas las empresas.
- Verificación de NroDoc único y Nombre opcionalmente único.
- Precios de Publicación (`dist_Precios`):
- Implementado CRUD básico (Modelos, DTOs, Repositorio, Servicio, Controlador).
- Endpoints anidados bajo `/publicaciones/{idPublicacion}/precios`.
- Lógica de negocio para cerrar período de precio anterior al crear uno nuevo.
- Lógica de negocio para reabrir período de precio anterior al eliminar el último.
- Auditoría en `dist_Precios_H`.
- Auditoría en Eliminación de Publicaciones:
- Extendido `PublicacionService.EliminarAsync` para eliminar en cascada registros de precios, recargos, porcentajes de pago (distribuidores y canillitas) y secciones de publicación.
- Repositorios correspondientes (`PrecioRepository`, `RecargoZonaRepository`, `PorcPagoRepository`, `PorcMonCanillaRepository`, `PubliSeccionRepository`) actualizados con métodos `DeleteByPublicacionIdAsync` que registran en sus respectivas tablas `_H` (si existen y se implementó la lógica).
- Asegurada la correcta propagación del `idUsuario` para la auditoría en cascada.
- Correcciones de Nulabilidad:
- Ajustados los métodos `MapToDto` y su uso en `CanillaService` y `PublicacionService` para manejar correctamente tipos anulables.
Frontend React:
- Canillitas:
- `canillaService.ts`.
- `CanillaFormModal.tsx` con selectores para Zona y Empresa, y lógica de Accionista.
- `GestionarCanillitasPage.tsx` con filtros, paginación, y acciones (editar, toggle baja).
- Distribuidores:
- `distribuidorService.ts`.
- `DistribuidorFormModal.tsx` con múltiples campos y selector de Zona.
- `GestionarDistribuidoresPage.tsx` con filtros, paginación, y acciones (editar, eliminar).
- Precios de Publicación:
- `precioService.ts`.
- `PrecioFormModal.tsx` para crear/editar períodos de precios (VigenciaD, VigenciaH opcional, precios por día).
- `GestionarPreciosPublicacionPage.tsx` accesible desde la gestión de publicaciones, para listar y gestionar los períodos de precios de una publicación específica.
- Layout:
- Reemplazado el uso de `Grid` por `Box` con Flexbox en `CanillaFormModal`, `GestionarCanillitasPage` (filtros), `DistribuidorFormModal` y `PrecioFormModal` para resolver problemas de tipos y mejorar la consistencia del layout de formularios.
- Navegación:
- Actualizadas las rutas y pestañas para los nuevos módulos y sub-módulos.
2025-05-20 12:38:55 -03:00
}