Compare commits

..

10 Commits

Author SHA1 Message Date
91d353655d Merge pull request '#15 ADM-001: Medios y Secciones (fundacional)'
ADM-001 entrega el catálogo fundacional Medio + Seccion con Temporal Tables, auditoría y frontend CMS (9 commits, 831 tests / 0 fails). Desbloquea 20+ UDTs downstream.
2026-04-17 14:37:15 +00:00
740298a9e1 fix(web): reemplazar <select> nativos por shadcn Select (dark mode compat) — ADM-001
Reemplaza 13 <select>/<option> nativos en 8 archivos por el componente
shadcn Select (Radix UI). Los selects nativos ignoraban los tokens del
design system en dark mode, causando texto invisible. Se agrega mock de
pointer capture APIs en test setup para compatibilidad de Radix con jsdom.
2026-04-17 10:13:20 -03:00
6b946f6080 feat(web): Medios + Secciones admin UI + hooks + routing — ADM-001 B7+B8
- API clients + TanStack Query hooks for medios and secciones
- CRUD pages: List, Create, Edit, Detail for both entities
- Components: MediosTable, MedioForm, DeactivateMedioModal,
  SeccionesTable, SeccionForm, DeactivateSeccionModal, SeccionesFilters
- TipoMedio enum (int→label) and TipoSeccion display helpers
- CanPerform permission gates: administracion:medios:gestionar,
  administracion:secciones:gestionar
- Routes /admin/medios/** and /admin/secciones/** in router.tsx
- Sidebar items (Newspaper + Columns3 icons) with requiredPermission
- Vitest+RTL+MSW tests: 11 test files, 38 new cases — 207 pass total
2026-04-16 19:28:30 -03:00
13480ad8c2 feat(api): MediosController + SeccionesController + ExceptionFilter mappings — ADM-001 B6
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/medios
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/secciones
- ExceptionFilter: add Medio/Seccion 404+409 mappings after RolInUseException
- Integration tests: 19 scenarios covering 401/403/201/404/409/idempotency/AuditEvent
- All 166 Api.Tests + 458 Application.Tests passing
2026-04-16 19:16:33 -03:00
a6f4011806 fix(tests): resolve ADM-001 regressions in Api.Tests fixture
- Update hardcoded permiso count from 21 → 22 in AuthControllerTests and
  PermisosEndpointTests after V011 added 'administracion:secciones:gestionar'
- The TestSupport SqlTestFixture already had Medio_History/Seccion_History in
  TablesToIgnore; tests were failing due to stale binaries (needed rebuild)
2026-04-16 19:08:32 -03:00
2f0da2d720 feat(infra): MedioRepository + SeccionRepository + integration tests — ADM-001 B5 2026-04-16 19:04:09 -03:00
a1a8e6e0cb fix(tests): realign test expectations with V011 (ADM-001) seed — 22 permisos + Medios fixture 2026-04-16 19:04:06 -03:00
f672de78ce feat(medios,secciones): application layer + handlers TDD — ADM-001 B3+B4
- IMedioRepository, ISeccionRepository interfaces
- MediosQuery, SeccionesQuery common records
- TipoSeccion static AllowedTipos helper
- Medios: 6 use cases (Create/Update/Deactivate/Reactivate/List/GetById) with validators, handlers and DTOs
- Secciones: 6 use cases mirroring Medios; Create validates MedioId active via IMedioRepository
- 52 unit tests (xUnit + NSubstitute) all green; audit LogAsync asserted per mutating handler
- DI registrations for all 12 handlers and validators auto-scanned via AddValidatorsFromAssemblyContaining
2026-04-16 18:53:57 -03:00
bb98dbf217 feat(domain): Medio + Seccion entities + 4 exceptions — ADM-001 B2
Entities sealed immutable con factory ForCreation + copy-with methods
WithUpdatedProfile/WithActivo (Codigo inmutable en Medio; MedioId y
Codigo inmutables en Seccion — enforzado en Validators en B4).

Exceptions: MedioCodigoDuplicado (UQ global), SeccionCodigoDuplicadoEnMedio
(UQ compuesto), MedioNotFound, SeccionNotFound. Todas heredan de
DomainException.
2026-04-16 18:45:46 -03:00
ff7d8986fd feat(db): Medio + Seccion (temporal tables + seed) — ADM-001 B1
V011 crea dbo.Medio y dbo.Seccion con SYSTEM_VERSIONING ON (retention 10
anios) y PAGE compression en history; siembra el permiso
'administracion:secciones:gestionar' y lo asigna a rol admin. El permiso
'administracion:medios:gestionar' ya existia desde V005.

V012 siembra Medios fundacionales ELDIA y ELPLATA (MERGE idempotente).

Rollbacks V011/V012 validados estructuralmente; aplicacion y
reaplicacion verificadas en SIGCM2_Test y SIGCM2. Fixture de tests
actualizado: EnsureV011SchemaAsync, SeedMediosCanonicalAsync, ignora
Medio_History y Seccion_History en Respawner.
2026-04-16 18:13:54 -03:00
149 changed files with 8079 additions and 81 deletions

View File

@@ -27,6 +27,8 @@ database/
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
## Convenciones
@@ -78,6 +80,16 @@ O desde SSMS: abrir el archivo, conectar a cada base, F5.
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
### V011/V012 — ADM-001 Medios y Secciones
**Alcance**: crea `dbo.Medio` y `dbo.Seccion` con Temporal Tables (retention 10 años), el permiso `administracion:secciones:gestionar` (y lo asigna a rol `admin`), y siembra los dos Medios fundacionales `ELDIA` y `ELPLATA`.
**Notas**:
- `administracion:medios:gestionar` ya existía desde V005 — no se toca.
- `PlataformaEmpresaId` es `INT NULL` sin FK; la FK se agrega en INT-003 cuando se cree la tabla `PlataformaEmpresa`.
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`

View File

@@ -0,0 +1,118 @@
-- V011_ROLLBACK.sql
-- Reversa de V011__create_medio_seccion.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal,
-- el permiso 'administracion:secciones:gestionar' y sus asignaciones.
-- ('administracion:medios:gestionar' NO se toca — es pre-existente de V005.)
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario).
-- Si ADM-008, ADM-009 o PRC-* ya están aplicados, este rollback falla — usar backup.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio
-- ═══════════════════════════════════════════════════════════════════════
-- Seccion primero (FK al Medio)
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF);
PRINT 'Seccion: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion'))
BEGIN
ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom;
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo;
ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo;
PRINT 'Seccion: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Seccion_History;
PRINT 'Seccion_History dropped.';
END
GO
-- Medio
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF);
PRINT 'Medio: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio'))
BEGIN
ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom;
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo;
ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo;
PRINT 'Medio: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Medio_History;
PRINT 'Medio_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop Seccion y Medio (Seccion primero por FK)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Seccion;
PRINT 'Table dbo.Seccion dropped.';
END
GO
IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Medio;
PRINT 'Table dbo.Medio dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'administracion:secciones:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'administracion:secciones:gestionar';
GO
PRINT '';
PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.';
PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).';
GO

View File

@@ -0,0 +1,206 @@
-- V011__create_medio_seccion.sql
-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones — catálogo fundacional.
--
-- Cambios:
-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON).
-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON).
-- 3. Permiso 'administracion:secciones:gestionar' + asignación a rol 'admin'.
-- El permiso 'administracion:medios:gestionar' ya existía desde V005.
--
-- Patrón: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 años + PAGE compression en history).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V011_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 📋 UDTs Módulo Administración.md (ADM-001)
-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏢 Medio.md
-- Auditoría: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Medio
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Medio (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY,
Codigo VARCHAR(30) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster
PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping)
Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo),
CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4)
);
PRINT 'Table dbo.Medio created.';
END
ELSE
PRINT 'Table dbo.Medio already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio'))
BEGIN
CREATE INDEX IX_Medio_Activo_Tipo
ON dbo.Medio(Activo, Tipo)
INCLUDE (Codigo, Nombre, PlataformaEmpresaId);
PRINT 'Index IX_Medio_Activo_Tipo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. dbo.Seccion
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Seccion (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY,
MedioId INT NOT NULL,
Codigo VARCHAR(30) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos'
Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo),
CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos'))
);
PRINT 'Table dbo.Seccion created.';
END
ELSE
PRINT 'Table dbo.Seccion already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion'))
BEGIN
CREATE INDEX IX_Seccion_MedioId_Activo
ON dbo.Seccion(MedioId, Activo)
INCLUDE (Codigo, Nombre, Tipo);
PRINT 'Index IX_Seccion_MedioId_Activo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. SYSTEM_VERSIONING — Medio
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Medio
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Medio
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Medio_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).';
END
ELSE
PRINT 'Medio: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Medio_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Medio_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SYSTEM_VERSIONING — Seccion
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Seccion
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Seccion
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Seccion_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Seccion: SYSTEM_VERSIONING = ON.';
END
ELSE
PRINT 'Seccion: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Seccion_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Seccion_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Permiso nuevo: administracion:secciones:gestionar
-- ('administracion:medios:gestionar' ya fue sembrado en V005 — no se toca).
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:secciones:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V011 applied successfully — dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.';
PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).';
GO

View File

@@ -0,0 +1,30 @@
-- V012_ROLLBACK.sql
-- Reversa de V012__seed_medios.sql.
--
-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas.
-- Si alguna sección depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Falla temprano si hay secciones vivas apuntando a estos Medios.
IF EXISTS (
SELECT 1
FROM dbo.Seccion s
JOIN dbo.Medio m ON m.Id = s.MedioId
WHERE m.Codigo IN ('ELDIA', 'ELPLATA')
)
BEGIN
RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1);
RETURN;
END
GO
DELETE FROM dbo.Medio
WHERE Codigo IN ('ELDIA', 'ELPLATA');
GO
PRINT 'V012 rolled back — seed Medios ELDIA y ELPLATA removed.';
GO

View File

@@ -0,0 +1,27 @@
-- V012__seed_medios.sql
-- ADM-001: seed inicial de Medios ELDIA y ELPLATA.
--
-- Idempotente via MERGE por Codigo.
-- Tipo = 1 (Diario) per enum TipoMedio.
-- PlataformaEmpresaId = NULL (INT-003 lo poblará cuando exista el mapeo IMAC).
--
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
MERGE dbo.Medio AS t
USING (VALUES
('ELDIA', N'El Día', 1),
('ELPLATA', N'El Plata', 1)
) AS s (Codigo, Nombre, Tipo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo)
VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1);
GO
PRINT 'V012 applied — Medios ELDIA y ELPLATA seeded (idempotent).';
GO

View File

@@ -0,0 +1,173 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Medios.Create;
using SIGCM2.Application.Medios.Deactivate;
using SIGCM2.Application.Medios.GetById;
using SIGCM2.Application.Medios.List;
using SIGCM2.Application.Medios.Reactivate;
using SIGCM2.Application.Medios.Update;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
/// All endpoints require permission 'administracion:medios:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/medios")]
public sealed class MediosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateMedioCommand> _createValidator;
private readonly IValidator<UpdateMedioCommand> _updateValidator;
public MediosController(
IDispatcher dispatcher,
IValidator<CreateMedioCommand> createValidator,
IValidator<UpdateMedioCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateMedio([FromBody] CreateMedioRequest request)
{
var command = new CreateMedioCommand(
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? TipoMedio.Diario,
PlataformaEmpresaId: request.PlataformaEmpresaId);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateMedioCommand, MedioCreatedDto>(command);
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
}
/// <summary>Lists medios with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListMedios(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = null,
[FromQuery] TipoMedio? tipo = null,
[FromQuery] string? q = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListMediosQuery(page, pageSize, activo, tipo, q);
var result = await _dispatcher.Send<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single medio by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMedioById([FromRoute] int id)
{
var query = new GetMedioByIdQuery(id);
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a medio's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request)
{
var command = new UpdateMedioCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? TipoMedio.Diario,
PlataformaEmpresaId: request.PlataformaEmpresaId);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateMedioCommand, MedioUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a medio (idempotent).</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
{
var command = new DeactivateMedioCommand(id);
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a medio (idempotent).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
{
var command = new ReactivateMedioCommand(id);
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-001: Create medio request body.</summary>
public sealed record CreateMedioRequest(
string? Codigo,
string? Nombre,
TipoMedio? Tipo,
int? PlataformaEmpresaId);
/// <summary>ADM-001: Update medio request body.</summary>
public sealed record UpdateMedioRequest(
string? Nombre,
TipoMedio? Tipo,
int? PlataformaEmpresaId);

View File

@@ -0,0 +1,172 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById;
using SIGCM2.Application.Secciones.List;
using SIGCM2.Application.Secciones.Reactivate;
using SIGCM2.Application.Secciones.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
/// All endpoints require permission 'administracion:secciones:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/secciones")]
public sealed class SeccionesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateSeccionCommand> _createValidator;
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
public SeccionesController(
IDispatcher dispatcher,
IValidator<CreateSeccionCommand> createValidator,
IValidator<UpdateSeccionCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateSeccion([FromBody] CreateSeccionRequest request)
{
var command = new CreateSeccionCommand(
MedioId: request.MedioId ?? 0,
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? string.Empty);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateSeccionCommand, SeccionCreatedDto>(command);
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
}
/// <summary>Lists secciones with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListSecciones(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int? medioId = null,
[FromQuery] string? tipo = null,
[FromQuery] bool? activo = null,
[FromQuery] string? q = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q);
var result = await _dispatcher.Send<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single seccion by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
{
var query = new GetSeccionByIdQuery(id);
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a seccion's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request)
{
var command = new UpdateSeccionCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? string.Empty);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateSeccionCommand, SeccionUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a seccion (idempotent).</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
{
var command = new DeactivateSeccionCommand(id);
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a seccion (idempotent).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
{
var command = new ReactivateSeccionCommand(id);
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-001: Create seccion request body.</summary>
public sealed record CreateSeccionRequest(
int? MedioId,
string? Codigo,
string? Nombre,
string? Tipo);
/// <summary>ADM-001: Update seccion request body.</summary>
public sealed record UpdateSeccionRequest(
string? Nombre,
string? Tipo);

View File

@@ -169,6 +169,56 @@ public sealed class ExceptionFilter : IExceptionFilter
context.ExceptionHandled = true;
break;
// ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new
{
error = "medio_codigo_duplicado",
message = medioCodDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case MedioNotFoundException medioNotFoundEx:
context.Result = new ObjectResult(new
{
error = "medio_not_found",
message = medioNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// ADM-001: Seccion exceptions
case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx:
context.Result = new ObjectResult(new
{
error = "seccion_codigo_duplicado_en_medio",
message = seccionCodDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case SeccionNotFoundException seccionNotFoundEx:
context.Result = new ObjectResult(new
{
error = "seccion_not_found",
message = seccionNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// UDT-009: permiso override validation errors
case InvalidPermisoCodesException ipce:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails

View File

@@ -0,0 +1,13 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IMedioRepository
{
Task<int> AddAsync(Medio m, CancellationToken ct = default);
Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default);
Task UpdateAsync(Medio m, CancellationToken ct = default);
Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface ISeccionRepository
{
Task<int> AddAsync(Seccion s, CancellationToken ct = default);
Task<Seccion?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default);
Task UpdateAsync(Seccion s, CancellationToken ct = default);
Task<PagedResult<Seccion>> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default);
}

View File

@@ -0,0 +1,12 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Common;
/// <summary>Query parameters for listing medios with optional filters and paging.</summary>
public sealed record MediosQuery(
int Page,
int PageSize,
bool? Activo,
TipoMedio? Tipo,
string? Search
);

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Common;
/// <summary>Query parameters for listing secciones with optional filters and paging.</summary>
public sealed record SeccionesQuery(
int Page,
int PageSize,
int? MedioId,
string? Tipo,
bool? Activo,
string? Search
);

View File

@@ -5,6 +5,12 @@ using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
using SIGCM2.Application.Common;
using SIGCM2.Application.Medios.Create;
using SIGCM2.Application.Medios.Deactivate;
using SIGCM2.Application.Medios.GetById;
using SIGCM2.Application.Medios.List;
using SIGCM2.Application.Medios.Reactivate;
using SIGCM2.Application.Medios.Update;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol;
@@ -15,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update;
using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById;
using SIGCM2.Application.Secciones.List;
using SIGCM2.Application.Secciones.Reactivate;
using SIGCM2.Application.Secciones.Update;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
@@ -62,6 +74,22 @@ public static class DependencyInjection
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
// Medios (ADM-001)
services.AddScoped<ICommandHandler<CreateMedioCommand, MedioCreatedDto>, CreateMedioCommandHandler>();
services.AddScoped<ICommandHandler<UpdateMedioCommand, MedioUpdatedDto>, UpdateMedioCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateMedioCommand, MedioStatusDto>, DeactivateMedioCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateMedioCommand, MedioStatusDto>, ReactivateMedioCommandHandler>();
services.AddScoped<ICommandHandler<ListMediosQuery, PagedResult<MedioListItemDto>>, ListMediosQueryHandler>();
services.AddScoped<ICommandHandler<GetMedioByIdQuery, MedioDetailDto>, GetMedioByIdQueryHandler>();
// Secciones (ADM-001)
services.AddScoped<ICommandHandler<CreateSeccionCommand, SeccionCreatedDto>, CreateSeccionCommandHandler>();
services.AddScoped<ICommandHandler<UpdateSeccionCommand, SeccionUpdatedDto>, UpdateSeccionCommandHandler>();
services.AddScoped<ICommandHandler<DeactivateSeccionCommand, SeccionStatusDto>, DeactivateSeccionCommandHandler>();
services.AddScoped<ICommandHandler<ReactivateSeccionCommand, SeccionStatusDto>, ReactivateSeccionCommandHandler>();
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
// FluentValidation validators (scans entire Application assembly)
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();

View File

@@ -0,0 +1,9 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.Create;
public sealed record CreateMedioCommand(
string Codigo,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId);

View File

@@ -0,0 +1,63 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Medios.Create;
public sealed class CreateMedioCommandHandler : ICommandHandler<CreateMedioCommand, MedioCreatedDto>
{
private readonly IMedioRepository _repo;
private readonly IAuditLogger _audit;
public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<MedioCreatedDto> Handle(CreateMedioCommand command)
{
var codigoNorm = command.Codigo.ToUpperInvariant();
var exists = await _repo.ExistsByCodigoAsync(codigoNorm);
if (exists)
throw new MedioCodigoDuplicadoException(codigoNorm);
var medio = Medio.ForCreation(codigoNorm, command.Nombre, command.Tipo, command.PlataformaEmpresaId);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(medio);
await _audit.LogAsync(
action: "medio.create",
targetType: "Medio",
targetId: newId.ToString(),
metadata: new
{
after = new
{
medio.Codigo,
medio.Nombre,
medio.Tipo,
medio.PlataformaEmpresaId,
},
});
tx.Complete();
return new MedioCreatedDto(
Id: newId,
Codigo: medio.Codigo,
Nombre: medio.Nombre,
Tipo: medio.Tipo,
PlataformaEmpresaId: medio.PlataformaEmpresaId,
Activo: medio.Activo);
}
}

View File

@@ -0,0 +1,25 @@
using FluentValidation;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.Create;
public sealed class CreateMedioCommandValidator : AbstractValidator<CreateMedioCommand>
{
private const int CodigoMaxLength = 30;
private const int NombreMaxLength = 100;
public CreateMedioCommandValidator()
{
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.")
.MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres.")
.Matches(@"^[A-Za-z0-9_]+$").WithMessage("El código solo puede contener letras, dígitos y guiones bajos.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Tipo)
.IsInEnum().WithMessage("El tipo de medio no es válido.");
}
}

View File

@@ -0,0 +1,11 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.Create;
public sealed record MedioCreatedDto(
int Id,
string Codigo,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId,
bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Medios.Deactivate;
public sealed record DeactivateMedioCommand(int Id);

View File

@@ -0,0 +1,48 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Medios.Deactivate;
public sealed class DeactivateMedioCommandHandler : ICommandHandler<DeactivateMedioCommand, MedioStatusDto>
{
private readonly IMedioRepository _repo;
private readonly IAuditLogger _audit;
public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<MedioStatusDto> Handle(DeactivateMedioCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new MedioNotFoundException(command.Id);
// Idempotent: already inactive → return as-is without writing an audit event
if (!target.Activo)
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
var updated = target.WithActivo(false);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "medio.deactivate",
targetType: "Medio",
targetId: command.Id.ToString());
tx.Complete();
return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Medios.Deactivate;
public sealed record MedioStatusDto(int Id, string Codigo, bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Medios.GetById;
public sealed record GetMedioByIdQuery(int Id);

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Medios.GetById;
public sealed class GetMedioByIdQueryHandler : ICommandHandler<GetMedioByIdQuery, MedioDetailDto>
{
private readonly IMedioRepository _repo;
public GetMedioByIdQueryHandler(IMedioRepository repo)
{
_repo = repo;
}
public async Task<MedioDetailDto> Handle(GetMedioByIdQuery query)
{
var medio = await _repo.GetByIdAsync(query.Id)
?? throw new MedioNotFoundException(query.Id);
return new MedioDetailDto(
Id: medio.Id,
Codigo: medio.Codigo,
Nombre: medio.Nombre,
Tipo: medio.Tipo,
PlataformaEmpresaId: medio.PlataformaEmpresaId,
Activo: medio.Activo,
FechaCreacion: medio.FechaCreacion,
FechaModificacion: medio.FechaModificacion);
}
}

View File

@@ -0,0 +1,13 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.GetById;
public sealed record MedioDetailDto(
int Id,
string Codigo,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,10 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.List;
public sealed record ListMediosQuery(
int Page = 1,
int PageSize = 20,
bool? Activo = true,
TipoMedio? Tipo = null,
string? Search = null);

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.List;
public sealed class ListMediosQueryHandler : ICommandHandler<ListMediosQuery, PagedResult<MedioListItemDto>>
{
private readonly IMedioRepository _repo;
public ListMediosQueryHandler(IMedioRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<MedioListItemDto>> Handle(ListMediosQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new MediosQuery(page, pageSize, query.Activo, query.Tipo, query.Search);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items
.Select(m => new MedioListItemDto(m.Id, m.Codigo, m.Nombre, m.Tipo, m.PlataformaEmpresaId, m.Activo))
.ToList();
return new PagedResult<MedioListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,11 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.List;
public sealed record MedioListItemDto(
int Id,
string Codigo,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId,
bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Medios.Reactivate;
public sealed record ReactivateMedioCommand(int Id);

View File

@@ -0,0 +1,49 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Medios.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Medios.Reactivate;
public sealed class ReactivateMedioCommandHandler : ICommandHandler<ReactivateMedioCommand, MedioStatusDto>
{
private readonly IMedioRepository _repo;
private readonly IAuditLogger _audit;
public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<MedioStatusDto> Handle(ReactivateMedioCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new MedioNotFoundException(command.Id);
// Idempotent: already active → return as-is without writing an audit event
if (target.Activo)
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
var updated = target.WithActivo(true);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "medio.reactivate",
targetType: "Medio",
targetId: command.Id.ToString());
tx.Complete();
return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo);
}
}

View File

@@ -0,0 +1,11 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.Update;
public sealed record MedioUpdatedDto(
int Id,
string Codigo,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId,
bool Activo);

View File

@@ -0,0 +1,9 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Medios.Update;
public sealed record UpdateMedioCommand(
int Id,
string Nombre,
TipoMedio Tipo,
int? PlataformaEmpresaId);

View File

@@ -0,0 +1,55 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Medios.Update;
public sealed class UpdateMedioCommandHandler : ICommandHandler<UpdateMedioCommand, MedioUpdatedDto>
{
private readonly IMedioRepository _repo;
private readonly IAuditLogger _audit;
public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<MedioUpdatedDto> Handle(UpdateMedioCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new MedioNotFoundException(command.Id);
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "medio.update",
targetType: "Medio",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.Tipo, target.PlataformaEmpresaId },
after = new { updated.Nombre, updated.Tipo, updated.PlataformaEmpresaId },
});
tx.Complete();
return new MedioUpdatedDto(
Id: updated.Id,
Codigo: updated.Codigo,
Nombre: updated.Nombre,
Tipo: updated.Tipo,
PlataformaEmpresaId: updated.PlataformaEmpresaId,
Activo: updated.Activo);
}
}

View File

@@ -0,0 +1,21 @@
using FluentValidation;
namespace SIGCM2.Application.Medios.Update;
public sealed class UpdateMedioCommandValidator : AbstractValidator<UpdateMedioCommand>
{
private const int NombreMaxLength = 100;
public UpdateMedioCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Tipo)
.IsInEnum().WithMessage("El tipo de medio no es válido.");
}
}

View File

@@ -0,0 +1,7 @@
namespace SIGCM2.Application.Secciones.Create;
public sealed record CreateSeccionCommand(
int MedioId,
string Codigo,
string Nombre,
string Tipo);

View File

@@ -0,0 +1,68 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Secciones.Create;
public sealed class CreateSeccionCommandHandler : ICommandHandler<CreateSeccionCommand, SeccionCreatedDto>
{
private readonly ISeccionRepository _repo;
private readonly IMedioRepository _medioRepo;
private readonly IAuditLogger _audit;
public CreateSeccionCommandHandler(ISeccionRepository repo, IMedioRepository medioRepo, IAuditLogger audit)
{
_repo = repo;
_medioRepo = medioRepo;
_audit = audit;
}
public async Task<SeccionCreatedDto> Handle(CreateSeccionCommand command)
{
// Validate medio exists and is active (REQ-SEC-001)
var medio = await _medioRepo.GetByIdAsync(command.MedioId);
if (medio is null || !medio.Activo)
throw new MedioNotFoundException(command.MedioId);
var exists = await _repo.ExistsByCodigoInMedioAsync(command.MedioId, command.Codigo);
if (exists)
throw new SeccionCodigoDuplicadoEnMedioException(command.MedioId, command.Codigo);
var seccion = Seccion.ForCreation(command.MedioId, command.Codigo, command.Nombre, command.Tipo);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
var newId = await _repo.AddAsync(seccion);
await _audit.LogAsync(
action: "seccion.create",
targetType: "Seccion",
targetId: newId.ToString(),
metadata: new
{
after = new
{
seccion.MedioId,
seccion.Codigo,
seccion.Nombre,
seccion.Tipo,
},
});
tx.Complete();
return new SeccionCreatedDto(
Id: newId,
MedioId: seccion.MedioId,
Codigo: seccion.Codigo,
Nombre: seccion.Nombre,
Tipo: seccion.Tipo,
Activo: seccion.Activo);
}
}

View File

@@ -0,0 +1,29 @@
using FluentValidation;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Secciones.Create;
public sealed class CreateSeccionCommandValidator : AbstractValidator<CreateSeccionCommand>
{
private const int CodigoMaxLength = 30;
private const int NombreMaxLength = 100;
public CreateSeccionCommandValidator()
{
RuleFor(x => x.MedioId)
.GreaterThan(0).WithMessage("El medioId debe ser mayor a 0.");
RuleFor(x => x.Codigo)
.NotEmpty().WithMessage("El código es requerido.")
.MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Tipo)
.NotEmpty().WithMessage("El tipo es requerido.")
.Must(t => TipoSeccion.AllowedTipos.Contains(t))
.WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}.");
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Secciones.Create;
public sealed record SeccionCreatedDto(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Secciones.Deactivate;
public sealed record DeactivateSeccionCommand(int Id);

View File

@@ -0,0 +1,48 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Secciones.Deactivate;
public sealed class DeactivateSeccionCommandHandler : ICommandHandler<DeactivateSeccionCommand, SeccionStatusDto>
{
private readonly ISeccionRepository _repo;
private readonly IAuditLogger _audit;
public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<SeccionStatusDto> Handle(DeactivateSeccionCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new SeccionNotFoundException(command.Id);
// Idempotent: already inactive → return as-is without writing an audit event
if (!target.Activo)
return new SeccionStatusDto(target.Id, target.Codigo, target.Activo);
var updated = target.WithActivo(false);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "seccion.deactivate",
targetType: "Seccion",
targetId: command.Id.ToString());
tx.Complete();
return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo);
}
}

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Secciones.Deactivate;
public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Secciones.GetById;
public sealed record GetSeccionByIdQuery(int Id);

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Secciones.GetById;
public sealed class GetSeccionByIdQueryHandler : ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>
{
private readonly ISeccionRepository _repo;
public GetSeccionByIdQueryHandler(ISeccionRepository repo)
{
_repo = repo;
}
public async Task<SeccionDetailDto> Handle(GetSeccionByIdQuery query)
{
var seccion = await _repo.GetByIdAsync(query.Id)
?? throw new SeccionNotFoundException(query.Id);
return new SeccionDetailDto(
Id: seccion.Id,
MedioId: seccion.MedioId,
Codigo: seccion.Codigo,
Nombre: seccion.Nombre,
Tipo: seccion.Tipo,
Activo: seccion.Activo,
FechaCreacion: seccion.FechaCreacion,
FechaModificacion: seccion.FechaModificacion);
}
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Application.Secciones.GetById;
public sealed record SeccionDetailDto(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Secciones.List;
public sealed record ListSeccionesQuery(
int Page = 1,
int PageSize = 20,
int? MedioId = null,
string? Tipo = null,
bool? Activo = true,
string? Search = null);

View File

@@ -0,0 +1,30 @@
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
namespace SIGCM2.Application.Secciones.List;
public sealed class ListSeccionesQueryHandler : ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>
{
private readonly ISeccionRepository _repo;
public ListSeccionesQueryHandler(ISeccionRepository repo)
{
_repo = repo;
}
public async Task<PagedResult<SeccionListItemDto>> Handle(ListSeccionesQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 100);
var repoQuery = new SeccionesQuery(page, pageSize, query.MedioId, query.Tipo, query.Activo, query.Search);
var paged = await _repo.GetPagedAsync(repoQuery);
var items = paged.Items
.Select(s => new SeccionListItemDto(s.Id, s.MedioId, s.Codigo, s.Nombre, s.Tipo, s.Activo))
.ToList();
return new PagedResult<SeccionListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Secciones.List;
public sealed record SeccionListItemDto(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo);

View File

@@ -0,0 +1,3 @@
namespace SIGCM2.Application.Secciones.Reactivate;
public sealed record ReactivateSeccionCommand(int Id);

View File

@@ -0,0 +1,49 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Secciones.Reactivate;
public sealed class ReactivateSeccionCommandHandler : ICommandHandler<ReactivateSeccionCommand, SeccionStatusDto>
{
private readonly ISeccionRepository _repo;
private readonly IAuditLogger _audit;
public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<SeccionStatusDto> Handle(ReactivateSeccionCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new SeccionNotFoundException(command.Id);
// Idempotent: already active → return as-is without writing an audit event
if (target.Activo)
return new SeccionStatusDto(target.Id, target.Codigo, target.Activo);
var updated = target.WithActivo(true);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "seccion.reactivate",
targetType: "Seccion",
targetId: command.Id.ToString());
tx.Complete();
return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo);
}
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Application.Secciones.Update;
public sealed record SeccionUpdatedDto(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo);

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Secciones.Update;
public sealed record UpdateSeccionCommand(
int Id,
string Nombre,
string Tipo);

View File

@@ -0,0 +1,55 @@
using System.Transactions;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Domain.Entities;
using SIGCM2.Domain.Exceptions;
namespace SIGCM2.Application.Secciones.Update;
public sealed class UpdateSeccionCommandHandler : ICommandHandler<UpdateSeccionCommand, SeccionUpdatedDto>
{
private readonly ISeccionRepository _repo;
private readonly IAuditLogger _audit;
public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
{
_repo = repo;
_audit = audit;
}
public async Task<SeccionUpdatedDto> Handle(UpdateSeccionCommand command)
{
var target = await _repo.GetByIdAsync(command.Id)
?? throw new SeccionNotFoundException(command.Id);
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo);
using var tx = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
TransactionScopeAsyncFlowOption.Enabled);
await _repo.UpdateAsync(updated);
await _audit.LogAsync(
action: "seccion.update",
targetType: "Seccion",
targetId: command.Id.ToString(),
metadata: new
{
before = new { target.Nombre, target.Tipo },
after = new { updated.Nombre, updated.Tipo },
});
tx.Complete();
return new SeccionUpdatedDto(
Id: updated.Id,
MedioId: updated.MedioId,
Codigo: updated.Codigo,
Nombre: updated.Nombre,
Tipo: updated.Tipo,
Activo: updated.Activo);
}
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Secciones.Update;
public sealed class UpdateSeccionCommandValidator : AbstractValidator<UpdateSeccionCommand>
{
private const int NombreMaxLength = 100;
public UpdateSeccionCommandValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
RuleFor(x => x.Nombre)
.NotEmpty().WithMessage("El nombre es requerido.")
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
RuleFor(x => x.Tipo)
.NotEmpty().WithMessage("El tipo es requerido.")
.Must(t => TipoSeccion.AllowedTipos.Contains(t))
.WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}.");
}
}

View File

@@ -0,0 +1,75 @@
namespace SIGCM2.Domain.Entities;
public sealed class Medio
{
public int Id { get; }
public string Codigo { get; }
public string Nombre { get; }
public TipoMedio Tipo { get; }
public int? PlataformaEmpresaId { get; }
public bool Activo { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
public Medio(
int id,
string codigo,
string nombre,
TipoMedio tipo,
int? plataformaEmpresaId,
bool activo,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
Codigo = codigo;
Nombre = nombre;
Tipo = tipo;
PlataformaEmpresaId = plataformaEmpresaId;
Activo = activo;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
/// <summary>
/// Factory for creating a new Medio (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default).
/// </summary>
public static Medio ForCreation(string codigo, string nombre, TipoMedio tipo, int? plataformaEmpresaId)
{
return new Medio(
id: 0,
codigo: codigo,
nombre: nombre,
tipo: tipo,
plataformaEmpresaId: plataformaEmpresaId,
activo: true,
fechaCreacion: default,
fechaModificacion: null);
}
/// <summary>
/// Returns a new instance with updated fields. Codigo is immutable (use BD UQ to enforce).
/// Sets FechaModificacion = UtcNow.
/// </summary>
public Medio WithUpdatedProfile(string nombre, TipoMedio tipo, int? plataformaEmpresaId)
=> new(
id: Id,
codigo: Codigo,
nombre: nombre,
tipo: tipo,
plataformaEmpresaId: plataformaEmpresaId,
activo: Activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
public Medio WithActivo(bool activo)
=> new(
id: Id,
codigo: Codigo,
nombre: Nombre,
tipo: Tipo,
plataformaEmpresaId: PlataformaEmpresaId,
activo: activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
}

View File

@@ -0,0 +1,72 @@
namespace SIGCM2.Domain.Entities;
public sealed class Seccion
{
public int Id { get; }
public int MedioId { get; }
public string Codigo { get; }
public string Nombre { get; }
public string Tipo { get; } // 'clasificados' | 'notables' | 'suplementos' — enforzado por CHECK en BD
public bool Activo { get; }
public DateTime FechaCreacion { get; }
public DateTime? FechaModificacion { get; }
public Seccion(
int id,
int medioId,
string codigo,
string nombre,
string tipo,
bool activo,
DateTime fechaCreacion,
DateTime? fechaModificacion)
{
Id = id;
MedioId = medioId;
Codigo = codigo;
Nombre = nombre;
Tipo = tipo;
Activo = activo;
FechaCreacion = fechaCreacion;
FechaModificacion = fechaModificacion;
}
public static Seccion ForCreation(int medioId, string codigo, string nombre, string tipo)
{
return new Seccion(
id: 0,
medioId: medioId,
codigo: codigo,
nombre: nombre,
tipo: tipo,
activo: true,
fechaCreacion: default,
fechaModificacion: null);
}
/// <summary>
/// Returns a new instance with updated fields. MedioId and Codigo are immutable.
/// Sets FechaModificacion = UtcNow.
/// </summary>
public Seccion WithUpdatedProfile(string nombre, string tipo)
=> new(
id: Id,
medioId: MedioId,
codigo: Codigo,
nombre: nombre,
tipo: tipo,
activo: Activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
public Seccion WithActivo(bool activo)
=> new(
id: Id,
medioId: MedioId,
codigo: Codigo,
nombre: Nombre,
tipo: Tipo,
activo: activo,
fechaCreacion: FechaCreacion,
fechaModificacion: DateTime.UtcNow);
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM2.Domain.Entities;
public enum TipoMedio
{
Diario = 1,
Radio = 2,
Web = 3,
Poster = 4,
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Entities;
/// <summary>
/// Allowed string values for Seccion.Tipo.
/// Enforced at application layer (validator) and database layer (CHECK constraint).
/// </summary>
public static class TipoSeccion
{
public static readonly string[] AllowedTipos = { "clasificados", "notables", "suplementos" };
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create a Medio with a Codigo that already exists (global UQ).
/// </summary>
public sealed class MedioCodigoDuplicadoException : DomainException
{
public string Codigo { get; }
public MedioCodigoDuplicadoException(string codigo)
: base($"El medio con código '{codigo}' ya existe.")
{
Codigo = codigo;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Medio does not exist in the system.
/// </summary>
public sealed class MedioNotFoundException : DomainException
{
public int Id { get; }
public MedioNotFoundException(int id)
: base($"El medio con id '{id}' no existe.")
{
Id = id;
}
}

View File

@@ -0,0 +1,18 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when attempting to create a Seccion with a Codigo that already exists within the same Medio
/// (composite UQ on MedioId + Codigo).
/// </summary>
public sealed class SeccionCodigoDuplicadoEnMedioException : DomainException
{
public int MedioId { get; }
public string Codigo { get; }
public SeccionCodigoDuplicadoEnMedioException(int medioId, string codigo)
: base($"La sección con código '{codigo}' ya existe para el medio {medioId}.")
{
MedioId = medioId;
Codigo = codigo;
}
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested Seccion does not exist in the system.
/// </summary>
public sealed class SeccionNotFoundException : DomainException
{
public int Id { get; }
public SeccionNotFoundException(int id)
: base($"La sección con id '{id}' no existe.")
{
Id = id;
}
}

View File

@@ -32,6 +32,8 @@ public static class DependencyInjection
services.AddScoped<IRolRepository, RolRepository>();
services.AddScoped<IPermisoRepository, PermisoRepository>();
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
services.AddScoped<IMedioRepository, MedioRepository>();
services.AddScoped<ISeccionRepository, SeccionRepository>();
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));

View File

@@ -0,0 +1,188 @@
using System.Text;
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class MedioRepository : IMedioRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public MedioRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> AddAsync(Medio m, CancellationToken ct = default)
{
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
const string sql = """
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, PlataformaEmpresaId)
OUTPUT INSERTED.Id
VALUES (@Codigo, @Nombre, @Tipo, @PlataformaEmpresaId)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new
{
m.Codigo,
m.Nombre,
Tipo = (int)m.Tipo,
m.PlataformaEmpresaId,
});
}
public async Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion
FROM dbo.Medio
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<MedioRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Medio WHERE Codigo = @Codigo
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
return count > 0;
}
public async Task UpdateAsync(Medio m, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.Medio
SET Nombre = @Nombre,
Tipo = @Tipo,
PlataformaEmpresaId = @PlataformaEmpresaId,
Activo = @Activo,
FechaModificacion = @FechaModificacion
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(sql, new
{
m.Nombre,
Tipo = (int)m.Tipo,
m.PlataformaEmpresaId,
m.Activo,
FechaModificacion = m.FechaModificacion ?? DateTime.UtcNow,
m.Id,
});
}
public async Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, CancellationToken ct = default)
{
var page = Math.Max(1, q.Page);
var pageSize = Math.Clamp(q.PageSize, 1, 100);
var offset = (page - 1) * pageSize;
var where = new StringBuilder("WHERE 1=1");
var parameters = new DynamicParameters();
parameters.Add("PageSize", pageSize);
parameters.Add("Offset", offset);
if (q.Activo.HasValue)
{
where.Append(" AND Activo = @Activo");
parameters.Add("Activo", q.Activo.Value ? 1 : 0);
}
if (q.Tipo.HasValue)
{
where.Append(" AND Tipo = @Tipo");
parameters.Add("Tipo", (int)q.Tipo.Value);
}
if (!string.IsNullOrWhiteSpace(q.Search))
{
where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)");
parameters.Add("Search", $"%{q.Search}%");
}
var sql = $"""
SELECT
Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion,
COUNT(*) OVER() AS TotalCount
FROM dbo.Medio
{where}
ORDER BY Codigo
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<MedioPagedRow>(sql, parameters);
var list = rows.ToList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
var items = list.Select(r => MapRow(r)).ToList();
return new PagedResult<Medio>(items, page, pageSize, total);
}
// ── mapping ───────────────────────────────────────────────────────────────
private static Medio MapRow(MedioRow r)
=> new(
id: r.Id,
codigo: r.Codigo,
nombre: r.Nombre,
tipo: (TipoMedio)r.Tipo,
plataformaEmpresaId: r.PlataformaEmpresaId,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static Medio MapRow(MedioPagedRow r)
=> new(
id: r.Id,
codigo: r.Codigo,
nombre: r.Nombre,
tipo: (TipoMedio)r.Tipo,
plataformaEmpresaId: r.PlataformaEmpresaId,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private sealed record MedioRow(
int Id,
string Codigo,
string Nombre,
byte Tipo,
int? PlataformaEmpresaId,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record MedioPagedRow(
int Id,
string Codigo,
string Nombre,
byte Tipo,
int? PlataformaEmpresaId,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

@@ -0,0 +1,197 @@
using System.Text;
using Dapper;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Infrastructure.Persistence;
public sealed class SeccionRepository : ISeccionRepository
{
private readonly SqlConnectionFactory _connectionFactory;
public SeccionRepository(SqlConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<int> AddAsync(Seccion s, CancellationToken ct = default)
{
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
// FK_Seccion_Medio: if MedioId does not exist, SQL Server raises FK violation — let it bubble.
const string sql = """
INSERT INTO dbo.Seccion (MedioId, Codigo, Nombre, Tipo)
OUTPUT INSERTED.Id
VALUES (@MedioId, @Codigo, @Nombre, @Tipo)
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
return await connection.ExecuteScalarAsync<int>(sql, new
{
s.MedioId,
s.Codigo,
s.Nombre,
s.Tipo,
});
}
public async Task<Seccion?> GetByIdAsync(int id, CancellationToken ct = default)
{
const string sql = """
SELECT Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion
FROM dbo.Seccion
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var row = await connection.QuerySingleOrDefaultAsync<SeccionRow>(sql, new { Id = id });
return row is null ? null : MapRow(row);
}
public async Task<bool> ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default)
{
const string sql = """
SELECT COUNT(1) FROM dbo.Seccion WHERE MedioId = @MedioId AND Codigo = @Codigo
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var count = await connection.ExecuteScalarAsync<int>(sql, new { MedioId = medioId, Codigo = codigo });
return count > 0;
}
public async Task UpdateAsync(Seccion s, CancellationToken ct = default)
{
const string sql = """
UPDATE dbo.Seccion
SET Nombre = @Nombre,
Tipo = @Tipo,
Activo = @Activo,
FechaModificacion = @FechaModificacion
WHERE Id = @Id
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
await connection.ExecuteAsync(sql, new
{
s.Nombre,
s.Tipo,
s.Activo,
FechaModificacion = s.FechaModificacion ?? DateTime.UtcNow,
s.Id,
});
}
public async Task<PagedResult<Seccion>> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default)
{
var page = Math.Max(1, q.Page);
var pageSize = Math.Clamp(q.PageSize, 1, 100);
var offset = (page - 1) * pageSize;
var where = new StringBuilder("WHERE 1=1");
var parameters = new DynamicParameters();
parameters.Add("PageSize", pageSize);
parameters.Add("Offset", offset);
if (q.MedioId.HasValue)
{
where.Append(" AND MedioId = @MedioId");
parameters.Add("MedioId", q.MedioId.Value);
}
if (!string.IsNullOrWhiteSpace(q.Tipo))
{
where.Append(" AND Tipo = @Tipo");
parameters.Add("Tipo", q.Tipo);
}
if (q.Activo.HasValue)
{
where.Append(" AND Activo = @Activo");
parameters.Add("Activo", q.Activo.Value ? 1 : 0);
}
if (!string.IsNullOrWhiteSpace(q.Search))
{
where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)");
parameters.Add("Search", $"%{q.Search}%");
}
// ADM-001: filter only Seccion.Activo; Medio.Activo check is left to Application/UI layer.
// Joining Medio to filter on m.Activo would affect performance for large catalogs and is
// not required by the current specs. REQ-SEC-003 (Deactivate Medio hides Secciones) is
// enforced at the Application handler level, not the query level.
var sql = $"""
SELECT
Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion,
COUNT(*) OVER() AS TotalCount
FROM dbo.Seccion
{where}
ORDER BY MedioId, Codigo
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
""";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(ct);
var rows = await connection.QueryAsync<SeccionPagedRow>(sql, parameters);
var list = rows.ToList();
var total = list.Count > 0 ? list[0].TotalCount : 0;
var items = list.Select(r => MapRow(r)).ToList();
return new PagedResult<Seccion>(items, page, pageSize, total);
}
// ── mapping ───────────────────────────────────────────────────────────────
private static Seccion MapRow(SeccionRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
codigo: r.Codigo,
nombre: r.Nombre,
tipo: r.Tipo,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private static Seccion MapRow(SeccionPagedRow r)
=> new(
id: r.Id,
medioId: r.MedioId,
codigo: r.Codigo,
nombre: r.Nombre,
tipo: r.Tipo,
activo: r.Activo,
fechaCreacion: r.FechaCreacion,
fechaModificacion: r.FechaModificacion);
private sealed record SeccionRow(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion);
private sealed record SeccionPagedRow(
int Id,
int MedioId,
string Codigo,
string Nombre,
string Tipo,
bool Activo,
DateTime FechaCreacion,
DateTime? FechaModificacion,
int TotalCount);
}

View File

@@ -12,6 +12,8 @@ import {
FileClock,
PanelLeftClose,
PanelLeftOpen,
Newspaper,
Columns3,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -47,6 +49,18 @@ const adminItems: NavItem[] = [
icon: FileClock,
requiredPermission: 'administracion:auditoria:ver',
},
{
label: 'Medios',
href: '/admin/medios',
icon: Newspaper,
requiredPermission: 'administracion:medios:gestionar',
},
{
label: 'Secciones',
href: '/admin/secciones',
icon: Columns3,
requiredPermission: 'administracion:secciones:gestionar',
},
]
interface SidebarNavProps {

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { CreateMedioRequest, MedioCreated } from '../types'
export async function createMedio(payload: CreateMedioRequest): Promise<MedioCreated> {
const response = await axiosClient.post<MedioCreated>('/api/v1/admin/medios', payload)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function deactivateMedio(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`)
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { MedioDetail } from '../types'
export async function getMedio(id: number): Promise<MedioDetail> {
const response = await axiosClient.get<MedioDetail>(`/api/v1/admin/medios/${id}`)
return response.data
}

View File

@@ -0,0 +1,17 @@
import { axiosClient } from '@/api/axiosClient'
import type { MedioListItem, MediosQuery, PagedResult } from '../types'
export async function listMedios(query: MediosQuery): Promise<PagedResult<MedioListItem>> {
const params = new URLSearchParams()
if (query.page !== undefined) params.set('page', String(query.page))
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
if (query.activo !== undefined) params.set('activo', String(query.activo))
if (query.tipo !== undefined) params.set('tipo', String(query.tipo))
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
const response = await axiosClient.get<PagedResult<MedioListItem>>(
'/api/v1/admin/medios',
{ params },
)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function reactivateMedio(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`)
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { MedioDetail, UpdateMedioRequest } from '../types'
export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise<MedioDetail> {
const response = await axiosClient.put<MedioDetail>(`/api/v1/admin/medios/${id}`, payload)
return response.data
}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useDeactivateMedio } from '../hooks/useDeactivateMedio'
import { useReactivateMedio } from '../hooks/useReactivateMedio'
interface DeactivateMedioModalProps {
medioId: number
medioNombre: string
activo: boolean
}
export function DeactivateMedioModal({ medioId, medioNombre, activo }: DeactivateMedioModalProps) {
const [open, setOpen] = useState(false)
const { mutate: deactivate, isPending: deactivating } = useDeactivateMedio()
const { mutate: reactivate, isPending: reactivating } = useReactivateMedio()
const isPending = deactivating || reactivating
function handleConfirm() {
if (activo) {
deactivate(medioId, { onSuccess: () => setOpen(false) })
} else {
reactivate(medioId, { onSuccess: () => setOpen(false) })
}
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
{activo ? 'Desactivar' : 'Reactivar'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{activo ? 'Desactivar medio' : 'Reactivar medio'}
</AlertDialogTitle>
<AlertDialogDescription>
{activo
? `¿Confirmás que querés desactivar el medio "${medioNombre}"? El medio no podrá usarse hasta que sea reactivado.`
: `¿Confirmás que querés reactivar el medio "${medioNombre}"?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,197 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
import type { MedioDetail } from '../types'
const medioFormSchema = z.object({
codigo: z
.string()
.min(1, 'El código es requerido')
.max(20, 'Máximo 20 caracteres'),
nombre: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'Máximo 100 caracteres'),
tipo: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un tipo válido'),
plataformaEmpresaId: z
.union([z.coerce.number().int().positive('Debe ser un número positivo'), z.literal('')])
.optional()
.transform((v) => (v === '' || v === undefined ? null : Number(v))),
})
export type MedioFormValues = z.infer<typeof medioFormSchema>
interface MedioFormProps {
/** If provided, the form is in edit mode (código is disabled, form is pre-filled). */
initialData?: MedioDetail
isPending: boolean
error: unknown
onSubmit: (values: MedioFormValues) => void
}
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
if (data.error === 'medio_codigo_duplicado') {
return data.message ?? 'Ya existe un medio con ese código'
}
return data.message ?? data.error ?? 'Error al guardar el medio'
}
return 'Error al guardar el medio'
}
export function MedioForm({ initialData, isPending, error, onSubmit }: MedioFormProps) {
const isEdit = !!initialData
const form = useForm<MedioFormValues>({
resolver: zodResolver(medioFormSchema),
defaultValues: {
codigo: initialData?.codigo ?? '',
nombre: initialData?.nombre ?? '',
tipo: initialData?.tipo ?? ('' as unknown as number),
plataformaEmpresaId: (initialData?.plataformaEmpresaId ?? '') as unknown as undefined,
},
})
useEffect(() => {
if (initialData) {
form.reset({
codigo: initialData.codigo,
nombre: initialData.nombre,
tipo: initialData.tipo,
plataformaEmpresaId: (initialData.plataformaEmpresaId ?? '') as unknown as undefined,
})
}
}, [initialData, form])
const backendError = resolveBackendError(error)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="codigo"
render={({ field }) => (
<FormItem>
<FormLabel>Código</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending || isEdit}
placeholder="Ej: DIARIO01"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending}
placeholder="Nombre del medio"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tipo"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo</FormLabel>
<Select
value={field.value ? String(field.value) : ''}
onValueChange={(v) => field.onChange(v === '' ? '' : Number(v))}
disabled={isPending}
>
<FormControl>
<SelectTrigger className="w-full" aria-label="Tipo">
<SelectValue placeholder="Seleccioná un tipo" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TIPO_MEDIO_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="plataformaEmpresaId"
render={({ field }) => (
<FormItem>
<FormLabel>Plataforma Empresa ID (opcional)</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
type="number"
min={1}
disabled={isPending}
placeholder="ID numérico (opcional)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear medio'}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,103 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import type { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import { CanPerform } from '@/components/auth/CanPerform'
import type { MedioListItem } from '../types'
import { tipoMedioLabel } from '../tipoMedio'
import { DeactivateMedioModal } from './DeactivateMedioModal'
interface MediosTableProps {
rows: MedioListItem[]
}
export function MediosTable({ rows }: MediosTableProps) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<MedioListItem>[]>(
() => [
{
accessorKey: 'codigo',
header: 'Código',
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.codigo}</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'nombre',
header: 'Nombre',
meta: { priority: 'high' },
},
{
accessorKey: 'tipo',
header: 'Tipo',
cell: ({ row }) => (
<Badge variant="secondary">{tipoMedioLabel(row.original.tipo)}</Badge>
),
meta: { priority: 'high' },
},
{
accessorKey: 'plataformaEmpresaId',
header: 'Plataforma ID',
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.plataformaEmpresaId ?? '—'}
</span>
),
meta: { priority: 'medium' },
},
{
accessorKey: 'activo',
header: 'Estado',
cell: ({ row }) =>
row.original.activo ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Activo
</Badge>
) : (
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Inactivo
</Badge>
),
meta: { priority: 'medium' },
},
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<CanPerform permission="administracion:medios:gestionar">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/medios/${row.original.id}/edit`)}
>
Editar
</Button>
<DeactivateMedioModal
medioId={row.original.id}
medioNombre={row.original.nombre}
activo={row.original.activo}
/>
</CanPerform>
</div>
),
meta: { priority: 'high' },
},
],
[navigate],
)
return (
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => navigate(`/admin/medios/${row.id}`)}
getRowId={(row) => String(row.id)}
emptyMessage="Sin resultados — no se encontraron medios con los filtros seleccionados."
/>
)
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createMedio } from '../api/createMedio'
import type { CreateMedioRequest } from '../types'
export function useCreateMedio() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: CreateMedioRequest) => createMedio(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['medios'] })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { deactivateMedio } from '../api/deactivateMedio'
export function useDeactivateMedio() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deactivateMedio(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['medios'] })
},
})
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getMedio } from '../api/getMedio'
export function useMedio(id: number) {
return useQuery({
queryKey: ['medios', 'detail', id],
queryFn: () => getMedio(id),
enabled: !!id,
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { listMedios } from '../api/listMedios'
import type { MediosQuery } from '../types'
export const mediosListQueryKey = (query: MediosQuery) => ['medios', 'list', query] as const
export function useMediosList(query: MediosQuery) {
return useQuery({
queryKey: mediosListQueryKey(query),
queryFn: () => listMedios(query),
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { reactivateMedio } from '../api/reactivateMedio'
export function useReactivateMedio() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => reactivateMedio(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['medios'] })
},
})
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateMedio } from '../api/updateMedio'
import type { UpdateMedioRequest } from '../types'
export function useUpdateMedio(id: number) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: UpdateMedioRequest) => updateMedio(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['medios'] })
},
})
}

View File

@@ -0,0 +1,50 @@
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { MedioForm } from '../components/MedioForm'
import { useCreateMedio } from '../hooks/useCreateMedio'
import type { MedioFormValues } from '../components/MedioForm'
export function CreateMedioPage() {
const navigate = useNavigate()
const { mutate, isPending, error } = useCreateMedio()
function handleSubmit(values: MedioFormValues) {
mutate(
{
codigo: values.codigo,
nombre: values.nombre,
tipo: values.tipo,
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
},
{
onSuccess: () => {
toast.success('Medio creado correctamente')
void navigate('/admin/medios')
},
},
)
}
return (
<div className="flex justify-center py-8">
<Card className="w-full max-w-lg">
<CardHeader className="space-y-1">
<CardTitle className="text-xl">Crear Medio</CardTitle>
<CardDescription>
Completá los datos para registrar un nuevo medio en el sistema.
</CardDescription>
</CardHeader>
<CardContent>
<MedioForm isPending={isPending} error={error} onSubmit={handleSubmit} />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { MedioForm } from '../components/MedioForm'
import { useMedio } from '../hooks/useMedio'
import { useUpdateMedio } from '../hooks/useUpdateMedio'
import type { MedioFormValues } from '../components/MedioForm'
export function EditMedioPage() {
const { id } = useParams<{ id: string }>()
const medioId = Number(id)
const navigate = useNavigate()
const { data: medio, isLoading } = useMedio(medioId)
const { mutate, isPending, error } = useUpdateMedio(medioId)
function handleSubmit(values: MedioFormValues) {
mutate(
{
nombre: values.nombre,
tipo: values.tipo,
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
},
{
onSuccess: () => {
toast.success('Medio actualizado correctamente')
void navigate(`/admin/medios/${medioId}`)
},
},
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!medio) {
return (
<div className="py-12 text-center text-muted-foreground">
Medio no encontrado.
</div>
)
}
return (
<div className="flex justify-center py-8">
<Card className="w-full max-w-lg">
<CardHeader className="space-y-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl">Editar Medio</CardTitle>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
Volver
</Button>
</div>
<CardDescription>
Editá los datos del medio <strong>{medio.nombre}</strong>.
</CardDescription>
</CardHeader>
<CardContent>
<MedioForm
initialData={medio}
isPending={isPending}
error={error}
onSubmit={handleSubmit}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,97 @@
import { useNavigate, useParams } from 'react-router-dom'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CanPerform } from '@/components/auth/CanPerform'
import { useMedio } from '../hooks/useMedio'
import { DeactivateMedioModal } from '../components/DeactivateMedioModal'
import { tipoMedioLabel } from '../tipoMedio'
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function MedioDetailPage() {
const { id } = useParams<{ id: string }>()
const medioId = Number(id)
const navigate = useNavigate()
const { data: medio, isLoading } = useMedio(medioId)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<span className="text-muted-foreground">Cargando...</span>
</div>
)
}
if (!medio) {
return (
<div className="py-12 text-center text-muted-foreground">
Medio no encontrado.
</div>
)
}
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{medio.nombre}</h1>
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
Volver
</Button>
</div>
<div className="rounded-md border border-border p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Código</span>
<span className="font-mono">{medio.codigo}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Tipo</span>
<Badge variant="secondary">{tipoMedioLabel(medio.tipo)}</Badge>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Plataforma ID</span>
<span>{medio.plataformaEmpresaId ?? '—'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Estado</span>
{medio.activo
? <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Activo</Badge>
: <Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Inactivo</Badge>
}
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Creado</span>
<span>{formatDate(medio.fechaCreacion)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Modificado</span>
<span>{formatDate(medio.fechaModificacion)}</span>
</div>
</div>
<CanPerform permission="administracion:medios:gestionar">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => navigate(`/admin/medios/${medioId}/edit`)}
>
Editar
</Button>
<DeactivateMedioModal
medioId={medioId}
medioNombre={medio.nombre}
activo={medio.activo}
/>
</div>
</CanPerform>
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { CanPerform } from '@/components/auth/CanPerform'
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MediosTable } from '../components/MediosTable'
import { useMediosList } from '../hooks/useMediosList'
import { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
export function MediosListPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const [tipo, setTipo] = useState<number | undefined>(undefined)
const [activo, setActivo] = useState<boolean | undefined>(undefined)
const [searchRaw, setSearchRaw] = useState('')
const q = useDebouncedValue(searchRaw, 300)
const query = {
page,
pageSize: 20,
...(tipo !== undefined ? { tipo } : {}),
...(activo !== undefined ? { activo } : {}),
...(q ? { q } : {}),
}
const { data, isLoading } = useMediosList(query)
const handleTipoChange = useCallback((value: string) => {
setTipo(value === '' ? undefined : Number(value))
setPage(1)
}, [])
const handleActivoChange = useCallback((value: string) => {
if (value === '') setActivo(undefined)
else setActivo(value === 'true')
setPage(1)
}, [])
const handleSearchChange = useCallback((value: string) => {
setSearchRaw(value)
setPage(1)
}, [])
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
const hasPrev = page > 1
const hasNext = page < totalPages
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Medios</h1>
<CanPerform permission="administracion:medios:gestionar">
<Button onClick={() => navigate('/admin/medios/nuevo')} size="sm">
Nuevo medio
</Button>
</CanPerform>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<Input
type="text"
placeholder="Buscar por código, nombre..."
value={searchRaw}
onChange={(e) => handleSearchChange(e.target.value)}
className="max-w-xs"
aria-label="Buscar medios"
/>
<Select
value={tipo !== undefined ? String(tipo) : '__all__'}
onValueChange={(v) => handleTipoChange(v === '__all__' ? '' : v)}
>
<SelectTrigger className="h-9 w-40" aria-label="Tipo">
<SelectValue placeholder="Todos los tipos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos los tipos</SelectItem>
{TIPO_MEDIO_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={activo !== undefined ? String(activo) : '__all__'}
onValueChange={(v) => handleActivoChange(v === '__all__' ? '' : v)}
>
<SelectTrigger className="h-9 w-32" aria-label="Estado">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos</SelectItem>
<SelectItem value="true">Activos</SelectItem>
<SelectItem value="false">Inactivos</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</div>
) : (
<MediosTable rows={data?.items ?? []} />
)}
{/* Pagination */}
<div className="flex items-center justify-between pt-2">
<span className="text-sm text-muted-foreground">
{data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
aria-label="Anterior"
>
Anterior
</Button>
<span className="flex items-center px-2 text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
aria-label="Siguiente"
>
Siguiente
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,18 @@
// TipoMedio enum helper: int on wire → display label
export const TIPO_MEDIO_LABELS: Record<number, string> = {
1: 'Diario',
2: 'Radio',
3: 'Web',
4: 'Poster',
}
export const TIPO_MEDIO_OPTIONS = [
{ value: 1, label: 'Diario' },
{ value: 2, label: 'Radio' },
{ value: 3, label: 'Web' },
{ value: 4, label: 'Poster' },
]
export function tipoMedioLabel(tipo: number): string {
return TIPO_MEDIO_LABELS[tipo] ?? String(tipo)
}

View File

@@ -0,0 +1,58 @@
// ADM-001 — shared types for medios feature
export interface MedioListItem {
id: number
codigo: string
nombre: string
tipo: number
plataformaEmpresaId: number | null
activo: boolean
}
export interface MedioDetail {
id: number
codigo: string
nombre: string
tipo: number
plataformaEmpresaId: number | null
activo: boolean
fechaCreacion: string
fechaModificacion: string | null
}
export interface MedioCreated {
id: number
codigo: string
nombre: string
tipo: number
plataformaEmpresaId: number | null
activo: boolean
}
export interface CreateMedioRequest {
codigo: string
nombre: string
tipo: number
plataformaEmpresaId?: number | null
}
export interface UpdateMedioRequest {
nombre: string
tipo: number
plataformaEmpresaId?: number | null
}
export interface MediosQuery {
page?: number
pageSize?: number
activo?: boolean
tipo?: number
q?: string
}
export interface PagedResult<T> {
items: T[]
page: number
pageSize: number
total: number
}

View File

@@ -6,6 +6,13 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useRoles } from '../../roles/hooks/useRoles'
import { RolPermisosEditor } from '../components/RolPermisosEditor'
@@ -28,28 +35,28 @@ export function RolPermisosPage() {
<CardContent className="space-y-6">
{/* Selector de rol */}
<div className="flex flex-col gap-1 max-w-xs">
<label
htmlFor="rol-selector"
className="text-sm font-medium text-foreground"
>
<label className="text-sm font-medium text-foreground">
Rol
</label>
{loadingRoles ? (
<p className="text-sm text-muted-foreground">Cargando roles...</p>
) : (
<select
id="rol-selector"
value={selectedRol ?? ''}
onChange={(e) => setSelectedRol(e.target.value || null)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
<Select
value={selectedRol ?? '__none__'}
onValueChange={(v) => setSelectedRol(v === '__none__' ? null : v)}
>
<option value=""> Seleccioná un rol </option>
<SelectTrigger className="w-full" aria-label="Rol">
<SelectValue placeholder="— Seleccioná un rol —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Seleccioná un rol </SelectItem>
{rolesActivos.map((r) => (
<option key={r.codigo} value={r.codigo}>
<SelectItem key={r.codigo} value={r.codigo}>
{r.nombre} ({r.codigo})
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
)}
</div>

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { CreateSeccionRequest, SeccionCreated } from '../types'
export async function createSeccion(payload: CreateSeccionRequest): Promise<SeccionCreated> {
const response = await axiosClient.post<SeccionCreated>('/api/v1/admin/secciones', payload)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function deactivateSeccion(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`)
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { SeccionDetail } from '../types'
export async function getSeccion(id: number): Promise<SeccionDetail> {
const response = await axiosClient.get<SeccionDetail>(`/api/v1/admin/secciones/${id}`)
return response.data
}

View File

@@ -0,0 +1,18 @@
import { axiosClient } from '@/api/axiosClient'
import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types'
export async function listSecciones(query: SeccionesQuery): Promise<PagedResult<SeccionListItem>> {
const params = new URLSearchParams()
if (query.page !== undefined) params.set('page', String(query.page))
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
if (query.medioId !== undefined) params.set('medioId', String(query.medioId))
if (query.tipo !== undefined) params.set('tipo', query.tipo)
if (query.activo !== undefined) params.set('activo', String(query.activo))
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
const response = await axiosClient.get<PagedResult<SeccionListItem>>(
'/api/v1/admin/secciones',
{ params },
)
return response.data
}

View File

@@ -0,0 +1,5 @@
import { axiosClient } from '@/api/axiosClient'
export async function reactivateSeccion(id: number): Promise<void> {
await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`)
}

View File

@@ -0,0 +1,7 @@
import { axiosClient } from '@/api/axiosClient'
import type { SeccionDetail, UpdateSeccionRequest } from '../types'
export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise<SeccionDetail> {
const response = await axiosClient.put<SeccionDetail>(`/api/v1/admin/secciones/${id}`, payload)
return response.data
}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useDeactivateSeccion } from '../hooks/useDeactivateSeccion'
import { useReactivateSeccion } from '../hooks/useReactivateSeccion'
interface DeactivateSeccionModalProps {
seccionId: number
seccionNombre: string
activo: boolean
}
export function DeactivateSeccionModal({ seccionId, seccionNombre, activo }: DeactivateSeccionModalProps) {
const [open, setOpen] = useState(false)
const { mutate: deactivate, isPending: deactivating } = useDeactivateSeccion()
const { mutate: reactivate, isPending: reactivating } = useReactivateSeccion()
const isPending = deactivating || reactivating
function handleConfirm() {
if (activo) {
deactivate(seccionId, { onSuccess: () => setOpen(false) })
} else {
reactivate(seccionId, { onSuccess: () => setOpen(false) })
}
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
{activo ? 'Desactivar' : 'Reactivar'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{activo ? 'Desactivar sección' : 'Reactivar sección'}
</AlertDialogTitle>
<AlertDialogDescription>
{activo
? `¿Confirmás que querés desactivar la sección "${seccionNombre}"?`
: `¿Confirmás que querés reactivar la sección "${seccionNombre}"?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,206 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMediosList } from '@/features/medios/hooks/useMediosList'
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
import type { SeccionDetail, TipoSeccion } from '../types'
const TIPO_SECCION_VALUES = ['clasificados', 'notables', 'suplementos'] as const
const seccionFormSchema = z.object({
medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'),
codigo: z
.string()
.min(1, 'El código es requerido')
.max(20, 'Máximo 20 caracteres'),
nombre: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'Máximo 100 caracteres'),
tipo: z.enum(TIPO_SECCION_VALUES, { errorMap: () => ({ message: 'Seleccioná un tipo válido' }) }),
})
export type SeccionFormValues = z.infer<typeof seccionFormSchema>
interface SeccionFormProps {
initialData?: SeccionDetail
isPending: boolean
error: unknown
onSubmit: (values: SeccionFormValues) => void
}
function resolveBackendError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
if (data.error === 'seccion_codigo_duplicado_en_medio') {
return data.message ?? 'Ya existe una sección con ese código en el medio'
}
return data.message ?? data.error ?? 'Error al guardar la sección'
}
return 'Error al guardar la sección'
}
export function SeccionForm({ initialData, isPending, error, onSubmit }: SeccionFormProps) {
const isEdit = !!initialData
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 })
const medios = mediosData?.items ?? []
const form = useForm<SeccionFormValues>({
resolver: zodResolver(seccionFormSchema),
defaultValues: {
medioId: initialData?.medioId ?? ('' as unknown as number),
codigo: initialData?.codigo ?? '',
nombre: initialData?.nombre ?? '',
tipo: initialData?.tipo ?? ('' as TipoSeccion),
},
})
useEffect(() => {
if (initialData) {
form.reset({
medioId: initialData.medioId,
codigo: initialData.codigo,
nombre: initialData.nombre,
tipo: initialData.tipo,
})
}
}, [initialData, form])
const backendError = resolveBackendError(error)
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="medioId"
render={({ field }) => (
<FormItem>
<FormLabel>Medio</FormLabel>
<Select
value={field.value ? String(field.value) : ''}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isPending || isEdit}
>
<FormControl>
<SelectTrigger className="w-full" aria-label="Medio">
<SelectValue placeholder="Seleccioná un medio" />
</SelectTrigger>
</FormControl>
<SelectContent>
{medios.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="codigo"
render={({ field }) => (
<FormItem>
<FormLabel>Código</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending || isEdit}
placeholder="Ej: CLASIF01"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nombre"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre</FormLabel>
<FormControl>
<Input
{...field}
type="text"
disabled={isPending}
placeholder="Nombre de la sección"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tipo"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo</FormLabel>
<Select
value={field.value ?? ''}
onValueChange={(v) => field.onChange(v as TipoSeccion)}
disabled={isPending}
>
<FormControl>
<SelectTrigger className="w-full" aria-label="Tipo de sección">
<SelectValue placeholder="Seleccioná un tipo" />
</SelectTrigger>
</FormControl>
<SelectContent>
{TIPO_SECCION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear sección'}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMediosList } from '@/features/medios/hooks/useMediosList'
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
import type { TipoSeccion } from '../types'
interface SeccionesFiltersProps {
onMedioIdChange: (medioId: number | undefined) => void
onTipoChange: (tipo: TipoSeccion | undefined) => void
onActivoChange: (activo: boolean | undefined) => void
onSearchChange: (q: string) => void
}
export function SeccionesFilters({
onMedioIdChange,
onTipoChange,
onActivoChange,
onSearchChange,
}: SeccionesFiltersProps) {
const [searchRaw, setSearchRaw] = useState('')
const debouncedSearch = useDebouncedValue(searchRaw, 300)
useEffect(() => {
onSearchChange(debouncedSearch)
}, [debouncedSearch, onSearchChange])
// Load medios for the selector
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true })
const medios = mediosData?.items ?? []
return (
<div className="flex flex-wrap gap-3 items-center mb-4">
<Input
type="text"
placeholder="Buscar por código, nombre..."
value={searchRaw}
onChange={(e) => setSearchRaw(e.target.value)}
className="max-w-xs"
aria-label="Buscar secciones"
/>
<Select
defaultValue="__all__"
onValueChange={(v) => onMedioIdChange(v === '__all__' ? undefined : Number(v))}
>
<SelectTrigger className="h-9 w-44" aria-label="Medio">
<SelectValue placeholder="Todos los medios" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos los medios</SelectItem>
{medios.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
defaultValue="__all__"
onValueChange={(v) => onTipoChange(v === '__all__' ? undefined : (v as TipoSeccion))}
>
<SelectTrigger className="h-9 w-44" aria-label="Tipo de sección">
<SelectValue placeholder="Todos los tipos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos los tipos</SelectItem>
{TIPO_SECCION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
defaultValue="__all__"
onValueChange={(v) => {
if (v === '__all__') onActivoChange(undefined)
else onActivoChange(v === 'true')
}}
>
<SelectTrigger className="h-9 w-32" aria-label="Estado">
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Todos</SelectItem>
<SelectItem value="true">Activos</SelectItem>
<SelectItem value="false">Inactivos</SelectItem>
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import type { ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DataTable } from '@/components/ui/data-table'
import { CanPerform } from '@/components/auth/CanPerform'
import type { SeccionListItem } from '../types'
import { tipoSeccionLabel } from '../tipoSeccion'
import { DeactivateSeccionModal } from './DeactivateSeccionModal'
interface SeccionesTableProps {
rows: SeccionListItem[]
}
export function SeccionesTable({ rows }: SeccionesTableProps) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<SeccionListItem>[]>(
() => [
{
accessorKey: 'codigo',
header: 'Código',
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.codigo}</span>
),
meta: { priority: 'high' },
},
{
accessorKey: 'nombre',
header: 'Nombre',
meta: { priority: 'high' },
},
{
accessorKey: 'tipo',
header: 'Tipo',
cell: ({ row }) => (
<Badge variant="secondary" className="capitalize">
{tipoSeccionLabel(row.original.tipo)}
</Badge>
),
meta: { priority: 'high' },
},
{
accessorKey: 'medioId',
header: 'Medio ID',
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.medioId}</span>
),
meta: { priority: 'medium' },
},
{
accessorKey: 'activo',
header: 'Estado',
cell: ({ row }) =>
row.original.activo ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Activo
</Badge>
) : (
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Inactivo
</Badge>
),
meta: { priority: 'medium' },
},
{
id: 'acciones',
header: 'Acciones',
cell: ({ row }) => (
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<CanPerform permission="administracion:secciones:gestionar">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/secciones/${row.original.id}/edit`)}
>
Editar
</Button>
<DeactivateSeccionModal
seccionId={row.original.id}
seccionNombre={row.original.nombre}
activo={row.original.activo}
/>
</CanPerform>
</div>
),
meta: { priority: 'high' },
},
],
[navigate],
)
return (
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => navigate(`/admin/secciones/${row.id}`)}
getRowId={(row) => String(row.id)}
emptyMessage="Sin resultados — no se encontraron secciones con los filtros seleccionados."
/>
)
}

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createSeccion } from '../api/createSeccion'
import type { CreateSeccionRequest } from '../types'
export function useCreateSeccion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (payload: CreateSeccionRequest) => createSeccion(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['secciones'] })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { deactivateSeccion } from '../api/deactivateSeccion'
export function useDeactivateSeccion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deactivateSeccion(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['secciones'] })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { reactivateSeccion } from '../api/reactivateSeccion'
export function useReactivateSeccion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => reactivateSeccion(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['secciones'] })
},
})
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getSeccion } from '../api/getSeccion'
export function useSeccion(id: number) {
return useQuery({
queryKey: ['secciones', 'detail', id],
queryFn: () => getSeccion(id),
enabled: !!id,
staleTime: 15_000,
})
}

View File

@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query'
import { listSecciones } from '../api/listSecciones'
import type { SeccionesQuery } from '../types'
export const seccionesListQueryKey = (query: SeccionesQuery) => ['secciones', 'list', query] as const
export function useSeccionesList(query: SeccionesQuery) {
return useQuery({
queryKey: seccionesListQueryKey(query),
queryFn: () => listSecciones(query),
staleTime: 15_000,
})
}

Some files were not shown because too many files have changed in this diff Show More