diff --git a/database/README.md b/database/README.md index c8d18a7..2b3f549 100644 --- a/database/README.md +++ b/database/README.md @@ -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` diff --git a/database/migrations/V011_ROLLBACK.sql b/database/migrations/V011_ROLLBACK.sql new file mode 100644 index 0000000..db381d2 --- /dev/null +++ b/database/migrations/V011_ROLLBACK.sql @@ -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 diff --git a/database/migrations/V011__create_medio_seccion.sql b/database/migrations/V011__create_medio_seccion.sql new file mode 100644 index 0000000..dfbb8c7 --- /dev/null +++ b/database/migrations/V011__create_medio_seccion.sql @@ -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 diff --git a/database/migrations/V012_ROLLBACK.sql b/database/migrations/V012_ROLLBACK.sql new file mode 100644 index 0000000..658caf8 --- /dev/null +++ b/database/migrations/V012_ROLLBACK.sql @@ -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 diff --git a/database/migrations/V012__seed_medios.sql b/database/migrations/V012__seed_medios.sql new file mode 100644 index 0000000..28a2e6b --- /dev/null +++ b/database/migrations/V012__seed_medios.sql @@ -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 diff --git a/src/api/SIGCM2.Api/Controllers/MediosController.cs b/src/api/SIGCM2.Api/Controllers/MediosController.cs new file mode 100644 index 0000000..9ebf5f1 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/MediosController.cs @@ -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; + +/// +/// ADM-001: Medio management endpoints at /api/v1/admin/medios. +/// All endpoints require permission 'administracion:medios:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/medios")] +public sealed class MediosController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public MediosController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new medio. Requires administracion:medios:gestionar. + [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 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(command); + return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result); + } + + /// Lists medios with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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>(query); + return Ok(result); + } + + /// Gets a single medio by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMedioById([FromRoute] int id) + { + var query = new GetMedioByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a medio's editable fields. + [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 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(command); + return Ok(result); + } + + /// Deactivates a medio (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateMedio([FromRoute] int id) + { + var command = new DeactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a medio (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:medios:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateMedio([FromRoute] int id) + { + var command = new ReactivateMedioCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create medio request body. +public sealed record CreateMedioRequest( + string? Codigo, + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); + +/// ADM-001: Update medio request body. +public sealed record UpdateMedioRequest( + string? Nombre, + TipoMedio? Tipo, + int? PlataformaEmpresaId); diff --git a/src/api/SIGCM2.Api/Controllers/SeccionesController.cs b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs new file mode 100644 index 0000000..9ff6653 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/SeccionesController.cs @@ -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; + +/// +/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones. +/// All endpoints require permission 'administracion:secciones:gestionar'. +/// +[ApiController] +[Route("api/v1/admin/secciones")] +public sealed class SeccionesController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _createValidator; + private readonly IValidator _updateValidator; + + public SeccionesController( + IDispatcher dispatcher, + IValidator createValidator, + IValidator updateValidator) + { + _dispatcher = dispatcher; + _createValidator = createValidator; + _updateValidator = updateValidator; + } + + /// Creates a new seccion. Requires administracion:secciones:gestionar. + [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 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(command); + return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result); + } + + /// Lists secciones with optional filters and pagination. + [HttpGet] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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>(query); + return Ok(result); + } + + /// Gets a single seccion by id. + [HttpGet("{id:int}")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSeccionById([FromRoute] int id) + { + var query = new GetSeccionByIdQuery(id); + var result = await _dispatcher.Send(query); + return Ok(result); + } + + /// Updates a seccion's editable fields. + [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 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(command); + return Ok(result); + } + + /// Deactivates a seccion (idempotent). + [HttpPost("{id:int}/deactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeactivateSeccion([FromRoute] int id) + { + var command = new DeactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } + + /// Reactivates a seccion (idempotent). + [HttpPost("{id:int}/reactivate")] + [RequirePermission("administracion:secciones:gestionar")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ReactivateSeccion([FromRoute] int id) + { + var command = new ReactivateSeccionCommand(id); + await _dispatcher.Send(command); + return NoContent(); + } +} + +// ── Request body records ────────────────────────────────────────────────────── + +/// ADM-001: Create seccion request body. +public sealed record CreateSeccionRequest( + int? MedioId, + string? Codigo, + string? Nombre, + string? Tipo); + +/// ADM-001: Update seccion request body. +public sealed record UpdateSeccionRequest( + string? Nombre, + string? Tipo); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f566bf3..77e481e 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -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 diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs new file mode 100644 index 0000000..e641117 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IMedioRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface IMedioRepository +{ + Task AddAsync(Medio m, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoAsync(string codigo, CancellationToken ct = default); + Task UpdateAsync(Medio m, CancellationToken ct = default); + Task> GetPagedAsync(MediosQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs new file mode 100644 index 0000000..6264d1e --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/ISeccionRepository.cs @@ -0,0 +1,13 @@ +using SIGCM2.Application.Common; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Abstractions.Persistence; + +public interface ISeccionRepository +{ + Task AddAsync(Seccion s, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default); + Task UpdateAsync(Seccion s, CancellationToken ct = default); + Task> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Common/MediosQuery.cs b/src/api/SIGCM2.Application/Common/MediosQuery.cs new file mode 100644 index 0000000..c5f215f --- /dev/null +++ b/src/api/SIGCM2.Application/Common/MediosQuery.cs @@ -0,0 +1,12 @@ +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Common; + +/// Query parameters for listing medios with optional filters and paging. +public sealed record MediosQuery( + int Page, + int PageSize, + bool? Activo, + TipoMedio? Tipo, + string? Search +); diff --git a/src/api/SIGCM2.Application/Common/SeccionesQuery.cs b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs new file mode 100644 index 0000000..35ce39b --- /dev/null +++ b/src/api/SIGCM2.Application/Common/SeccionesQuery.cs @@ -0,0 +1,11 @@ +namespace SIGCM2.Application.Common; + +/// Query parameters for listing secciones with optional filters and paging. +public sealed record SeccionesQuery( + int Page, + int PageSize, + int? MedioId, + string? Tipo, + bool? Activo, + string? Search +); diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index d71d9c2..fdf48d8 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -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, GetUsuarioPermisosQueryHandler>(); services.AddScoped, UpdateUsuarioPermisosOverridesCommandHandler>(); + // Medios (ADM-001) + services.AddScoped, CreateMedioCommandHandler>(); + services.AddScoped, UpdateMedioCommandHandler>(); + services.AddScoped, DeactivateMedioCommandHandler>(); + services.AddScoped, ReactivateMedioCommandHandler>(); + services.AddScoped>, ListMediosQueryHandler>(); + services.AddScoped, GetMedioByIdQueryHandler>(); + + // Secciones (ADM-001) + services.AddScoped, CreateSeccionCommandHandler>(); + services.AddScoped, UpdateSeccionCommandHandler>(); + services.AddScoped, DeactivateSeccionCommandHandler>(); + services.AddScoped, ReactivateSeccionCommandHandler>(); + services.AddScoped>, ListSeccionesQueryHandler>(); + services.AddScoped, GetSeccionByIdQueryHandler>(); + // FluentValidation validators (scans entire Application assembly) services.AddValidatorsFromAssemblyContaining(); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs new file mode 100644 index 0000000..4ca06ac --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs new file mode 100644 index 0000000..48caf74 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandHandler.cs @@ -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 +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs new file mode 100644 index 0000000..e8de21b --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/CreateMedioCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Medios.Create; + +public sealed class CreateMedioCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs new file mode 100644 index 0000000..e868a72 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs new file mode 100644 index 0000000..d932353 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record DeactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs new file mode 100644 index 0000000..7b87564 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/DeactivateMedioCommandHandler.cs @@ -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 +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs new file mode 100644 index 0000000..84d3044 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Deactivate/MedioStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Deactivate; + +public sealed record MedioStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs new file mode 100644 index 0000000..4ca1e0e --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.GetById; + +public sealed record GetMedioByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs new file mode 100644 index 0000000..486aa50 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/GetMedioByIdQueryHandler.cs @@ -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 +{ + private readonly IMedioRepository _repo; + + public GetMedioByIdQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs new file mode 100644 index 0000000..ff2b01f --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs new file mode 100644 index 0000000..08ce6a8 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs new file mode 100644 index 0000000..ac815c5 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/ListMediosQueryHandler.cs @@ -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> +{ + private readonly IMedioRepository _repo; + + public ListMediosQueryHandler(IMedioRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs new file mode 100644 index 0000000..cd26052 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs new file mode 100644 index 0000000..ef1c9aa --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Medios.Reactivate; + +public sealed record ReactivateMedioCommand(int Id); diff --git a/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs new file mode 100644 index 0000000..d8447f9 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Reactivate/ReactivateMedioCommandHandler.cs @@ -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 +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs new file mode 100644 index 0000000..c497a07 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs new file mode 100644 index 0000000..b676188 --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommand.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs new file mode 100644 index 0000000..51e58fd --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandHandler.cs @@ -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 +{ + private readonly IMedioRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs new file mode 100644 index 0000000..a503fed --- /dev/null +++ b/src/api/SIGCM2.Application/Medios/Update/UpdateMedioCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace SIGCM2.Application.Medios.Update; + +public sealed class UpdateMedioCommandValidator : AbstractValidator +{ + 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."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs new file mode 100644 index 0000000..f4ce080 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommand.cs @@ -0,0 +1,7 @@ +namespace SIGCM2.Application.Secciones.Create; + +public sealed record CreateSeccionCommand( + int MedioId, + string Codigo, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs new file mode 100644 index 0000000..e8cb8e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandHandler.cs @@ -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 +{ + 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 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); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs new file mode 100644 index 0000000..db062e7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/CreateSeccionCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Create; + +public sealed class CreateSeccionCommandValidator : AbstractValidator +{ + 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)}."); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs new file mode 100644 index 0000000..ce35275 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Create/SeccionCreatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs new file mode 100644 index 0000000..8d052c0 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record DeactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..c77c9d9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/DeactivateSeccionCommandHandler.cs @@ -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 +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs new file mode 100644 index 0000000..471db39 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Deactivate/SeccionStatusDto.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Deactivate; + +public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs new file mode 100644 index 0000000..05ceea7 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQuery.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.GetById; + +public sealed record GetSeccionByIdQuery(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs new file mode 100644 index 0000000..53017db --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/GetSeccionByIdQueryHandler.cs @@ -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 +{ + private readonly ISeccionRepository _repo; + + public GetSeccionByIdQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs new file mode 100644 index 0000000..2362ab9 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/GetById/SeccionDetailDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs new file mode 100644 index 0000000..92f7d7e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQuery.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs new file mode 100644 index 0000000..e894157 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/ListSeccionesQueryHandler.cs @@ -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> +{ + private readonly ISeccionRepository _repo; + + public ListSeccionesQueryHandler(ISeccionRepository repo) + { + _repo = repo; + } + + public async Task> 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(items, paged.Page, paged.PageSize, paged.Total); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs new file mode 100644 index 0000000..9e8569d --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/List/SeccionListItemDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs new file mode 100644 index 0000000..0bca214 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommand.cs @@ -0,0 +1,3 @@ +namespace SIGCM2.Application.Secciones.Reactivate; + +public sealed record ReactivateSeccionCommand(int Id); diff --git a/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs new file mode 100644 index 0000000..9d1dd5e --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Reactivate/ReactivateSeccionCommandHandler.cs @@ -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 +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs new file mode 100644 index 0000000..4a4b25a --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/SeccionUpdatedDto.cs @@ -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); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs new file mode 100644 index 0000000..85fbd74 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommand.cs @@ -0,0 +1,6 @@ +namespace SIGCM2.Application.Secciones.Update; + +public sealed record UpdateSeccionCommand( + int Id, + string Nombre, + string Tipo); diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs new file mode 100644 index 0000000..ca89608 --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandHandler.cs @@ -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 +{ + private readonly ISeccionRepository _repo; + private readonly IAuditLogger _audit; + + public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit) + { + _repo = repo; + _audit = audit; + } + + public async Task 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); + } +} diff --git a/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs new file mode 100644 index 0000000..a101c7b --- /dev/null +++ b/src/api/SIGCM2.Application/Secciones/Update/UpdateSeccionCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using SIGCM2.Domain.Entities; + +namespace SIGCM2.Application.Secciones.Update; + +public sealed class UpdateSeccionCommandValidator : AbstractValidator +{ + 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)}."); + } +} diff --git a/src/api/SIGCM2.Domain/Entities/Medio.cs b/src/api/SIGCM2.Domain/Entities/Medio.cs new file mode 100644 index 0000000..6c7df79 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Medio.cs @@ -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; + } + + /// + /// Factory for creating a new Medio (Id=0 β€” DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default). + /// + 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); + } + + /// + /// Returns a new instance with updated fields. Codigo is immutable (use BD UQ to enforce). + /// Sets FechaModificacion = UtcNow. + /// + 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); +} diff --git a/src/api/SIGCM2.Domain/Entities/Seccion.cs b/src/api/SIGCM2.Domain/Entities/Seccion.cs new file mode 100644 index 0000000..f7d3e2f --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/Seccion.cs @@ -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); + } + + /// + /// Returns a new instance with updated fields. MedioId and Codigo are immutable. + /// Sets FechaModificacion = UtcNow. + /// + 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); +} diff --git a/src/api/SIGCM2.Domain/Entities/TipoMedio.cs b/src/api/SIGCM2.Domain/Entities/TipoMedio.cs new file mode 100644 index 0000000..df8ef89 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoMedio.cs @@ -0,0 +1,9 @@ +namespace SIGCM2.Domain.Entities; + +public enum TipoMedio +{ + Diario = 1, + Radio = 2, + Web = 3, + Poster = 4, +} diff --git a/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs new file mode 100644 index 0000000..de77036 --- /dev/null +++ b/src/api/SIGCM2.Domain/Entities/TipoSeccion.cs @@ -0,0 +1,10 @@ +namespace SIGCM2.Domain.Entities; + +/// +/// Allowed string values for Seccion.Tipo. +/// Enforced at application layer (validator) and database layer (CHECK constraint). +/// +public static class TipoSeccion +{ + public static readonly string[] AllowedTipos = { "clasificados", "notables", "suplementos" }; +} diff --git a/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs b/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs new file mode 100644 index 0000000..0568d49 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/MedioCodigoDuplicadoException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create a Medio with a Codigo that already exists (global UQ). +/// +public sealed class MedioCodigoDuplicadoException : DomainException +{ + public string Codigo { get; } + + public MedioCodigoDuplicadoException(string codigo) + : base($"El medio con cΓ³digo '{codigo}' ya existe.") + { + Codigo = codigo; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs new file mode 100644 index 0000000..3a5bf6f --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested Medio does not exist in the system. +/// +public sealed class MedioNotFoundException : DomainException +{ + public int Id { get; } + + public MedioNotFoundException(int id) + : base($"El medio con id '{id}' no existe.") + { + Id = id; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs b/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs new file mode 100644 index 0000000..79e6aef --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/SeccionCodigoDuplicadoEnMedioException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create a Seccion with a Codigo that already exists within the same Medio +/// (composite UQ on MedioId + Codigo). +/// +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; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs b/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs new file mode 100644 index 0000000..faf72d6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs @@ -0,0 +1,15 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when a requested Seccion does not exist in the system. +/// +public sealed class SeccionNotFoundException : DomainException +{ + public int Id { get; } + + public SeccionNotFoundException(int id) + : base($"La secciΓ³n con id '{id}' no existe.") + { + Id = id; + } +} diff --git a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs index e9fe1b3..7887203 100644 --- a/src/api/SIGCM2.Infrastructure/DependencyInjection.cs +++ b/src/api/SIGCM2.Infrastructure/DependencyInjection.cs @@ -32,6 +32,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // JWT Options β€” bound lazily via IOptions so tests can override via ConfigureWebHost services.Configure(configuration.GetSection("Jwt")); diff --git a/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs new file mode 100644 index 0000000..99dc1ca --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs @@ -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 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(sql, new + { + m.Codigo, + m.Nombre, + Tipo = (int)m.Tipo, + m.PlataformaEmpresaId, + }); + } + + public async Task 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(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task 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(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> 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(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(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); +} diff --git a/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs new file mode 100644 index 0000000..6ec75eb --- /dev/null +++ b/src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs @@ -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 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(sql, new + { + s.MedioId, + s.Codigo, + s.Nombre, + s.Tipo, + }); + } + + public async Task 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(sql, new { Id = id }); + return row is null ? null : MapRow(row); + } + + public async Task 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(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> 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(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(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); +} diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 7263153..5516319 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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 { diff --git a/src/web/src/features/medios/api/createMedio.ts b/src/web/src/features/medios/api/createMedio.ts new file mode 100644 index 0000000..9809f3b --- /dev/null +++ b/src/web/src/features/medios/api/createMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateMedioRequest, MedioCreated } from '../types' + +export async function createMedio(payload: CreateMedioRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/medios', payload) + return response.data +} diff --git a/src/web/src/features/medios/api/deactivateMedio.ts b/src/web/src/features/medios/api/deactivateMedio.ts new file mode 100644 index 0000000..d893ce8 --- /dev/null +++ b/src/web/src/features/medios/api/deactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`) +} diff --git a/src/web/src/features/medios/api/getMedio.ts b/src/web/src/features/medios/api/getMedio.ts new file mode 100644 index 0000000..a0c10a6 --- /dev/null +++ b/src/web/src/features/medios/api/getMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail } from '../types' + +export async function getMedio(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/medios/${id}`) + return response.data +} diff --git a/src/web/src/features/medios/api/listMedios.ts b/src/web/src/features/medios/api/listMedios.ts new file mode 100644 index 0000000..75df9a9 --- /dev/null +++ b/src/web/src/features/medios/api/listMedios.ts @@ -0,0 +1,17 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioListItem, MediosQuery, PagedResult } from '../types' + +export async function listMedios(query: MediosQuery): Promise> { + 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>( + '/api/v1/admin/medios', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/medios/api/reactivateMedio.ts b/src/web/src/features/medios/api/reactivateMedio.ts new file mode 100644 index 0000000..ba485f4 --- /dev/null +++ b/src/web/src/features/medios/api/reactivateMedio.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateMedio(id: number): Promise { + await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`) +} diff --git a/src/web/src/features/medios/api/updateMedio.ts b/src/web/src/features/medios/api/updateMedio.ts new file mode 100644 index 0000000..fcedcfe --- /dev/null +++ b/src/web/src/features/medios/api/updateMedio.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { MedioDetail, UpdateMedioRequest } from '../types' + +export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/medios/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/medios/components/DeactivateMedioModal.tsx b/src/web/src/features/medios/components/DeactivateMedioModal.tsx new file mode 100644 index 0000000..c4faa4d --- /dev/null +++ b/src/web/src/features/medios/components/DeactivateMedioModal.tsx @@ -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 ( + + + + + + + + {activo ? 'Desactivar medio' : 'Reactivar medio'} + + + {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}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/medios/components/MedioForm.tsx b/src/web/src/features/medios/components/MedioForm.tsx new file mode 100644 index 0000000..1c87169 --- /dev/null +++ b/src/web/src/features/medios/components/MedioForm.tsx @@ -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 + +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({ + 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 ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + CΓ³digo + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + )} + /> + + ( + + Plataforma Empresa ID (opcional) + + field.onChange(e.target.value)} + type="number" + min={1} + disabled={isPending} + placeholder="ID numΓ©rico (opcional)" + /> + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/medios/components/MediosTable.tsx b/src/web/src/features/medios/components/MediosTable.tsx new file mode 100644 index 0000000..10113e8 --- /dev/null +++ b/src/web/src/features/medios/components/MediosTable.tsx @@ -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[]>( + () => [ + { + accessorKey: 'codigo', + header: 'CΓ³digo', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + {tipoMedioLabel(row.original.tipo)} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'plataformaEmpresaId', + header: 'Plataforma ID', + cell: ({ row }) => ( + + {row.original.plataformaEmpresaId ?? 'β€”'} + + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/medios/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados β€” no se encontraron medios con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/medios/hooks/useCreateMedio.ts b/src/web/src/features/medios/hooks/useCreateMedio.ts new file mode 100644 index 0000000..2549cfb --- /dev/null +++ b/src/web/src/features/medios/hooks/useCreateMedio.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useDeactivateMedio.ts b/src/web/src/features/medios/hooks/useDeactivateMedio.ts new file mode 100644 index 0000000..d93304b --- /dev/null +++ b/src/web/src/features/medios/hooks/useDeactivateMedio.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useMedio.ts b/src/web/src/features/medios/hooks/useMedio.ts new file mode 100644 index 0000000..bf005ee --- /dev/null +++ b/src/web/src/features/medios/hooks/useMedio.ts @@ -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, + }) +} diff --git a/src/web/src/features/medios/hooks/useMediosList.ts b/src/web/src/features/medios/hooks/useMediosList.ts new file mode 100644 index 0000000..e74cd75 --- /dev/null +++ b/src/web/src/features/medios/hooks/useMediosList.ts @@ -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, + }) +} diff --git a/src/web/src/features/medios/hooks/useReactivateMedio.ts b/src/web/src/features/medios/hooks/useReactivateMedio.ts new file mode 100644 index 0000000..15f1781 --- /dev/null +++ b/src/web/src/features/medios/hooks/useReactivateMedio.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/medios/hooks/useUpdateMedio.ts b/src/web/src/features/medios/hooks/useUpdateMedio.ts new file mode 100644 index 0000000..b9702e3 --- /dev/null +++ b/src/web/src/features/medios/hooks/useUpdateMedio.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/medios/pages/CreateMedioPage.tsx b/src/web/src/features/medios/pages/CreateMedioPage.tsx new file mode 100644 index 0000000..432d016 --- /dev/null +++ b/src/web/src/features/medios/pages/CreateMedioPage.tsx @@ -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 ( +
+ + + Crear Medio + + CompletΓ‘ los datos para registrar un nuevo medio en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/medios/pages/EditMedioPage.tsx b/src/web/src/features/medios/pages/EditMedioPage.tsx new file mode 100644 index 0000000..9c34969 --- /dev/null +++ b/src/web/src/features/medios/pages/EditMedioPage.tsx @@ -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 ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+ + +
+ Editar Medio + +
+ + EditΓ‘ los datos del medio {medio.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MedioDetailPage.tsx b/src/web/src/features/medios/pages/MedioDetailPage.tsx new file mode 100644 index 0000000..2992e68 --- /dev/null +++ b/src/web/src/features/medios/pages/MedioDetailPage.tsx @@ -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 ( +
+ Cargando... +
+ ) + } + + if (!medio) { + return ( +
+ Medio no encontrado. +
+ ) + } + + return ( +
+
+

{medio.nombre}

+ +
+ +
+
+ CΓ³digo + {medio.codigo} +
+
+ Tipo + {tipoMedioLabel(medio.tipo)} +
+
+ Plataforma ID + {medio.plataformaEmpresaId ?? 'β€”'} +
+
+ Estado + {medio.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(medio.fechaCreacion)} +
+
+ Modificado + {formatDate(medio.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/pages/MediosListPage.tsx b/src/web/src/features/medios/pages/MediosListPage.tsx new file mode 100644 index 0000000..842eba8 --- /dev/null +++ b/src/web/src/features/medios/pages/MediosListPage.tsx @@ -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(undefined) + const [activo, setActivo] = useState(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 ( +
+
+

Medios

+ + + +
+ + {/* Filters */} +
+ handleSearchChange(e.target.value)} + className="max-w-xs" + aria-label="Buscar medios" + /> + + + + +
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/medios/tipoMedio.ts b/src/web/src/features/medios/tipoMedio.ts new file mode 100644 index 0000000..5557342 --- /dev/null +++ b/src/web/src/features/medios/tipoMedio.ts @@ -0,0 +1,18 @@ +// TipoMedio enum helper: int on wire β†’ display label +export const TIPO_MEDIO_LABELS: Record = { + 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) +} diff --git a/src/web/src/features/medios/types.ts b/src/web/src/features/medios/types.ts new file mode 100644 index 0000000..a1370f8 --- /dev/null +++ b/src/web/src/features/medios/types.ts @@ -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 { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/features/permisos/pages/RolPermisosPage.tsx b/src/web/src/features/permisos/pages/RolPermisosPage.tsx index 7c79c8d..63fb3c1 100644 --- a/src/web/src/features/permisos/pages/RolPermisosPage.tsx +++ b/src/web/src/features/permisos/pages/RolPermisosPage.tsx @@ -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() { {/* Selector de rol */}
-
diff --git a/src/web/src/features/secciones/api/createSeccion.ts b/src/web/src/features/secciones/api/createSeccion.ts new file mode 100644 index 0000000..b00a269 --- /dev/null +++ b/src/web/src/features/secciones/api/createSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { CreateSeccionRequest, SeccionCreated } from '../types' + +export async function createSeccion(payload: CreateSeccionRequest): Promise { + const response = await axiosClient.post('/api/v1/admin/secciones', payload) + return response.data +} diff --git a/src/web/src/features/secciones/api/deactivateSeccion.ts b/src/web/src/features/secciones/api/deactivateSeccion.ts new file mode 100644 index 0000000..db675a8 --- /dev/null +++ b/src/web/src/features/secciones/api/deactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function deactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`) +} diff --git a/src/web/src/features/secciones/api/getSeccion.ts b/src/web/src/features/secciones/api/getSeccion.ts new file mode 100644 index 0000000..908cb79 --- /dev/null +++ b/src/web/src/features/secciones/api/getSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail } from '../types' + +export async function getSeccion(id: number): Promise { + const response = await axiosClient.get(`/api/v1/admin/secciones/${id}`) + return response.data +} diff --git a/src/web/src/features/secciones/api/listSecciones.ts b/src/web/src/features/secciones/api/listSecciones.ts new file mode 100644 index 0000000..26d7c56 --- /dev/null +++ b/src/web/src/features/secciones/api/listSecciones.ts @@ -0,0 +1,18 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types' + +export async function listSecciones(query: SeccionesQuery): Promise> { + 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>( + '/api/v1/admin/secciones', + { params }, + ) + return response.data +} diff --git a/src/web/src/features/secciones/api/reactivateSeccion.ts b/src/web/src/features/secciones/api/reactivateSeccion.ts new file mode 100644 index 0000000..5c5185d --- /dev/null +++ b/src/web/src/features/secciones/api/reactivateSeccion.ts @@ -0,0 +1,5 @@ +import { axiosClient } from '@/api/axiosClient' + +export async function reactivateSeccion(id: number): Promise { + await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`) +} diff --git a/src/web/src/features/secciones/api/updateSeccion.ts b/src/web/src/features/secciones/api/updateSeccion.ts new file mode 100644 index 0000000..f017207 --- /dev/null +++ b/src/web/src/features/secciones/api/updateSeccion.ts @@ -0,0 +1,7 @@ +import { axiosClient } from '@/api/axiosClient' +import type { SeccionDetail, UpdateSeccionRequest } from '../types' + +export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise { + const response = await axiosClient.put(`/api/v1/admin/secciones/${id}`, payload) + return response.data +} diff --git a/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx new file mode 100644 index 0000000..07d70cb --- /dev/null +++ b/src/web/src/features/secciones/components/DeactivateSeccionModal.tsx @@ -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 ( + + + + + + + + {activo ? 'Desactivar secciΓ³n' : 'Reactivar secciΓ³n'} + + + {activo + ? `ΒΏConfirmΓ‘s que querΓ©s desactivar la secciΓ³n "${seccionNombre}"?` + : `ΒΏConfirmΓ‘s que querΓ©s reactivar la secciΓ³n "${seccionNombre}"?`} + + + + Cancelar + + {isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'} + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionForm.tsx b/src/web/src/features/secciones/components/SeccionForm.tsx new file mode 100644 index 0000000..1d188fb --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionForm.tsx @@ -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 + +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({ + 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 ( +
+ + {backendError && ( + + + {backendError} + + )} + + ( + + Medio + + + + )} + /> + + ( + + CΓ³digo + + + + + + )} + /> + + ( + + Nombre + + + + + + )} + /> + + ( + + Tipo + + + + )} + /> + + + + + ) +} diff --git a/src/web/src/features/secciones/components/SeccionesFilters.tsx b/src/web/src/features/secciones/components/SeccionesFilters.tsx new file mode 100644 index 0000000..25598ab --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesFilters.tsx @@ -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 ( +
+ setSearchRaw(e.target.value)} + className="max-w-xs" + aria-label="Buscar secciones" + /> + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/components/SeccionesTable.tsx b/src/web/src/features/secciones/components/SeccionesTable.tsx new file mode 100644 index 0000000..7a6f455 --- /dev/null +++ b/src/web/src/features/secciones/components/SeccionesTable.tsx @@ -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[]>( + () => [ + { + accessorKey: 'codigo', + header: 'CΓ³digo', + cell: ({ row }) => ( + {row.original.codigo} + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'nombre', + header: 'Nombre', + meta: { priority: 'high' }, + }, + { + accessorKey: 'tipo', + header: 'Tipo', + cell: ({ row }) => ( + + {tipoSeccionLabel(row.original.tipo)} + + ), + meta: { priority: 'high' }, + }, + { + accessorKey: 'medioId', + header: 'Medio ID', + cell: ({ row }) => ( + {row.original.medioId} + ), + meta: { priority: 'medium' }, + }, + { + accessorKey: 'activo', + header: 'Estado', + cell: ({ row }) => + row.original.activo ? ( + + Activo + + ) : ( + + Inactivo + + ), + meta: { priority: 'medium' }, + }, + { + id: 'acciones', + header: 'Acciones', + cell: ({ row }) => ( +
e.stopPropagation()}> + + + + +
+ ), + meta: { priority: 'high' }, + }, + ], + [navigate], + ) + + return ( + navigate(`/admin/secciones/${row.id}`)} + getRowId={(row) => String(row.id)} + emptyMessage="Sin resultados β€” no se encontraron secciones con los filtros seleccionados." + /> + ) +} diff --git a/src/web/src/features/secciones/hooks/useCreateSeccion.ts b/src/web/src/features/secciones/hooks/useCreateSeccion.ts new file mode 100644 index 0000000..b113b4c --- /dev/null +++ b/src/web/src/features/secciones/hooks/useCreateSeccion.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts new file mode 100644 index 0000000..3c6f3eb --- /dev/null +++ b/src/web/src/features/secciones/hooks/useDeactivateSeccion.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useReactivateSeccion.ts b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts new file mode 100644 index 0000000..b97cbc3 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useReactivateSeccion.ts @@ -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'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccion.ts b/src/web/src/features/secciones/hooks/useSeccion.ts new file mode 100644 index 0000000..dea820a --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccion.ts @@ -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, + }) +} diff --git a/src/web/src/features/secciones/hooks/useSeccionesList.ts b/src/web/src/features/secciones/hooks/useSeccionesList.ts new file mode 100644 index 0000000..aeb3b0f --- /dev/null +++ b/src/web/src/features/secciones/hooks/useSeccionesList.ts @@ -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, + }) +} diff --git a/src/web/src/features/secciones/hooks/useUpdateSeccion.ts b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts new file mode 100644 index 0000000..ba65eb4 --- /dev/null +++ b/src/web/src/features/secciones/hooks/useUpdateSeccion.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateSeccion } from '../api/updateSeccion' +import type { UpdateSeccionRequest } from '../types' + +export function useUpdateSeccion(id: number) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (payload: UpdateSeccionRequest) => updateSeccion(id, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['secciones'] }) + }, + }) +} diff --git a/src/web/src/features/secciones/pages/CreateSeccionPage.tsx b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx new file mode 100644 index 0000000..72b1887 --- /dev/null +++ b/src/web/src/features/secciones/pages/CreateSeccionPage.tsx @@ -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 { SeccionForm } from '../components/SeccionForm' +import { useCreateSeccion } from '../hooks/useCreateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function CreateSeccionPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useCreateSeccion() + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + medioId: values.medioId, + codigo: values.codigo, + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('SecciΓ³n creada correctamente') + void navigate('/admin/secciones') + }, + }, + ) + } + + return ( +
+ + + Crear SecciΓ³n + + CompletΓ‘ los datos para registrar una nueva secciΓ³n en el sistema. + + + + + + +
+ ) +} diff --git a/src/web/src/features/secciones/pages/EditSeccionPage.tsx b/src/web/src/features/secciones/pages/EditSeccionPage.tsx new file mode 100644 index 0000000..2464094 --- /dev/null +++ b/src/web/src/features/secciones/pages/EditSeccionPage.tsx @@ -0,0 +1,80 @@ +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 { SeccionForm } from '../components/SeccionForm' +import { useSeccion } from '../hooks/useSeccion' +import { useUpdateSeccion } from '../hooks/useUpdateSeccion' +import type { SeccionFormValues } from '../components/SeccionForm' + +export function EditSeccionPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + const { mutate, isPending, error } = useUpdateSeccion(seccionId) + + function handleSubmit(values: SeccionFormValues) { + mutate( + { + nombre: values.nombre, + tipo: values.tipo, + }, + { + onSuccess: () => { + toast.success('SecciΓ³n actualizada correctamente') + void navigate(`/admin/secciones/${seccionId}`) + }, + }, + ) + } + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ SecciΓ³n no encontrada. +
+ ) + } + + return ( +
+ + +
+ Editar SecciΓ³n + +
+ + EditΓ‘ los datos de la secciΓ³n {seccion.nombre}. + +
+ + + +
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionDetailPage.tsx b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx new file mode 100644 index 0000000..f02dca3 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionDetailPage.tsx @@ -0,0 +1,99 @@ +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 { useSeccion } from '../hooks/useSeccion' +import { DeactivateSeccionModal } from '../components/DeactivateSeccionModal' +import { tipoSeccionLabel } from '../tipoSeccion' + +function formatDate(iso: string | null): string { + if (!iso) return 'β€”' + return new Date(iso).toLocaleDateString('es-AR', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function SeccionDetailPage() { + const { id } = useParams<{ id: string }>() + const seccionId = Number(id) + const navigate = useNavigate() + + const { data: seccion, isLoading } = useSeccion(seccionId) + + if (isLoading) { + return ( +
+ Cargando... +
+ ) + } + + if (!seccion) { + return ( +
+ SecciΓ³n no encontrada. +
+ ) + } + + return ( +
+
+

{seccion.nombre}

+ +
+ +
+
+ CΓ³digo + {seccion.codigo} +
+
+ Medio ID + {seccion.medioId} +
+
+ Tipo + + {tipoSeccionLabel(seccion.tipo)} + +
+
+ Estado + {seccion.activo + ? Activo + : Inactivo + } +
+
+ Creado + {formatDate(seccion.fechaCreacion)} +
+
+ Modificado + {formatDate(seccion.fechaModificacion)} +
+
+ + +
+ + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/pages/SeccionesListPage.tsx b/src/web/src/features/secciones/pages/SeccionesListPage.tsx new file mode 100644 index 0000000..d3e7048 --- /dev/null +++ b/src/web/src/features/secciones/pages/SeccionesListPage.tsx @@ -0,0 +1,114 @@ +import { useState, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { CanPerform } from '@/components/auth/CanPerform' +import { SeccionesTable } from '../components/SeccionesTable' +import { SeccionesFilters } from '../components/SeccionesFilters' +import { useSeccionesList } from '../hooks/useSeccionesList' +import type { TipoSeccion } from '../types' + +export function SeccionesListPage() { + const navigate = useNavigate() + + const [page, setPage] = useState(1) + const [medioId, setMedioId] = useState(undefined) + const [tipo, setTipo] = useState(undefined) + const [activo, setActivo] = useState(undefined) + const [q, setQ] = useState('') + + const query = { + page, + pageSize: 20, + ...(medioId !== undefined ? { medioId } : {}), + ...(tipo !== undefined ? { tipo } : {}), + ...(activo !== undefined ? { activo } : {}), + ...(q ? { q } : {}), + } + + const { data, isLoading } = useSeccionesList(query) + + const handleMedioIdChange = useCallback((value: number | undefined) => { + setMedioId(value) + setPage(1) + }, []) + + const handleTipoChange = useCallback((value: TipoSeccion | undefined) => { + setTipo(value) + setPage(1) + }, []) + + const handleActivoChange = useCallback((value: boolean | undefined) => { + setActivo(value) + setPage(1) + }, []) + + const handleSearchChange = useCallback((value: string) => { + setQ(value) + setPage(1) + }, []) + + const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1 + const hasPrev = page > 1 + const hasNext = page < totalPages + + return ( +
+
+

Secciones

+ + + +
+ + + + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + + {/* Pagination */} +
+ + {data ? `${data.total} secciΓ³n${data.total !== 1 ? 'es' : ''}` : ''} + +
+ + + {page} / {totalPages} + + +
+
+
+ ) +} diff --git a/src/web/src/features/secciones/tipoSeccion.ts b/src/web/src/features/secciones/tipoSeccion.ts new file mode 100644 index 0000000..fd54729 --- /dev/null +++ b/src/web/src/features/secciones/tipoSeccion.ts @@ -0,0 +1,12 @@ +import type { TipoSeccion } from './types' + +export const TIPO_SECCION_OPTIONS: { value: TipoSeccion; label: string }[] = [ + { value: 'clasificados', label: 'Clasificados' }, + { value: 'notables', label: 'Notables' }, + { value: 'suplementos', label: 'Suplementos' }, +] + +export function tipoSeccionLabel(tipo: TipoSeccion): string { + const found = TIPO_SECCION_OPTIONS.find((o) => o.value === tipo) + return found ? found.label : tipo +} diff --git a/src/web/src/features/secciones/types.ts b/src/web/src/features/secciones/types.ts new file mode 100644 index 0000000..dab896f --- /dev/null +++ b/src/web/src/features/secciones/types.ts @@ -0,0 +1,60 @@ +// ADM-001 β€” shared types for secciones feature + +export type TipoSeccion = 'clasificados' | 'notables' | 'suplementos' + +export interface SeccionListItem { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface SeccionDetail { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean + fechaCreacion: string + fechaModificacion: string | null +} + +export interface SeccionCreated { + id: number + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion + activo: boolean +} + +export interface CreateSeccionRequest { + medioId: number + codigo: string + nombre: string + tipo: TipoSeccion +} + +export interface UpdateSeccionRequest { + nombre: string + tipo: TipoSeccion +} + +export interface SeccionesQuery { + page?: number + pageSize?: number + medioId?: number + tipo?: TipoSeccion + activo?: boolean + q?: string +} + +export interface PagedResult { + items: T[] + page: number + pageSize: number + total: number +} diff --git a/src/web/src/features/users/components/UserForm.tsx b/src/web/src/features/users/components/UserForm.tsx index 792adc3..7df874c 100644 --- a/src/web/src/features/users/components/UserForm.tsx +++ b/src/web/src/features/users/components/UserForm.tsx @@ -14,6 +14,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useCreateUser } from '../hooks/useCreateUser' import { useRolesForSelect } from '../hooks/useRolesForSelect' import type { CreatedUserDto } from '../api/createUser' @@ -202,23 +209,26 @@ export function UserForm({ onSuccess }: UserFormProps) { render={({ field }) => ( Rol - - + + + + + + {rolOptions.map((r) => ( - + ))} - - + + )} diff --git a/src/web/src/features/users/components/UsersFilters.tsx b/src/web/src/features/users/components/UsersFilters.tsx index e21c187..f4c1f86 100644 --- a/src/web/src/features/users/components/UsersFilters.tsx +++ b/src/web/src/features/users/components/UsersFilters.tsx @@ -1,6 +1,13 @@ import { useState, useEffect } from 'react' import { Input } from '@/components/ui/input' import { useDebouncedValue } from '@/hooks/useDebouncedValue' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' interface UsersFiltersProps { onRolChange: (rol: string) => void @@ -38,32 +45,39 @@ export function UsersFilters({ onRolChange, onActivoChange, onSearchChange }: Us /> {/* Rol select */} - onRolChange(v === '__all__' ? '' : v)} > - {ROL_OPTIONS.map((r) => ( - - ))} - + + + + + {ROL_OPTIONS.map((r) => ( + + {r.label} + + ))} + + {/* Activo filter */} - { + if (v === '__all__') onActivoChange(undefined) else onActivoChange(v === 'true') }} - className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - - - - + + + + + Todos + Activos + Inactivos + + ) } diff --git a/src/web/src/features/users/pages/UserEditPage.tsx b/src/web/src/features/users/pages/UserEditPage.tsx index 78605e7..85f1cfa 100644 --- a/src/web/src/features/users/pages/UserEditPage.tsx +++ b/src/web/src/features/users/pages/UserEditPage.tsx @@ -17,6 +17,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useUser } from '../hooks/useUser' import { useUpdateUser } from '../hooks/useUpdateUser' import { ResetPasswordModal } from '../components/ResetPasswordModal' @@ -199,18 +206,22 @@ export function UserEditPage() { render={({ field }) => ( Rol - - - + )} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx index 2a83906..8290b0c 100644 --- a/src/web/src/router.tsx +++ b/src/web/src/router.tsx @@ -13,6 +13,14 @@ import { NewRolPage } from './features/roles/pages/NewRolPage' import { EditRolPage } from './features/roles/pages/EditRolPage' import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage' import { AuditPage } from './pages/admin/audit/AuditPage' +import { MediosListPage } from './features/medios/pages/MediosListPage' +import { CreateMedioPage } from './features/medios/pages/CreateMedioPage' +import { EditMedioPage } from './features/medios/pages/EditMedioPage' +import { MedioDetailPage } from './features/medios/pages/MedioDetailPage' +import { SeccionesListPage } from './features/secciones/pages/SeccionesListPage' +import { CreateSeccionPage } from './features/secciones/pages/CreateSeccionPage' +import { EditSeccionPage } from './features/secciones/pages/EditSeccionPage' +import { SeccionDetailPage } from './features/secciones/pages/SeccionDetailPage' import { HomePage } from './pages/HomePage' import { PublicLayout } from './layouts/PublicLayout' import { ProtectedLayout } from './layouts/ProtectedLayout' @@ -164,6 +172,74 @@ export function AppRoutes() { } /> + {/* Medios routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Secciones routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> ) diff --git a/src/web/src/tests/features/medios/CreateMedioPage.test.tsx b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx new file mode 100644 index 0000000..d779ed3 --- /dev/null +++ b/src/web/src/tests/features/medios/CreateMedioPage.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { CreateMedioPage } from '../../../features/medios/pages/CreateMedioPage' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('CreateMedioPage', () => { + it('renders the create form with correct title', () => { + renderPage() + expect(screen.getByRole('heading', { name: /crear medio/i })).toBeInTheDocument() + }) + + it('successfully creates a medio and navigates away', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { id: 10, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, + { status: 201 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'RAD01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Radio AM') + + // Open Radix Select trigger and pick "Radio" (value=2) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^radio$/i })) + + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/admin/medios')) + }) + + it('shows backend error when codigo is duplicated', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json( + { error: 'medio_codigo_duplicado', message: 'Ya existe un medio con ese cΓ³digo' }, + { status: 409 }, + ), + ), + ) + + renderPage() + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DUP01') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') + + // Open Radix Select trigger and pick "Diario" (value=1) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^diario$/i })) + + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/ya existe un medio con ese cΓ³digo/i), + ) + }) + + it('submit button is labeled "Crear medio"', () => { + renderPage() + expect(screen.getByRole('button', { name: /crear medio/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx new file mode 100644 index 0000000..aca974f --- /dev/null +++ b/src/web/src/tests/features/medios/DeactivateMedioModal.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { DeactivateMedioModal } from '../../../features/medios/components/DeactivateMedioModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateMedioModal', () => { + it('shows "Desactivar" trigger when medio is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when medio is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar medio/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/diario el dΓ­a/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm and invalidates query', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) + + it('calls reactivate endpoint on confirm when inactive', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/medios/1/reactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(false) + await userEvent.click(screen.getByRole('button', { name: /reactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /reactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/medios/EditMedioPage.test.tsx b/src/web/src/tests/features/medios/EditMedioPage.test.tsx new file mode 100644 index 0000000..e1f99d0 --- /dev/null +++ b/src/web/src/tests/features/medios/EditMedioPage.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { EditMedioPage } from '../../../features/medios/pages/EditMedioPage' + +const API_URL = 'http://localhost:5000' + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio = { + id: 5, + codigo: 'WEB01', + nombre: 'Portal Web', + tipo: 3, + plataformaEmpresaId: 42, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(id = '5') { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('EditMedioPage', () => { + it('shows loading state initially', () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + expect(screen.getByText(/cargando/i)).toBeInTheDocument() + }) + + it('loads and pre-fills form with medio data', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Portal Web'), + ) + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('WEB01') + // CΓ³digo disabled in edit mode + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).disabled).toBe(true) + }) + + it('shows "Medio no encontrado" when 404', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/999`, () => + new HttpResponse(null, { status: 404 }), + ), + ) + + renderPage('999') + + await waitFor(() => + expect(screen.getByText(/medio no encontrado/i)).toBeInTheDocument(), + ) + }) + + it('shows "Guardar cambios" button in edit mode', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios/5`, () => + HttpResponse.json(sampleMedio), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/medios/MedioForm.test.tsx b/src/web/src/tests/features/medios/MedioForm.test.tsx new file mode 100644 index 0000000..021ee0d --- /dev/null +++ b/src/web/src/tests/features/medios/MedioForm.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { MedioForm } from '../../../features/medios/components/MedioForm' +import type { MedioFormValues } from '../../../features/medios/components/MedioForm' +import type { MedioDetail } from '../../../features/medios/types' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const sampleMedio: MedioDetail = { + id: 1, + codigo: 'DIA01', + nombre: 'Diario Principal', + tipo: 1, + plataformaEmpresaId: null, + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +function renderForm(opts: { initialData?: MedioDetail; onSubmit?: (v: MedioFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + render( + + + + + , + ) + return { onSubmit } +} + +describe('MedioForm β€” create mode', () => { + it('shows validation error when cΓ³digo is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when nombre is empty', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + await waitFor(() => + expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Medio') + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + // The form message for tipo validation appears as a

role + await waitFor(() => { + const messages = screen.getAllByText(/seleccionΓ‘ un tipo/i) + // At least one message should exist (validation error, not just the placeholder option) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DIA99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario') + + // Open the Radix Select trigger and pick option + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /diario/i })) + + await userEvent.click(screen.getByRole('button', { name: /crear medio/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ codigo: 'DIA99', nombre: 'Mi Diario', tipo: 1 }) + }) + }) +}) + +describe('MedioForm β€” edit mode', () => { + it('cΓ³digo field is disabled in edit mode', () => { + renderForm({ initialData: sampleMedio }) + const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + }) + + it('pre-fills form with initialData values', () => { + renderForm({ initialData: sampleMedio }) + expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('DIA01') + expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Diario Principal') + }) + + it('shows "Guardar cambios" button in edit mode', () => { + renderForm({ initialData: sampleMedio }) + expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument() + }) +}) diff --git a/src/web/src/tests/features/medios/MediosListPage.test.tsx b/src/web/src/tests/features/medios/MediosListPage.test.tsx new file mode 100644 index 0000000..34470ce --- /dev/null +++ b/src/web/src/tests/features/medios/MediosListPage.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { MediosListPage } from '../../../features/medios/pages/MediosListPage' +import { useAuthStore } from '../../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } +}) + +const adminUserWithMedios = { + id: 1, + username: 'admin', + nombre: 'Admin', + rol: 'admin', + permisos: ['administracion:medios:gestionar'], + mustChangePassword: false, +} + +const adminUserWithoutMedios = { + id: 2, + username: 'cajero', + nombre: 'Cajero', + rol: 'cajero', + permisos: [], + mustChangePassword: false, +} + +function makeMedios(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: i + 1, + codigo: `MEDIO${i + 1}`, + nombre: `Medio ${i + 1}`, + tipo: (i % 4) + 1, + plataformaEmpresaId: null, + activo: true, + })) +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + useAuthStore.getState().clearAuth() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderPage(user = adminUserWithMedios) { + useAuthStore.setState({ user }) + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + } /> + + + , + ) +} + +describe('MediosListPage', () => { + it('renders seed rows when API returns items', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByText('MEDIO2')).toBeInTheDocument() + expect(screen.getByText('MEDIO3')).toBeInTheDocument() + }) + + it('shows empty state when items is empty', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(), + ) + }) + + it('hides "Nuevo medio" button when user lacks permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage(adminUserWithoutMedios) + + // Wait for page to render + await waitFor(() => expect(screen.queryByRole('button', { name: /nuevo medio/i })).not.toBeInTheDocument()) + }) + + it('shows "Nuevo medio" button when user has permission', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }), + ), + ) + + renderPage() + + await waitFor(() => + expect(screen.getByRole('button', { name: /nuevo medio/i })).toBeInTheDocument(), + ) + }) + + it('filter by tipo adds querystring tipo', async () => { + const requests: string[] = [] + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, ({ request }) => { + requests.push(request.url) + return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }) + }), + ) + + renderPage() + + await waitFor(() => expect(requests.length).toBeGreaterThan(0)) + + // Open the Radix Select trigger and pick "Diario" (value=1) + await userEvent.click(screen.getByRole('combobox', { name: /tipo/i })) + await userEvent.click(screen.getByRole('option', { name: /^diario$/i })) + + await waitFor(() => { + const filtered = requests.find((u) => u.includes('tipo=1')) + expect(filtered).toBeTruthy() + }) + }) + + it('prev button disabled on first page', async () => { + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }), + ), + ) + + renderPage() + + await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) +}) diff --git a/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx new file mode 100644 index 0000000..310e087 --- /dev/null +++ b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { DeactivateSeccionModal } from '../../../features/secciones/components/DeactivateSeccionModal' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderModal(activo = true) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + + + , + ) +} + +describe('DeactivateSeccionModal', () => { + it('shows "Desactivar" trigger when secciΓ³n is active', () => { + renderModal(true) + expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument() + }) + + it('shows "Reactivar" trigger when secciΓ³n is inactive', () => { + renderModal(false) + expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument() + }) + + it('opens dialog and shows confirmation text', async () => { + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => + expect(screen.getByText(/desactivar secciΓ³n/i)).toBeInTheDocument(), + ) + expect(screen.getByText(/clasificados autos/i)).toBeInTheDocument() + }) + + it('calls deactivate endpoint on confirm', async () => { + let called = false + server.use( + http.post(`${API_URL}/api/v1/admin/secciones/1/deactivate`, () => { + called = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + renderModal(true) + await userEvent.click(screen.getByRole('button', { name: /desactivar/i })) + await waitFor(() => screen.getByRole('alertdialog')) + await userEvent.click(screen.getByRole('button', { name: /desactivar$/i })) + + await waitFor(() => expect(called).toBe(true)) + }) +}) diff --git a/src/web/src/tests/features/secciones/SeccionForm.test.tsx b/src/web/src/tests/features/secciones/SeccionForm.test.tsx new file mode 100644 index 0000000..741f4bd --- /dev/null +++ b/src/web/src/tests/features/secciones/SeccionForm.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { SeccionForm } from '../../../features/secciones/components/SeccionForm' +import type { SeccionFormValues } from '../../../features/secciones/components/SeccionForm' +import type { SeccionDetail } from '../../../features/secciones/types' + +const API_URL = 'http://localhost:5000' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +const mockMedios = [ + { id: 1, codigo: 'DIA01', nombre: 'Diario El DΓ­a', tipo: 1, plataformaEmpresaId: null, activo: true }, + { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true }, +] + +const sampleSeccion: SeccionDetail = { + id: 1, + medioId: 1, + codigo: 'CLAS01', + nombre: 'Clasificados Autos', + tipo: 'clasificados', + activo: true, + fechaCreacion: '2026-01-01T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function renderForm(opts: { initialData?: SeccionDetail; onSubmit?: (v: SeccionFormValues) => void } = {}) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSubmit = opts.onSubmit ?? vi.fn() + server.use( + http.get(`${API_URL}/api/v1/admin/medios`, () => + HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }), + ), + ) + render( + + + + + , + ) + return { onSubmit } +} + +describe('SeccionForm β€” create mode', () => { + it('shows validation error when cΓ³digo is empty', async () => { + renderForm() + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + await waitFor(() => + expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(), + ) + }) + + it('shows validation error when tipo is not selected', async () => { + renderForm() + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n') + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + await waitFor(() => + expect(screen.getByText(/seleccionΓ‘ un tipo/i)).toBeInTheDocument(), + ) + }) + + it('calls onSubmit with correct values on valid form', async () => { + const onSubmit = vi.fn() + renderForm({ onSubmit }) + + // Open Medio trigger, wait for medios to load, then pick one + const medioTrigger = screen.getByRole('combobox', { name: /medio/i }) + await userEvent.click(medioTrigger) + await waitFor(() => + expect(screen.getByRole('option', { name: 'Diario El DΓ­a' })).toBeInTheDocument(), + ) + await userEvent.click(screen.getByRole('option', { name: 'Diario El DΓ­a' })) + + await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99') + await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n') + + // Open Tipo trigger and pick Clasificados + const tipoTrigger = screen.getByRole('combobox', { name: /tipo de secciΓ³n/i }) + await userEvent.click(tipoTrigger) + await waitFor(() => + expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(), + ) + await userEvent.click(screen.getByRole('option', { name: 'Clasificados' })) + + await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const firstArg = onSubmit.mock.calls[0][0] + expect(firstArg).toMatchObject({ + medioId: 1, + codigo: 'CLAS99', + nombre: 'Mi SecciΓ³n', + tipo: 'clasificados', + }) + }) + }) +}) + +describe('SeccionForm β€” edit mode', () => { + it('cΓ³digo and medioId are disabled in edit mode', async () => { + renderForm({ initialData: sampleSeccion }) + const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement + expect(codigoInput.disabled).toBe(true) + // Radix Select trigger is a