Compare commits
8 Commits
7c0646be0d
...
6b946f6080
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b946f6080 | |||
| 13480ad8c2 | |||
| a6f4011806 | |||
| 2f0da2d720 | |||
| a1a8e6e0cb | |||
| f672de78ce | |||
| bb98dbf217 | |||
| ff7d8986fd |
@@ -27,6 +27,8 @@ database/
|
|||||||
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
|
| 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}` |
|
| 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.** |
|
| **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
|
## 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.
|
**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
|
## Recursos
|
||||||
|
|
||||||
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
|
||||||
|
|||||||
118
database/migrations/V011_ROLLBACK.sql
Normal file
118
database/migrations/V011_ROLLBACK.sql
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-- V011_ROLLBACK.sql
|
||||||
|
-- Reversa de V011__create_medio_seccion.sql.
|
||||||
|
--
|
||||||
|
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal,
|
||||||
|
-- el permiso 'administracion:secciones:gestionar' y sus asignaciones.
|
||||||
|
-- ('administracion:medios:gestionar' NO se toca — es pre-existente de V005.)
|
||||||
|
--
|
||||||
|
-- Uso intended: ROLLBACK en entornos NO-productivos.
|
||||||
|
-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario).
|
||||||
|
-- Si ADM-008, ADM-009 o PRC-* ya están aplicados, este rollback falla — usar backup.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
-- Seccion primero (FK al Medio)
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom;
|
||||||
|
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo;
|
||||||
|
ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'Seccion: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Seccion_History;
|
||||||
|
PRINT 'Seccion_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Medio
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF);
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING OFF.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio'))
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME;
|
||||||
|
PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom;
|
||||||
|
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo;
|
||||||
|
ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo;
|
||||||
|
PRINT 'Medio: ValidFrom/ValidTo dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Medio_History;
|
||||||
|
PRINT 'Medio_History dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. Drop Seccion y Medio (Seccion primero por FK)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Seccion;
|
||||||
|
PRINT 'Table dbo.Seccion dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
DROP TABLE dbo.Medio;
|
||||||
|
PRINT 'Table dbo.Medio dropped.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DELETE rp
|
||||||
|
FROM dbo.RolPermiso rp
|
||||||
|
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
|
||||||
|
WHERE p.Codigo = 'administracion:secciones:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Permiso
|
||||||
|
WHERE Codigo = 'administracion:secciones:gestionar';
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.';
|
||||||
|
PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).';
|
||||||
|
GO
|
||||||
206
database/migrations/V011__create_medio_seccion.sql
Normal file
206
database/migrations/V011__create_medio_seccion.sql
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
-- V011__create_medio_seccion.sql
|
||||||
|
-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones — catálogo fundacional.
|
||||||
|
--
|
||||||
|
-- Cambios:
|
||||||
|
-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON).
|
||||||
|
-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON).
|
||||||
|
-- 3. Permiso 'administracion:secciones:gestionar' + asignación a rol 'admin'.
|
||||||
|
-- El permiso 'administracion:medios:gestionar' ya existía desde V005.
|
||||||
|
--
|
||||||
|
-- Patrón: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 años + PAGE compression en history).
|
||||||
|
-- Idempotente: seguro para re-ejecutar.
|
||||||
|
-- Reversa: V011_ROLLBACK.sql.
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
--
|
||||||
|
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 📋 UDTs Módulo Administración.md (ADM-001)
|
||||||
|
-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏢 Medio.md
|
||||||
|
-- Auditoría: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 1. dbo.Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.Medio (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY,
|
||||||
|
Codigo VARCHAR(30) NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster
|
||||||
|
PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping)
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo),
|
||||||
|
CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4)
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.Medio created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.Medio already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Medio_Activo_Tipo
|
||||||
|
ON dbo.Medio(Activo, Tipo)
|
||||||
|
INCLUDE (Codigo, Nombre, PlataformaEmpresaId);
|
||||||
|
PRINT 'Index IX_Medio_Activo_Tipo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 2. dbo.Seccion
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.Seccion (
|
||||||
|
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY,
|
||||||
|
MedioId INT NOT NULL,
|
||||||
|
Codigo VARCHAR(30) NOT NULL,
|
||||||
|
Nombre NVARCHAR(100) NOT NULL,
|
||||||
|
Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos'
|
||||||
|
Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1),
|
||||||
|
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()),
|
||||||
|
FechaModificacion DATETIME2(3) NULL,
|
||||||
|
CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo),
|
||||||
|
CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos'))
|
||||||
|
);
|
||||||
|
PRINT 'Table dbo.Seccion created.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Table dbo.Seccion already exists — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion'))
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX IX_Seccion_MedioId_Activo
|
||||||
|
ON dbo.Seccion(MedioId, Activo)
|
||||||
|
INCLUDE (Codigo, Nombre, Tipo);
|
||||||
|
PRINT 'Index IX_Seccion_MedioId_Activo created.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 3. SYSTEM_VERSIONING — Medio
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.Medio_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Medio: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.partitions p
|
||||||
|
JOIN sys.tables t ON t.object_id = p.object_id
|
||||||
|
WHERE t.name = 'Medio_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'Medio_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 4. SYSTEM_VERSIONING — Seccion
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion
|
||||||
|
ADD
|
||||||
|
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()),
|
||||||
|
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
|
||||||
|
CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
|
||||||
|
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
|
||||||
|
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion
|
||||||
|
SET (SYSTEM_VERSIONING = ON (
|
||||||
|
HISTORY_TABLE = dbo.Seccion_History,
|
||||||
|
HISTORY_RETENTION_PERIOD = 10 YEARS
|
||||||
|
));
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING = ON.';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
PRINT 'Seccion: SYSTEM_VERSIONING already ON — skip.';
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_History' AND schema_id = SCHEMA_ID('dbo'))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys.partitions p
|
||||||
|
JOIN sys.tables t ON t.object_id = p.object_id
|
||||||
|
WHERE t.name = 'Seccion_History' AND p.data_compression = 2
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE);
|
||||||
|
PRINT 'Seccion_History: rebuilt with PAGE compression.';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
-- 5. Permiso nuevo: administracion:secciones:gestionar
|
||||||
|
-- ('administracion:medios:gestionar' ya fue sembrado en V005 — no se toca).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
MERGE dbo.Permiso AS t
|
||||||
|
USING (VALUES
|
||||||
|
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion')
|
||||||
|
) AS s (Codigo, Nombre, Descripcion, Modulo)
|
||||||
|
ON t.Codigo = s.Codigo
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Nombre, Descripcion, Modulo)
|
||||||
|
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.RolPermiso AS t
|
||||||
|
USING (
|
||||||
|
SELECT r.Id AS RolId, p.Id AS PermisoId
|
||||||
|
FROM (VALUES
|
||||||
|
('admin', 'administracion:secciones:gestionar')
|
||||||
|
) AS x (RolCodigo, PermisoCodigo)
|
||||||
|
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
|
||||||
|
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
|
||||||
|
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'V011 applied successfully — dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.';
|
||||||
|
PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).';
|
||||||
|
GO
|
||||||
30
database/migrations/V012_ROLLBACK.sql
Normal file
30
database/migrations/V012_ROLLBACK.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- V012_ROLLBACK.sql
|
||||||
|
-- Reversa de V012__seed_medios.sql.
|
||||||
|
--
|
||||||
|
-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas.
|
||||||
|
-- Si alguna sección depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION.
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Falla temprano si hay secciones vivas apuntando a estos Medios.
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Seccion s
|
||||||
|
JOIN dbo.Medio m ON m.Id = s.MedioId
|
||||||
|
WHERE m.Codigo IN ('ELDIA', 'ELPLATA')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
DELETE FROM dbo.Medio
|
||||||
|
WHERE Codigo IN ('ELDIA', 'ELPLATA');
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V012 rolled back — seed Medios ELDIA y ELPLATA removed.';
|
||||||
|
GO
|
||||||
27
database/migrations/V012__seed_medios.sql
Normal file
27
database/migrations/V012__seed_medios.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- V012__seed_medios.sql
|
||||||
|
-- ADM-001: seed inicial de Medios ELDIA y ELPLATA.
|
||||||
|
--
|
||||||
|
-- Idempotente via MERGE por Codigo.
|
||||||
|
-- Tipo = 1 (Diario) per enum TipoMedio.
|
||||||
|
-- PlataformaEmpresaId = NULL (INT-003 lo poblará cuando exista el mapeo IMAC).
|
||||||
|
--
|
||||||
|
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
|
||||||
|
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
MERGE dbo.Medio AS t
|
||||||
|
USING (VALUES
|
||||||
|
('ELDIA', N'El Día', 1),
|
||||||
|
('ELPLATA', N'El Plata', 1)
|
||||||
|
) AS s (Codigo, Nombre, Tipo)
|
||||||
|
ON t.Codigo = s.Codigo
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo)
|
||||||
|
VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1);
|
||||||
|
GO
|
||||||
|
|
||||||
|
PRINT 'V012 applied — Medios ELDIA y ELPLATA seeded (idempotent).';
|
||||||
|
GO
|
||||||
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
173
src/api/SIGCM2.Api/Controllers/MediosController.cs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Medios.Create;
|
||||||
|
using SIGCM2.Application.Medios.Deactivate;
|
||||||
|
using SIGCM2.Application.Medios.GetById;
|
||||||
|
using SIGCM2.Application.Medios.List;
|
||||||
|
using SIGCM2.Application.Medios.Reactivate;
|
||||||
|
using SIGCM2.Application.Medios.Update;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
|
||||||
|
/// All endpoints require permission 'administracion:medios:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/medios")]
|
||||||
|
public sealed class MediosController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateMedioCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateMedioCommand> _updateValidator;
|
||||||
|
|
||||||
|
public MediosController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateMedioCommand> createValidator,
|
||||||
|
IValidator<UpdateMedioCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateMedio([FromBody] CreateMedioRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateMedioCommand(
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||||
|
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
var validation = await _createValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<CreateMedioCommand, MedioCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists medios with optional filters and pagination.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListMedios(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] TipoMedio? tipo = null,
|
||||||
|
[FromQuery] string? q = null)
|
||||||
|
{
|
||||||
|
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
|
||||||
|
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
|
||||||
|
|
||||||
|
var query = new ListMediosQuery(page, pageSize, activo, tipo, q);
|
||||||
|
var result = await _dispatcher.Send<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single medio by id.</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetMedioById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetMedioByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a medio's editable fields.</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateMedioCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? TipoMedio.Diario,
|
||||||
|
PlataformaEmpresaId: request.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
var validation = await _updateValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<UpdateMedioCommand, MedioUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a medio (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateMedioCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a medio (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:medios:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateMedioCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Create medio request body.</summary>
|
||||||
|
public sealed record CreateMedioRequest(
|
||||||
|
string? Codigo,
|
||||||
|
string? Nombre,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
int? PlataformaEmpresaId);
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Update medio request body.</summary>
|
||||||
|
public sealed record UpdateMedioRequest(
|
||||||
|
string? Nombre,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
int? PlataformaEmpresaId);
|
||||||
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
172
src/api/SIGCM2.Api/Controllers/SeccionesController.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SIGCM2.Api.Authorization;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Application.Secciones.Create;
|
||||||
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
using SIGCM2.Application.Secciones.GetById;
|
||||||
|
using SIGCM2.Application.Secciones.List;
|
||||||
|
using SIGCM2.Application.Secciones.Reactivate;
|
||||||
|
using SIGCM2.Application.Secciones.Update;
|
||||||
|
|
||||||
|
namespace SIGCM2.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
|
||||||
|
/// All endpoints require permission 'administracion:secciones:gestionar'.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin/secciones")]
|
||||||
|
public sealed class SeccionesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
private readonly IValidator<CreateSeccionCommand> _createValidator;
|
||||||
|
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
|
||||||
|
|
||||||
|
public SeccionesController(
|
||||||
|
IDispatcher dispatcher,
|
||||||
|
IValidator<CreateSeccionCommand> createValidator,
|
||||||
|
IValidator<UpdateSeccionCommand> updateValidator)
|
||||||
|
{
|
||||||
|
_dispatcher = dispatcher;
|
||||||
|
_createValidator = createValidator;
|
||||||
|
_updateValidator = updateValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> CreateSeccion([FromBody] CreateSeccionRequest request)
|
||||||
|
{
|
||||||
|
var command = new CreateSeccionCommand(
|
||||||
|
MedioId: request.MedioId ?? 0,
|
||||||
|
Codigo: request.Codigo ?? string.Empty,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? string.Empty);
|
||||||
|
|
||||||
|
var validation = await _createValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<CreateSeccionCommand, SeccionCreatedDto>(command);
|
||||||
|
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists secciones with optional filters and pagination.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> ListSecciones(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] int? medioId = null,
|
||||||
|
[FromQuery] string? tipo = null,
|
||||||
|
[FromQuery] bool? activo = null,
|
||||||
|
[FromQuery] string? q = null)
|
||||||
|
{
|
||||||
|
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
|
||||||
|
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
|
||||||
|
|
||||||
|
var query = new ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q);
|
||||||
|
var result = await _dispatcher.Send<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets a single seccion by id.</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var query = new GetSeccionByIdQuery(id);
|
||||||
|
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates a seccion's editable fields.</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request)
|
||||||
|
{
|
||||||
|
var command = new UpdateSeccionCommand(
|
||||||
|
Id: id,
|
||||||
|
Nombre: request.Nombre ?? string.Empty,
|
||||||
|
Tipo: request.Tipo ?? string.Empty);
|
||||||
|
|
||||||
|
var validation = await _updateValidator.ValidateAsync(command);
|
||||||
|
if (!validation.IsValid)
|
||||||
|
{
|
||||||
|
var errors = validation.Errors
|
||||||
|
.GroupBy(e => e.PropertyName)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _dispatcher.Send<UpdateSeccionCommand, SeccionUpdatedDto>(command);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivates a seccion (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/deactivate")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new DeactivateSeccionCommand(id);
|
||||||
|
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reactivates a seccion (idempotent).</summary>
|
||||||
|
[HttpPost("{id:int}/reactivate")]
|
||||||
|
[RequirePermission("administracion:secciones:gestionar")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
|
||||||
|
{
|
||||||
|
var command = new ReactivateSeccionCommand(id);
|
||||||
|
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request body records ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Create seccion request body.</summary>
|
||||||
|
public sealed record CreateSeccionRequest(
|
||||||
|
int? MedioId,
|
||||||
|
string? Codigo,
|
||||||
|
string? Nombre,
|
||||||
|
string? Tipo);
|
||||||
|
|
||||||
|
/// <summary>ADM-001: Update seccion request body.</summary>
|
||||||
|
public sealed record UpdateSeccionRequest(
|
||||||
|
string? Nombre,
|
||||||
|
string? Tipo);
|
||||||
@@ -169,6 +169,56 @@ public sealed class ExceptionFilter : IExceptionFilter
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
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
|
// UDT-009: permiso override validation errors
|
||||||
case InvalidPermisoCodesException ipce:
|
case InvalidPermisoCodesException ipce:
|
||||||
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface IMedioRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Medio m, CancellationToken ct = default);
|
||||||
|
Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Medio m, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
|
||||||
|
public interface ISeccionRepository
|
||||||
|
{
|
||||||
|
Task<int> AddAsync(Seccion s, CancellationToken ct = default);
|
||||||
|
Task<Seccion?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||||
|
Task<bool> ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default);
|
||||||
|
Task UpdateAsync(Seccion s, CancellationToken ct = default);
|
||||||
|
Task<PagedResult<Seccion>> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default);
|
||||||
|
}
|
||||||
12
src/api/SIGCM2.Application/Common/MediosQuery.cs
Normal file
12
src/api/SIGCM2.Application/Common/MediosQuery.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing medios with optional filters and paging.</summary>
|
||||||
|
public sealed record MediosQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
bool? Activo,
|
||||||
|
TipoMedio? Tipo,
|
||||||
|
string? Search
|
||||||
|
);
|
||||||
11
src/api/SIGCM2.Application/Common/SeccionesQuery.cs
Normal file
11
src/api/SIGCM2.Application/Common/SeccionesQuery.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Query parameters for listing secciones with optional filters and paging.</summary>
|
||||||
|
public sealed record SeccionesQuery(
|
||||||
|
int Page,
|
||||||
|
int PageSize,
|
||||||
|
int? MedioId,
|
||||||
|
string? Tipo,
|
||||||
|
bool? Activo,
|
||||||
|
string? Search
|
||||||
|
);
|
||||||
@@ -5,6 +5,12 @@ using SIGCM2.Application.Auth.Login;
|
|||||||
using SIGCM2.Application.Auth.Logout;
|
using SIGCM2.Application.Auth.Logout;
|
||||||
using SIGCM2.Application.Auth.Refresh;
|
using SIGCM2.Application.Auth.Refresh;
|
||||||
using SIGCM2.Application.Common;
|
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.Assign;
|
||||||
using SIGCM2.Application.Permisos.Dtos;
|
using SIGCM2.Application.Permisos.Dtos;
|
||||||
using SIGCM2.Application.Permisos.GetByRol;
|
using SIGCM2.Application.Permisos.GetByRol;
|
||||||
@@ -15,6 +21,12 @@ using SIGCM2.Application.Roles.Dtos;
|
|||||||
using SIGCM2.Application.Roles.Get;
|
using SIGCM2.Application.Roles.Get;
|
||||||
using SIGCM2.Application.Roles.List;
|
using SIGCM2.Application.Roles.List;
|
||||||
using SIGCM2.Application.Roles.Update;
|
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.ChangeMyPassword;
|
||||||
using SIGCM2.Application.Usuarios.Create;
|
using SIGCM2.Application.Usuarios.Create;
|
||||||
using SIGCM2.Application.Usuarios.Deactivate;
|
using SIGCM2.Application.Usuarios.Deactivate;
|
||||||
@@ -62,6 +74,22 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
services.AddScoped<ICommandHandler<GetUsuarioPermisosQuery, UsuarioPermisosDto>, GetUsuarioPermisosQueryHandler>();
|
||||||
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
services.AddScoped<ICommandHandler<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>, UpdateUsuarioPermisosOverridesCommandHandler>();
|
||||||
|
|
||||||
|
// Medios (ADM-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateMedioCommand, MedioCreatedDto>, CreateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateMedioCommand, MedioUpdatedDto>, UpdateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateMedioCommand, MedioStatusDto>, DeactivateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateMedioCommand, MedioStatusDto>, ReactivateMedioCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListMediosQuery, PagedResult<MedioListItemDto>>, ListMediosQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetMedioByIdQuery, MedioDetailDto>, GetMedioByIdQueryHandler>();
|
||||||
|
|
||||||
|
// Secciones (ADM-001)
|
||||||
|
services.AddScoped<ICommandHandler<CreateSeccionCommand, SeccionCreatedDto>, CreateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<UpdateSeccionCommand, SeccionUpdatedDto>, UpdateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<DeactivateSeccionCommand, SeccionStatusDto>, DeactivateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ReactivateSeccionCommand, SeccionStatusDto>, ReactivateSeccionCommandHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>, ListSeccionesQueryHandler>();
|
||||||
|
services.AddScoped<ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>, GetSeccionByIdQueryHandler>();
|
||||||
|
|
||||||
// FluentValidation validators (scans entire Application assembly)
|
// FluentValidation validators (scans entire Application assembly)
|
||||||
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
services.AddValidatorsFromAssemblyContaining<LoginCommandValidator>();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Create;
|
||||||
|
|
||||||
|
public sealed class CreateMedioCommandHandler : ICommandHandler<CreateMedioCommand, MedioCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public CreateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioCreatedDto> Handle(CreateMedioCommand command)
|
||||||
|
{
|
||||||
|
var codigoNorm = command.Codigo.ToUpperInvariant();
|
||||||
|
|
||||||
|
var exists = await _repo.ExistsByCodigoAsync(codigoNorm);
|
||||||
|
if (exists)
|
||||||
|
throw new MedioCodigoDuplicadoException(codigoNorm);
|
||||||
|
|
||||||
|
var medio = Medio.ForCreation(codigoNorm, command.Nombre, command.Tipo, command.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
var newId = await _repo.AddAsync(medio);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "medio.create",
|
||||||
|
targetType: "Medio",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
medio.Codigo,
|
||||||
|
medio.Nombre,
|
||||||
|
medio.Tipo,
|
||||||
|
medio.PlataformaEmpresaId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new MedioCreatedDto(
|
||||||
|
Id: newId,
|
||||||
|
Codigo: medio.Codigo,
|
||||||
|
Nombre: medio.Nombre,
|
||||||
|
Tipo: medio.Tipo,
|
||||||
|
PlataformaEmpresaId: medio.PlataformaEmpresaId,
|
||||||
|
Activo: medio.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Create;
|
||||||
|
|
||||||
|
public sealed class CreateMedioCommandValidator : AbstractValidator<CreateMedioCommand>
|
||||||
|
{
|
||||||
|
private const int CodigoMaxLength = 30;
|
||||||
|
private const int NombreMaxLength = 100;
|
||||||
|
|
||||||
|
public CreateMedioCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Codigo)
|
||||||
|
.NotEmpty().WithMessage("El código es requerido.")
|
||||||
|
.MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres.")
|
||||||
|
.Matches(@"^[A-Za-z0-9_]+$").WithMessage("El código solo puede contener letras, dígitos y guiones bajos.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||||
|
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Tipo)
|
||||||
|
.IsInEnum().WithMessage("El tipo de medio no es válido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs
Normal file
11
src/api/SIGCM2.Application/Medios/Create/MedioCreatedDto.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Create;
|
||||||
|
|
||||||
|
public sealed record MedioCreatedDto(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
TipoMedio Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Medios.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateMedioCommand(int Id);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Deactivate;
|
||||||
|
|
||||||
|
public sealed class DeactivateMedioCommandHandler : ICommandHandler<DeactivateMedioCommand, MedioStatusDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public DeactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioStatusDto> Handle(DeactivateMedioCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new MedioNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// Idempotent: already inactive → return as-is without writing an audit event
|
||||||
|
if (!target.Activo)
|
||||||
|
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
|
||||||
|
|
||||||
|
var updated = target.WithActivo(false);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "medio.deactivate",
|
||||||
|
targetType: "Medio",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Medios.Deactivate;
|
||||||
|
|
||||||
|
public sealed record MedioStatusDto(int Id, string Codigo, bool Activo);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Medios.GetById;
|
||||||
|
|
||||||
|
public sealed record GetMedioByIdQuery(int Id);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.GetById;
|
||||||
|
|
||||||
|
public sealed class GetMedioByIdQueryHandler : ICommandHandler<GetMedioByIdQuery, MedioDetailDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
|
||||||
|
public GetMedioByIdQueryHandler(IMedioRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioDetailDto> Handle(GetMedioByIdQuery query)
|
||||||
|
{
|
||||||
|
var medio = await _repo.GetByIdAsync(query.Id)
|
||||||
|
?? throw new MedioNotFoundException(query.Id);
|
||||||
|
|
||||||
|
return new MedioDetailDto(
|
||||||
|
Id: medio.Id,
|
||||||
|
Codigo: medio.Codigo,
|
||||||
|
Nombre: medio.Nombre,
|
||||||
|
Tipo: medio.Tipo,
|
||||||
|
PlataformaEmpresaId: medio.PlataformaEmpresaId,
|
||||||
|
Activo: medio.Activo,
|
||||||
|
FechaCreacion: medio.FechaCreacion,
|
||||||
|
FechaModificacion: medio.FechaModificacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs
Normal file
13
src/api/SIGCM2.Application/Medios/GetById/MedioDetailDto.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.GetById;
|
||||||
|
|
||||||
|
public sealed record MedioDetailDto(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
TipoMedio Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
10
src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs
Normal file
10
src/api/SIGCM2.Application/Medios/List/ListMediosQuery.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.List;
|
||||||
|
|
||||||
|
public sealed record ListMediosQuery(
|
||||||
|
int Page = 1,
|
||||||
|
int PageSize = 20,
|
||||||
|
bool? Activo = true,
|
||||||
|
TipoMedio? Tipo = null,
|
||||||
|
string? Search = null);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.List;
|
||||||
|
|
||||||
|
public sealed class ListMediosQueryHandler : ICommandHandler<ListMediosQuery, PagedResult<MedioListItemDto>>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
|
||||||
|
public ListMediosQueryHandler(IMedioRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<MedioListItemDto>> Handle(ListMediosQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
|
||||||
|
var repoQuery = new MediosQuery(page, pageSize, query.Activo, query.Tipo, query.Search);
|
||||||
|
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||||
|
|
||||||
|
var items = paged.Items
|
||||||
|
.Select(m => new MedioListItemDto(m.Id, m.Codigo, m.Nombre, m.Tipo, m.PlataformaEmpresaId, m.Activo))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedResult<MedioListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs
Normal file
11
src/api/SIGCM2.Application/Medios/List/MedioListItemDto.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.List;
|
||||||
|
|
||||||
|
public sealed record MedioListItemDto(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
TipoMedio Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Medios.Reactivate;
|
||||||
|
|
||||||
|
public sealed record ReactivateMedioCommand(int Id);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Medios.Deactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Reactivate;
|
||||||
|
|
||||||
|
public sealed class ReactivateMedioCommandHandler : ICommandHandler<ReactivateMedioCommand, MedioStatusDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public ReactivateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioStatusDto> Handle(ReactivateMedioCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new MedioNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// Idempotent: already active → return as-is without writing an audit event
|
||||||
|
if (target.Activo)
|
||||||
|
return new MedioStatusDto(target.Id, target.Codigo, target.Activo);
|
||||||
|
|
||||||
|
var updated = target.WithActivo(true);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "medio.reactivate",
|
||||||
|
targetType: "Medio",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new MedioStatusDto(updated.Id, updated.Codigo, updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs
Normal file
11
src/api/SIGCM2.Application/Medios/Update/MedioUpdatedDto.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Update;
|
||||||
|
|
||||||
|
public sealed record MedioUpdatedDto(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
TipoMedio Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateMedioCommandHandler : ICommandHandler<UpdateMedioCommand, MedioUpdatedDto>
|
||||||
|
{
|
||||||
|
private readonly IMedioRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public UpdateMedioCommandHandler(IMedioRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MedioUpdatedDto> Handle(UpdateMedioCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new MedioNotFoundException(command.Id);
|
||||||
|
|
||||||
|
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo, command.PlataformaEmpresaId);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "medio.update",
|
||||||
|
targetType: "Medio",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new { target.Nombre, target.Tipo, target.PlataformaEmpresaId },
|
||||||
|
after = new { updated.Nombre, updated.Tipo, updated.PlataformaEmpresaId },
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new MedioUpdatedDto(
|
||||||
|
Id: updated.Id,
|
||||||
|
Codigo: updated.Codigo,
|
||||||
|
Nombre: updated.Nombre,
|
||||||
|
Tipo: updated.Tipo,
|
||||||
|
PlataformaEmpresaId: updated.PlataformaEmpresaId,
|
||||||
|
Activo: updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Medios.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateMedioCommandValidator : AbstractValidator<UpdateMedioCommand>
|
||||||
|
{
|
||||||
|
private const int NombreMaxLength = 100;
|
||||||
|
|
||||||
|
public UpdateMedioCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||||
|
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Tipo)
|
||||||
|
.IsInEnum().WithMessage("El tipo de medio no es válido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.Create;
|
||||||
|
|
||||||
|
public sealed record CreateSeccionCommand(
|
||||||
|
int MedioId,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string Tipo);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Create;
|
||||||
|
|
||||||
|
public sealed class CreateSeccionCommandHandler : ICommandHandler<CreateSeccionCommand, SeccionCreatedDto>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
private readonly IMedioRepository _medioRepo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public CreateSeccionCommandHandler(ISeccionRepository repo, IMedioRepository medioRepo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_medioRepo = medioRepo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeccionCreatedDto> Handle(CreateSeccionCommand command)
|
||||||
|
{
|
||||||
|
// Validate medio exists and is active (REQ-SEC-001)
|
||||||
|
var medio = await _medioRepo.GetByIdAsync(command.MedioId);
|
||||||
|
if (medio is null || !medio.Activo)
|
||||||
|
throw new MedioNotFoundException(command.MedioId);
|
||||||
|
|
||||||
|
var exists = await _repo.ExistsByCodigoInMedioAsync(command.MedioId, command.Codigo);
|
||||||
|
if (exists)
|
||||||
|
throw new SeccionCodigoDuplicadoEnMedioException(command.MedioId, command.Codigo);
|
||||||
|
|
||||||
|
var seccion = Seccion.ForCreation(command.MedioId, command.Codigo, command.Nombre, command.Tipo);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
var newId = await _repo.AddAsync(seccion);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "seccion.create",
|
||||||
|
targetType: "Seccion",
|
||||||
|
targetId: newId.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
after = new
|
||||||
|
{
|
||||||
|
seccion.MedioId,
|
||||||
|
seccion.Codigo,
|
||||||
|
seccion.Nombre,
|
||||||
|
seccion.Tipo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new SeccionCreatedDto(
|
||||||
|
Id: newId,
|
||||||
|
MedioId: seccion.MedioId,
|
||||||
|
Codigo: seccion.Codigo,
|
||||||
|
Nombre: seccion.Nombre,
|
||||||
|
Tipo: seccion.Tipo,
|
||||||
|
Activo: seccion.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Create;
|
||||||
|
|
||||||
|
public sealed class CreateSeccionCommandValidator : AbstractValidator<CreateSeccionCommand>
|
||||||
|
{
|
||||||
|
private const int CodigoMaxLength = 30;
|
||||||
|
private const int NombreMaxLength = 100;
|
||||||
|
|
||||||
|
public CreateSeccionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.MedioId)
|
||||||
|
.GreaterThan(0).WithMessage("El medioId debe ser mayor a 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Codigo)
|
||||||
|
.NotEmpty().WithMessage("El código es requerido.")
|
||||||
|
.MaximumLength(CodigoMaxLength).WithMessage($"El código no puede superar los {CodigoMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||||
|
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Tipo)
|
||||||
|
.NotEmpty().WithMessage("El tipo es requerido.")
|
||||||
|
.Must(t => TipoSeccion.AllowedTipos.Contains(t))
|
||||||
|
.WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
|
||||||
|
public sealed record DeactivateSeccionCommand(int Id);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
|
||||||
|
public sealed class DeactivateSeccionCommandHandler : ICommandHandler<DeactivateSeccionCommand, SeccionStatusDto>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public DeactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeccionStatusDto> Handle(DeactivateSeccionCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new SeccionNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// Idempotent: already inactive → return as-is without writing an audit event
|
||||||
|
if (!target.Activo)
|
||||||
|
return new SeccionStatusDto(target.Id, target.Codigo, target.Activo);
|
||||||
|
|
||||||
|
var updated = target.WithActivo(false);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "seccion.deactivate",
|
||||||
|
targetType: "Seccion",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
|
||||||
|
public sealed record SeccionStatusDto(int Id, string Codigo, bool Activo);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.GetById;
|
||||||
|
|
||||||
|
public sealed record GetSeccionByIdQuery(int Id);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.GetById;
|
||||||
|
|
||||||
|
public sealed class GetSeccionByIdQueryHandler : ICommandHandler<GetSeccionByIdQuery, SeccionDetailDto>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
|
||||||
|
public GetSeccionByIdQueryHandler(ISeccionRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeccionDetailDto> Handle(GetSeccionByIdQuery query)
|
||||||
|
{
|
||||||
|
var seccion = await _repo.GetByIdAsync(query.Id)
|
||||||
|
?? throw new SeccionNotFoundException(query.Id);
|
||||||
|
|
||||||
|
return new SeccionDetailDto(
|
||||||
|
Id: seccion.Id,
|
||||||
|
MedioId: seccion.MedioId,
|
||||||
|
Codigo: seccion.Codigo,
|
||||||
|
Nombre: seccion.Nombre,
|
||||||
|
Tipo: seccion.Tipo,
|
||||||
|
Activo: seccion.Activo,
|
||||||
|
FechaCreacion: seccion.FechaCreacion,
|
||||||
|
FechaModificacion: seccion.FechaModificacion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.List;
|
||||||
|
|
||||||
|
public sealed class ListSeccionesQueryHandler : ICommandHandler<ListSeccionesQuery, PagedResult<SeccionListItemDto>>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
|
||||||
|
public ListSeccionesQueryHandler(ISeccionRepository repo)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<SeccionListItemDto>> Handle(ListSeccionesQuery query)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, query.Page);
|
||||||
|
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||||
|
|
||||||
|
var repoQuery = new SeccionesQuery(page, pageSize, query.MedioId, query.Tipo, query.Activo, query.Search);
|
||||||
|
var paged = await _repo.GetPagedAsync(repoQuery);
|
||||||
|
|
||||||
|
var items = paged.Items
|
||||||
|
.Select(s => new SeccionListItemDto(s.Id, s.MedioId, s.Codigo, s.Nombre, s.Tipo, s.Activo))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new PagedResult<SeccionListItemDto>(items, paged.Page, paged.PageSize, paged.Total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.Reactivate;
|
||||||
|
|
||||||
|
public sealed record ReactivateSeccionCommand(int Id);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Application.Secciones.Deactivate;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Reactivate;
|
||||||
|
|
||||||
|
public sealed class ReactivateSeccionCommandHandler : ICommandHandler<ReactivateSeccionCommand, SeccionStatusDto>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public ReactivateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeccionStatusDto> Handle(ReactivateSeccionCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new SeccionNotFoundException(command.Id);
|
||||||
|
|
||||||
|
// Idempotent: already active → return as-is without writing an audit event
|
||||||
|
if (target.Activo)
|
||||||
|
return new SeccionStatusDto(target.Id, target.Codigo, target.Activo);
|
||||||
|
|
||||||
|
var updated = target.WithActivo(true);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "seccion.reactivate",
|
||||||
|
targetType: "Seccion",
|
||||||
|
targetId: command.Id.ToString());
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new SeccionStatusDto(updated.Id, updated.Codigo, updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace SIGCM2.Application.Secciones.Update;
|
||||||
|
|
||||||
|
public sealed record UpdateSeccionCommand(
|
||||||
|
int Id,
|
||||||
|
string Nombre,
|
||||||
|
string Tipo);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Transactions;
|
||||||
|
using SIGCM2.Application.Abstractions;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Audit;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
using SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateSeccionCommandHandler : ICommandHandler<UpdateSeccionCommand, SeccionUpdatedDto>
|
||||||
|
{
|
||||||
|
private readonly ISeccionRepository _repo;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
|
||||||
|
public UpdateSeccionCommandHandler(ISeccionRepository repo, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
_repo = repo;
|
||||||
|
_audit = audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeccionUpdatedDto> Handle(UpdateSeccionCommand command)
|
||||||
|
{
|
||||||
|
var target = await _repo.GetByIdAsync(command.Id)
|
||||||
|
?? throw new SeccionNotFoundException(command.Id);
|
||||||
|
|
||||||
|
var updated = target.WithUpdatedProfile(command.Nombre, command.Tipo);
|
||||||
|
|
||||||
|
using var tx = new TransactionScope(
|
||||||
|
TransactionScopeOption.Required,
|
||||||
|
new TransactionOptions { IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted },
|
||||||
|
TransactionScopeAsyncFlowOption.Enabled);
|
||||||
|
|
||||||
|
await _repo.UpdateAsync(updated);
|
||||||
|
|
||||||
|
await _audit.LogAsync(
|
||||||
|
action: "seccion.update",
|
||||||
|
targetType: "Seccion",
|
||||||
|
targetId: command.Id.ToString(),
|
||||||
|
metadata: new
|
||||||
|
{
|
||||||
|
before = new { target.Nombre, target.Tipo },
|
||||||
|
after = new { updated.Nombre, updated.Tipo },
|
||||||
|
});
|
||||||
|
|
||||||
|
tx.Complete();
|
||||||
|
|
||||||
|
return new SeccionUpdatedDto(
|
||||||
|
Id: updated.Id,
|
||||||
|
MedioId: updated.MedioId,
|
||||||
|
Codigo: updated.Codigo,
|
||||||
|
Nombre: updated.Nombre,
|
||||||
|
Tipo: updated.Tipo,
|
||||||
|
Activo: updated.Activo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Application.Secciones.Update;
|
||||||
|
|
||||||
|
public sealed class UpdateSeccionCommandValidator : AbstractValidator<UpdateSeccionCommand>
|
||||||
|
{
|
||||||
|
private const int NombreMaxLength = 100;
|
||||||
|
|
||||||
|
public UpdateSeccionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("El id debe ser mayor a 0.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Nombre)
|
||||||
|
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||||
|
.MaximumLength(NombreMaxLength).WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Tipo)
|
||||||
|
.NotEmpty().WithMessage("El tipo es requerido.")
|
||||||
|
.Must(t => TipoSeccion.AllowedTipos.Contains(t))
|
||||||
|
.WithMessage($"El tipo debe ser uno de: {string.Join(", ", TipoSeccion.AllowedTipos)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/api/SIGCM2.Domain/Entities/Medio.cs
Normal file
75
src/api/SIGCM2.Domain/Entities/Medio.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class Medio
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
public string Codigo { get; }
|
||||||
|
public string Nombre { get; }
|
||||||
|
public TipoMedio Tipo { get; }
|
||||||
|
public int? PlataformaEmpresaId { get; }
|
||||||
|
public bool Activo { get; }
|
||||||
|
public DateTime FechaCreacion { get; }
|
||||||
|
public DateTime? FechaModificacion { get; }
|
||||||
|
|
||||||
|
public Medio(
|
||||||
|
int id,
|
||||||
|
string codigo,
|
||||||
|
string nombre,
|
||||||
|
TipoMedio tipo,
|
||||||
|
int? plataformaEmpresaId,
|
||||||
|
bool activo,
|
||||||
|
DateTime fechaCreacion,
|
||||||
|
DateTime? fechaModificacion)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Codigo = codigo;
|
||||||
|
Nombre = nombre;
|
||||||
|
Tipo = tipo;
|
||||||
|
PlataformaEmpresaId = plataformaEmpresaId;
|
||||||
|
Activo = activo;
|
||||||
|
FechaCreacion = fechaCreacion;
|
||||||
|
FechaModificacion = fechaModificacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for creating a new Medio (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default).
|
||||||
|
/// </summary>
|
||||||
|
public static Medio ForCreation(string codigo, string nombre, TipoMedio tipo, int? plataformaEmpresaId)
|
||||||
|
{
|
||||||
|
return new Medio(
|
||||||
|
id: 0,
|
||||||
|
codigo: codigo,
|
||||||
|
nombre: nombre,
|
||||||
|
tipo: tipo,
|
||||||
|
plataformaEmpresaId: plataformaEmpresaId,
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: default,
|
||||||
|
fechaModificacion: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with updated fields. Codigo is immutable (use BD UQ to enforce).
|
||||||
|
/// Sets FechaModificacion = UtcNow.
|
||||||
|
/// </summary>
|
||||||
|
public Medio WithUpdatedProfile(string nombre, TipoMedio tipo, int? plataformaEmpresaId)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
codigo: Codigo,
|
||||||
|
nombre: nombre,
|
||||||
|
tipo: tipo,
|
||||||
|
plataformaEmpresaId: plataformaEmpresaId,
|
||||||
|
activo: Activo,
|
||||||
|
fechaCreacion: FechaCreacion,
|
||||||
|
fechaModificacion: DateTime.UtcNow);
|
||||||
|
|
||||||
|
public Medio WithActivo(bool activo)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
codigo: Codigo,
|
||||||
|
nombre: Nombre,
|
||||||
|
tipo: Tipo,
|
||||||
|
plataformaEmpresaId: PlataformaEmpresaId,
|
||||||
|
activo: activo,
|
||||||
|
fechaCreacion: FechaCreacion,
|
||||||
|
fechaModificacion: DateTime.UtcNow);
|
||||||
|
}
|
||||||
72
src/api/SIGCM2.Domain/Entities/Seccion.cs
Normal file
72
src/api/SIGCM2.Domain/Entities/Seccion.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
public sealed class Seccion
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
public int MedioId { get; }
|
||||||
|
public string Codigo { get; }
|
||||||
|
public string Nombre { get; }
|
||||||
|
public string Tipo { get; } // 'clasificados' | 'notables' | 'suplementos' — enforzado por CHECK en BD
|
||||||
|
public bool Activo { get; }
|
||||||
|
public DateTime FechaCreacion { get; }
|
||||||
|
public DateTime? FechaModificacion { get; }
|
||||||
|
|
||||||
|
public Seccion(
|
||||||
|
int id,
|
||||||
|
int medioId,
|
||||||
|
string codigo,
|
||||||
|
string nombre,
|
||||||
|
string tipo,
|
||||||
|
bool activo,
|
||||||
|
DateTime fechaCreacion,
|
||||||
|
DateTime? fechaModificacion)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
MedioId = medioId;
|
||||||
|
Codigo = codigo;
|
||||||
|
Nombre = nombre;
|
||||||
|
Tipo = tipo;
|
||||||
|
Activo = activo;
|
||||||
|
FechaCreacion = fechaCreacion;
|
||||||
|
FechaModificacion = fechaModificacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Seccion ForCreation(int medioId, string codigo, string nombre, string tipo)
|
||||||
|
{
|
||||||
|
return new Seccion(
|
||||||
|
id: 0,
|
||||||
|
medioId: medioId,
|
||||||
|
codigo: codigo,
|
||||||
|
nombre: nombre,
|
||||||
|
tipo: tipo,
|
||||||
|
activo: true,
|
||||||
|
fechaCreacion: default,
|
||||||
|
fechaModificacion: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new instance with updated fields. MedioId and Codigo are immutable.
|
||||||
|
/// Sets FechaModificacion = UtcNow.
|
||||||
|
/// </summary>
|
||||||
|
public Seccion WithUpdatedProfile(string nombre, string tipo)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
medioId: MedioId,
|
||||||
|
codigo: Codigo,
|
||||||
|
nombre: nombre,
|
||||||
|
tipo: tipo,
|
||||||
|
activo: Activo,
|
||||||
|
fechaCreacion: FechaCreacion,
|
||||||
|
fechaModificacion: DateTime.UtcNow);
|
||||||
|
|
||||||
|
public Seccion WithActivo(bool activo)
|
||||||
|
=> new(
|
||||||
|
id: Id,
|
||||||
|
medioId: MedioId,
|
||||||
|
codigo: Codigo,
|
||||||
|
nombre: Nombre,
|
||||||
|
tipo: Tipo,
|
||||||
|
activo: activo,
|
||||||
|
fechaCreacion: FechaCreacion,
|
||||||
|
fechaModificacion: DateTime.UtcNow);
|
||||||
|
}
|
||||||
9
src/api/SIGCM2.Domain/Entities/TipoMedio.cs
Normal file
9
src/api/SIGCM2.Domain/Entities/TipoMedio.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
public enum TipoMedio
|
||||||
|
{
|
||||||
|
Diario = 1,
|
||||||
|
Radio = 2,
|
||||||
|
Web = 3,
|
||||||
|
Poster = 4,
|
||||||
|
}
|
||||||
10
src/api/SIGCM2.Domain/Entities/TipoSeccion.cs
Normal file
10
src/api/SIGCM2.Domain/Entities/TipoSeccion.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allowed string values for Seccion.Tipo.
|
||||||
|
/// Enforced at application layer (validator) and database layer (CHECK constraint).
|
||||||
|
/// </summary>
|
||||||
|
public static class TipoSeccion
|
||||||
|
{
|
||||||
|
public static readonly string[] AllowedTipos = { "clasificados", "notables", "suplementos" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when attempting to create a Medio with a Codigo that already exists (global UQ).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MedioCodigoDuplicadoException : DomainException
|
||||||
|
{
|
||||||
|
public string Codigo { get; }
|
||||||
|
|
||||||
|
public MedioCodigoDuplicadoException(string codigo)
|
||||||
|
: base($"El medio con código '{codigo}' ya existe.")
|
||||||
|
{
|
||||||
|
Codigo = codigo;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/MedioNotFoundException.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested Medio does not exist in the system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MedioNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public MedioNotFoundException(int id)
|
||||||
|
: base($"El medio con id '{id}' no existe.")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when attempting to create a Seccion with a Codigo that already exists within the same Medio
|
||||||
|
/// (composite UQ on MedioId + Codigo).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SeccionCodigoDuplicadoEnMedioException : DomainException
|
||||||
|
{
|
||||||
|
public int MedioId { get; }
|
||||||
|
public string Codigo { get; }
|
||||||
|
|
||||||
|
public SeccionCodigoDuplicadoEnMedioException(int medioId, string codigo)
|
||||||
|
: base($"La sección con código '{codigo}' ya existe para el medio {medioId}.")
|
||||||
|
{
|
||||||
|
MedioId = medioId;
|
||||||
|
Codigo = codigo;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs
Normal file
15
src/api/SIGCM2.Domain/Exceptions/SeccionNotFoundException.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SIGCM2.Domain.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a requested Seccion does not exist in the system.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SeccionNotFoundException : DomainException
|
||||||
|
{
|
||||||
|
public int Id { get; }
|
||||||
|
|
||||||
|
public SeccionNotFoundException(int id)
|
||||||
|
: base($"La sección con id '{id}' no existe.")
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IRolRepository, RolRepository>();
|
services.AddScoped<IRolRepository, RolRepository>();
|
||||||
services.AddScoped<IPermisoRepository, PermisoRepository>();
|
services.AddScoped<IPermisoRepository, PermisoRepository>();
|
||||||
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
services.AddScoped<IRolPermisoRepository, RolPermisoRepository>();
|
||||||
|
services.AddScoped<IMedioRepository, MedioRepository>();
|
||||||
|
services.AddScoped<ISeccionRepository, SeccionRepository>();
|
||||||
|
|
||||||
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
// JWT Options — bound lazily via IOptions so tests can override via ConfigureWebHost
|
||||||
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
services.Configure<JwtOptions>(configuration.GetSection("Jwt"));
|
||||||
|
|||||||
188
src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs
Normal file
188
src/api/SIGCM2.Infrastructure/Persistence/MedioRepository.cs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class MedioRepository : IMedioRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public MedioRepository(SqlConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddAsync(Medio m, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.Medio (Codigo, Nombre, Tipo, PlataformaEmpresaId)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES (@Codigo, @Nombre, @Tipo, @PlataformaEmpresaId)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
m.Codigo,
|
||||||
|
m.Nombre,
|
||||||
|
Tipo = (int)m.Tipo,
|
||||||
|
m.PlataformaEmpresaId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Medio
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<MedioRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1) FROM dbo.Medio WHERE Codigo = @Codigo
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Medio m, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Medio
|
||||||
|
SET Nombre = @Nombre,
|
||||||
|
Tipo = @Tipo,
|
||||||
|
PlataformaEmpresaId = @PlataformaEmpresaId,
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = @FechaModificacion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
m.Nombre,
|
||||||
|
Tipo = (int)m.Tipo,
|
||||||
|
m.PlataformaEmpresaId,
|
||||||
|
m.Activo,
|
||||||
|
FechaModificacion = m.FechaModificacion ?? DateTime.UtcNow,
|
||||||
|
m.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, q.Page);
|
||||||
|
var pageSize = Math.Clamp(q.PageSize, 1, 100);
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var where = new StringBuilder("WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("PageSize", pageSize);
|
||||||
|
parameters.Add("Offset", offset);
|
||||||
|
|
||||||
|
if (q.Activo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Activo = @Activo");
|
||||||
|
parameters.Add("Activo", q.Activo.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.Tipo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Tipo = @Tipo");
|
||||||
|
parameters.Add("Tipo", (int)q.Tipo.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(q.Search))
|
||||||
|
{
|
||||||
|
where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)");
|
||||||
|
parameters.Add("Search", $"%{q.Search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT
|
||||||
|
Id, Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo, FechaCreacion, FechaModificacion,
|
||||||
|
COUNT(*) OVER() AS TotalCount
|
||||||
|
FROM dbo.Medio
|
||||||
|
{where}
|
||||||
|
ORDER BY Codigo
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<MedioPagedRow>(sql, parameters);
|
||||||
|
var list = rows.ToList();
|
||||||
|
|
||||||
|
var total = list.Count > 0 ? list[0].TotalCount : 0;
|
||||||
|
var items = list.Select(r => MapRow(r)).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<Medio>(items, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Medio MapRow(MedioRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
tipo: (TipoMedio)r.Tipo,
|
||||||
|
plataformaEmpresaId: r.PlataformaEmpresaId,
|
||||||
|
activo: r.Activo,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private static Medio MapRow(MedioPagedRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
tipo: (TipoMedio)r.Tipo,
|
||||||
|
plataformaEmpresaId: r.PlataformaEmpresaId,
|
||||||
|
activo: r.Activo,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record MedioRow(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
byte Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record MedioPagedRow(
|
||||||
|
int Id,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
byte Tipo,
|
||||||
|
int? PlataformaEmpresaId,
|
||||||
|
bool Activo,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
int TotalCount);
|
||||||
|
}
|
||||||
197
src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs
Normal file
197
src/api/SIGCM2.Infrastructure/Persistence/SeccionRepository.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using SIGCM2.Application.Abstractions.Persistence;
|
||||||
|
using SIGCM2.Application.Common;
|
||||||
|
using SIGCM2.Domain.Entities;
|
||||||
|
|
||||||
|
namespace SIGCM2.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class SeccionRepository : ISeccionRepository
|
||||||
|
{
|
||||||
|
private readonly SqlConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public SeccionRepository(SqlConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
_connectionFactory = connectionFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddAsync(Seccion s, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
|
||||||
|
// FK_Seccion_Medio: if MedioId does not exist, SQL Server raises FK violation — let it bubble.
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.Seccion (MedioId, Codigo, Nombre, Tipo)
|
||||||
|
OUTPUT INSERTED.Id
|
||||||
|
VALUES (@MedioId, @Codigo, @Nombre, @Tipo)
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||||
|
{
|
||||||
|
s.MedioId,
|
||||||
|
s.Codigo,
|
||||||
|
s.Nombre,
|
||||||
|
s.Tipo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Seccion?> GetByIdAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion
|
||||||
|
FROM dbo.Seccion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var row = await connection.QuerySingleOrDefaultAsync<SeccionRow>(sql, new { Id = id });
|
||||||
|
return row is null ? null : MapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByCodigoInMedioAsync(int medioId, string codigo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT COUNT(1) FROM dbo.Seccion WHERE MedioId = @MedioId AND Codigo = @Codigo
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var count = await connection.ExecuteScalarAsync<int>(sql, new { MedioId = medioId, Codigo = codigo });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Seccion s, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.Seccion
|
||||||
|
SET Nombre = @Nombre,
|
||||||
|
Tipo = @Tipo,
|
||||||
|
Activo = @Activo,
|
||||||
|
FechaModificacion = @FechaModificacion
|
||||||
|
WHERE Id = @Id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
await connection.ExecuteAsync(sql, new
|
||||||
|
{
|
||||||
|
s.Nombre,
|
||||||
|
s.Tipo,
|
||||||
|
s.Activo,
|
||||||
|
FechaModificacion = s.FechaModificacion ?? DateTime.UtcNow,
|
||||||
|
s.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedResult<Seccion>> GetPagedAsync(SeccionesQuery q, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var page = Math.Max(1, q.Page);
|
||||||
|
var pageSize = Math.Clamp(q.PageSize, 1, 100);
|
||||||
|
var offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
var where = new StringBuilder("WHERE 1=1");
|
||||||
|
var parameters = new DynamicParameters();
|
||||||
|
parameters.Add("PageSize", pageSize);
|
||||||
|
parameters.Add("Offset", offset);
|
||||||
|
|
||||||
|
if (q.MedioId.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND MedioId = @MedioId");
|
||||||
|
parameters.Add("MedioId", q.MedioId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(q.Tipo))
|
||||||
|
{
|
||||||
|
where.Append(" AND Tipo = @Tipo");
|
||||||
|
parameters.Add("Tipo", q.Tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.Activo.HasValue)
|
||||||
|
{
|
||||||
|
where.Append(" AND Activo = @Activo");
|
||||||
|
parameters.Add("Activo", q.Activo.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(q.Search))
|
||||||
|
{
|
||||||
|
where.Append(" AND (Codigo LIKE @Search OR Nombre LIKE @Search)");
|
||||||
|
parameters.Add("Search", $"%{q.Search}%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADM-001: filter only Seccion.Activo; Medio.Activo check is left to Application/UI layer.
|
||||||
|
// Joining Medio to filter on m.Activo would affect performance for large catalogs and is
|
||||||
|
// not required by the current specs. REQ-SEC-003 (Deactivate Medio hides Secciones) is
|
||||||
|
// enforced at the Application handler level, not the query level.
|
||||||
|
var sql = $"""
|
||||||
|
SELECT
|
||||||
|
Id, MedioId, Codigo, Nombre, Tipo, Activo, FechaCreacion, FechaModificacion,
|
||||||
|
COUNT(*) OVER() AS TotalCount
|
||||||
|
FROM dbo.Seccion
|
||||||
|
{where}
|
||||||
|
ORDER BY MedioId, Codigo
|
||||||
|
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
|
||||||
|
var rows = await connection.QueryAsync<SeccionPagedRow>(sql, parameters);
|
||||||
|
var list = rows.ToList();
|
||||||
|
|
||||||
|
var total = list.Count > 0 ? list[0].TotalCount : 0;
|
||||||
|
var items = list.Select(r => MapRow(r)).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<Seccion>(items, page, pageSize, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Seccion MapRow(SeccionRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
medioId: r.MedioId,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
tipo: r.Tipo,
|
||||||
|
activo: r.Activo,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private static Seccion MapRow(SeccionPagedRow r)
|
||||||
|
=> new(
|
||||||
|
id: r.Id,
|
||||||
|
medioId: r.MedioId,
|
||||||
|
codigo: r.Codigo,
|
||||||
|
nombre: r.Nombre,
|
||||||
|
tipo: r.Tipo,
|
||||||
|
activo: r.Activo,
|
||||||
|
fechaCreacion: r.FechaCreacion,
|
||||||
|
fechaModificacion: r.FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record SeccionRow(
|
||||||
|
int Id,
|
||||||
|
int MedioId,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string Tipo,
|
||||||
|
bool Activo,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion);
|
||||||
|
|
||||||
|
private sealed record SeccionPagedRow(
|
||||||
|
int Id,
|
||||||
|
int MedioId,
|
||||||
|
string Codigo,
|
||||||
|
string Nombre,
|
||||||
|
string Tipo,
|
||||||
|
bool Activo,
|
||||||
|
DateTime FechaCreacion,
|
||||||
|
DateTime? FechaModificacion,
|
||||||
|
int TotalCount);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
FileClock,
|
FileClock,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
|
Newspaper,
|
||||||
|
Columns3,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -47,6 +49,18 @@ const adminItems: NavItem[] = [
|
|||||||
icon: FileClock,
|
icon: FileClock,
|
||||||
requiredPermission: 'administracion:auditoria:ver',
|
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 {
|
interface SidebarNavProps {
|
||||||
|
|||||||
7
src/web/src/features/medios/api/createMedio.ts
Normal file
7
src/web/src/features/medios/api/createMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateMedioRequest, MedioCreated } from '../types'
|
||||||
|
|
||||||
|
export async function createMedio(payload: CreateMedioRequest): Promise<MedioCreated> {
|
||||||
|
const response = await axiosClient.post<MedioCreated>('/api/v1/admin/medios', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/medios/api/deactivateMedio.ts
Normal file
5
src/web/src/features/medios/api/deactivateMedio.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateMedio(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/medios/${id}/deactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/medios/api/getMedio.ts
Normal file
7
src/web/src/features/medios/api/getMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getMedio(id: number): Promise<MedioDetail> {
|
||||||
|
const response = await axiosClient.get<MedioDetail>(`/api/v1/admin/medios/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
17
src/web/src/features/medios/api/listMedios.ts
Normal file
17
src/web/src/features/medios/api/listMedios.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioListItem, MediosQuery, PagedResult } from '../types'
|
||||||
|
|
||||||
|
export async function listMedios(query: MediosQuery): Promise<PagedResult<MedioListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.tipo !== undefined) params.set('tipo', String(query.tipo))
|
||||||
|
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<MedioListItem>>(
|
||||||
|
'/api/v1/admin/medios',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/medios/api/reactivateMedio.ts
Normal file
5
src/web/src/features/medios/api/reactivateMedio.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function reactivateMedio(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/medios/${id}/reactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/medios/api/updateMedio.ts
Normal file
7
src/web/src/features/medios/api/updateMedio.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { MedioDetail, UpdateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export async function updateMedio(id: number, payload: UpdateMedioRequest): Promise<MedioDetail> {
|
||||||
|
const response = await axiosClient.put<MedioDetail>(`/api/v1/admin/medios/${id}`, payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDeactivateMedio } from '../hooks/useDeactivateMedio'
|
||||||
|
import { useReactivateMedio } from '../hooks/useReactivateMedio'
|
||||||
|
|
||||||
|
interface DeactivateMedioModalProps {
|
||||||
|
medioId: number
|
||||||
|
medioNombre: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeactivateMedioModal({ medioId, medioNombre, activo }: DeactivateMedioModalProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { mutate: deactivate, isPending: deactivating } = useDeactivateMedio()
|
||||||
|
const { mutate: reactivate, isPending: reactivating } = useReactivateMedio()
|
||||||
|
|
||||||
|
const isPending = deactivating || reactivating
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (activo) {
|
||||||
|
deactivate(medioId, { onSuccess: () => setOpen(false) })
|
||||||
|
} else {
|
||||||
|
reactivate(medioId, { onSuccess: () => setOpen(false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activo ? 'Desactivar medio' : 'Reactivar medio'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activo
|
||||||
|
? `¿Confirmás que querés desactivar el medio "${medioNombre}"? El medio no podrá usarse hasta que sea reactivado.`
|
||||||
|
: `¿Confirmás que querés reactivar el medio "${medioNombre}"?`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
src/web/src/features/medios/components/MedioForm.tsx
Normal file
188
src/web/src/features/medios/components/MedioForm.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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 { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
|
||||||
|
import type { MedioDetail } from '../types'
|
||||||
|
|
||||||
|
const medioFormSchema = z.object({
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.max(20, 'Máximo 20 caracteres'),
|
||||||
|
nombre: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'Máximo 100 caracteres'),
|
||||||
|
tipo: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un tipo válido'),
|
||||||
|
plataformaEmpresaId: z
|
||||||
|
.union([z.coerce.number().int().positive('Debe ser un número positivo'), z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v === '' || v === undefined ? null : Number(v))),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type MedioFormValues = z.infer<typeof medioFormSchema>
|
||||||
|
|
||||||
|
interface MedioFormProps {
|
||||||
|
/** If provided, the form is in edit mode (código is disabled, form is pre-filled). */
|
||||||
|
initialData?: MedioDetail
|
||||||
|
isPending: boolean
|
||||||
|
error: unknown
|
||||||
|
onSubmit: (values: MedioFormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'medio_codigo_duplicado') {
|
||||||
|
return data.message ?? 'Ya existe un medio con ese código'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar el medio'
|
||||||
|
}
|
||||||
|
return 'Error al guardar el medio'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedioForm({ initialData, isPending, error, onSubmit }: MedioFormProps) {
|
||||||
|
const isEdit = !!initialData
|
||||||
|
|
||||||
|
const form = useForm<MedioFormValues>({
|
||||||
|
resolver: zodResolver(medioFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
codigo: initialData?.codigo ?? '',
|
||||||
|
nombre: initialData?.nombre ?? '',
|
||||||
|
tipo: initialData?.tipo ?? ('' as unknown as number),
|
||||||
|
plataformaEmpresaId: (initialData?.plataformaEmpresaId ?? '') as unknown as undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset({
|
||||||
|
codigo: initialData.codigo,
|
||||||
|
nombre: initialData.nombre,
|
||||||
|
tipo: initialData.tipo,
|
||||||
|
plataformaEmpresaId: (initialData.plataformaEmpresaId ?? '') as unknown as undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="Ej: DIARIO01"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Nombre del medio"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tipo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tipo"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un tipo</option>
|
||||||
|
{TIPO_MEDIO_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="plataformaEmpresaId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Plataforma Empresa ID (opcional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="ID numérico (opcional)"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear medio'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/web/src/features/medios/components/MediosTable.tsx
Normal file
103
src/web/src/features/medios/components/MediosTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import type { MedioListItem } from '../types'
|
||||||
|
import { tipoMedioLabel } from '../tipoMedio'
|
||||||
|
import { DeactivateMedioModal } from './DeactivateMedioModal'
|
||||||
|
|
||||||
|
interface MediosTableProps {
|
||||||
|
rows: MedioListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediosTable({ rows }: MediosTableProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<MedioListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'codigo',
|
||||||
|
header: 'Código',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.codigo}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nombre',
|
||||||
|
header: 'Nombre',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'tipo',
|
||||||
|
header: 'Tipo',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary">{tipoMedioLabel(row.original.tipo)}</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'plataformaEmpresaId',
|
||||||
|
header: 'Plataforma ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.original.plataformaEmpresaId ?? '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/medios/${row.original.id}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateMedioModal
|
||||||
|
medioId={row.original.id}
|
||||||
|
medioNombre={row.original.nombre}
|
||||||
|
activo={row.original.activo}
|
||||||
|
/>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => navigate(`/admin/medios/${row.id}`)}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron medios con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useCreateMedio.ts
Normal file
13
src/web/src/features/medios/hooks/useCreateMedio.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createMedio } from '../api/createMedio'
|
||||||
|
import type { CreateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateMedioRequest) => createMedio(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/medios/hooks/useDeactivateMedio.ts
Normal file
12
src/web/src/features/medios/hooks/useDeactivateMedio.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateMedio } from '../api/deactivateMedio'
|
||||||
|
|
||||||
|
export function useDeactivateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateMedio(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/web/src/features/medios/hooks/useMedio.ts
Normal file
11
src/web/src/features/medios/hooks/useMedio.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getMedio } from '../api/getMedio'
|
||||||
|
|
||||||
|
export function useMedio(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['medios', 'detail', id],
|
||||||
|
queryFn: () => getMedio(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useMediosList.ts
Normal file
13
src/web/src/features/medios/hooks/useMediosList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listMedios } from '../api/listMedios'
|
||||||
|
import type { MediosQuery } from '../types'
|
||||||
|
|
||||||
|
export const mediosListQueryKey = (query: MediosQuery) => ['medios', 'list', query] as const
|
||||||
|
|
||||||
|
export function useMediosList(query: MediosQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: mediosListQueryKey(query),
|
||||||
|
queryFn: () => listMedios(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/medios/hooks/useReactivateMedio.ts
Normal file
12
src/web/src/features/medios/hooks/useReactivateMedio.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateMedio } from '../api/reactivateMedio'
|
||||||
|
|
||||||
|
export function useReactivateMedio() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateMedio(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/medios/hooks/useUpdateMedio.ts
Normal file
13
src/web/src/features/medios/hooks/useUpdateMedio.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateMedio } from '../api/updateMedio'
|
||||||
|
import type { UpdateMedioRequest } from '../types'
|
||||||
|
|
||||||
|
export function useUpdateMedio(id: number) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateMedioRequest) => updateMedio(id, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['medios'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
50
src/web/src/features/medios/pages/CreateMedioPage.tsx
Normal file
50
src/web/src/features/medios/pages/CreateMedioPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { MedioForm } from '../components/MedioForm'
|
||||||
|
import { useCreateMedio } from '../hooks/useCreateMedio'
|
||||||
|
import type { MedioFormValues } from '../components/MedioForm'
|
||||||
|
|
||||||
|
export function CreateMedioPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mutate, isPending, error } = useCreateMedio()
|
||||||
|
|
||||||
|
function handleSubmit(values: MedioFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
codigo: values.codigo,
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Medio creado correctamente')
|
||||||
|
void navigate('/admin/medios')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Crear Medio</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Completá los datos para registrar un nuevo medio en el sistema.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MedioForm isPending={isPending} error={error} onSubmit={handleSubmit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/web/src/features/medios/pages/EditMedioPage.tsx
Normal file
81
src/web/src/features/medios/pages/EditMedioPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { MedioForm } from '../components/MedioForm'
|
||||||
|
import { useMedio } from '../hooks/useMedio'
|
||||||
|
import { useUpdateMedio } from '../hooks/useUpdateMedio'
|
||||||
|
import type { MedioFormValues } from '../components/MedioForm'
|
||||||
|
|
||||||
|
export function EditMedioPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const medioId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: medio, isLoading } = useMedio(medioId)
|
||||||
|
const { mutate, isPending, error } = useUpdateMedio(medioId)
|
||||||
|
|
||||||
|
function handleSubmit(values: MedioFormValues) {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
nombre: values.nombre,
|
||||||
|
tipo: values.tipo,
|
||||||
|
plataformaEmpresaId: values.plataformaEmpresaId as number | null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Medio actualizado correctamente')
|
||||||
|
void navigate(`/admin/medios/${medioId}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!medio) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Medio no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-xl">Editar Medio</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Editá los datos del medio <strong>{medio.nombre}</strong>.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MedioForm
|
||||||
|
initialData={medio}
|
||||||
|
isPending={isPending}
|
||||||
|
error={error}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
src/web/src/features/medios/pages/MedioDetailPage.tsx
Normal file
97
src/web/src/features/medios/pages/MedioDetailPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import { useMedio } from '../hooks/useMedio'
|
||||||
|
import { DeactivateMedioModal } from '../components/DeactivateMedioModal'
|
||||||
|
import { tipoMedioLabel } from '../tipoMedio'
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MedioDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const medioId = Number(id)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: medio, isLoading } = useMedio(medioId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<span className="text-muted-foreground">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!medio) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
Medio no encontrado.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">{medio.nombre}</h1>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/medios')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-4 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Código</span>
|
||||||
|
<span className="font-mono">{medio.codigo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Tipo</span>
|
||||||
|
<Badge variant="secondary">{tipoMedioLabel(medio.tipo)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Plataforma ID</span>
|
||||||
|
<span>{medio.plataformaEmpresaId ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estado</span>
|
||||||
|
{medio.activo
|
||||||
|
? <Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Activo</Badge>
|
||||||
|
: <Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Inactivo</Badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Creado</span>
|
||||||
|
<span>{formatDate(medio.fechaCreacion)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Modificado</span>
|
||||||
|
<span>{formatDate(medio.fechaModificacion)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/admin/medios/${medioId}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateMedioModal
|
||||||
|
medioId={medioId}
|
||||||
|
medioNombre={medio.nombre}
|
||||||
|
activo={medio.activo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
src/web/src/features/medios/pages/MediosListPage.tsx
Normal file
138
src/web/src/features/medios/pages/MediosListPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
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 { MediosTable } from '../components/MediosTable'
|
||||||
|
import { useMediosList } from '../hooks/useMediosList'
|
||||||
|
import { TIPO_MEDIO_OPTIONS } from '../tipoMedio'
|
||||||
|
|
||||||
|
export function MediosListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [tipo, setTipo] = useState<number | undefined>(undefined)
|
||||||
|
const [activo, setActivo] = useState<boolean | undefined>(undefined)
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const q = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
...(tipo !== undefined ? { tipo } : {}),
|
||||||
|
...(activo !== undefined ? { activo } : {}),
|
||||||
|
...(q ? { q } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useMediosList(query)
|
||||||
|
|
||||||
|
const handleTipoChange = useCallback((value: string) => {
|
||||||
|
setTipo(value === '' ? undefined : Number(value))
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleActivoChange = useCallback((value: string) => {
|
||||||
|
if (value === '') setActivo(undefined)
|
||||||
|
else setActivo(value === 'true')
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchRaw(value)
|
||||||
|
setPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / (data.pageSize || 20)) : 1
|
||||||
|
const hasPrev = page > 1
|
||||||
|
const hasNext = page < totalPages
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold">Medios</h1>
|
||||||
|
<CanPerform permission="administracion:medios:gestionar">
|
||||||
|
<Button onClick={() => navigate('/admin/medios/nuevo')} size="sm">
|
||||||
|
Nuevo medio
|
||||||
|
</Button>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por código, nombre..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar medios"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Tipo"
|
||||||
|
onChange={(e) => handleTipoChange(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{TIPO_MEDIO_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => handleActivoChange(e.target.value)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MediosTable rows={data?.items ?? []} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data ? `${data.total} medio${data.total !== 1 ? 's' : ''}` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
aria-label="Anterior"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<span className="flex items-center px-2 text-sm text-muted-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
src/web/src/features/medios/tipoMedio.ts
Normal file
18
src/web/src/features/medios/tipoMedio.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// TipoMedio enum helper: int on wire → display label
|
||||||
|
export const TIPO_MEDIO_LABELS: Record<number, string> = {
|
||||||
|
1: 'Diario',
|
||||||
|
2: 'Radio',
|
||||||
|
3: 'Web',
|
||||||
|
4: 'Poster',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIPO_MEDIO_OPTIONS = [
|
||||||
|
{ value: 1, label: 'Diario' },
|
||||||
|
{ value: 2, label: 'Radio' },
|
||||||
|
{ value: 3, label: 'Web' },
|
||||||
|
{ value: 4, label: 'Poster' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function tipoMedioLabel(tipo: number): string {
|
||||||
|
return TIPO_MEDIO_LABELS[tipo] ?? String(tipo)
|
||||||
|
}
|
||||||
58
src/web/src/features/medios/types.ts
Normal file
58
src/web/src/features/medios/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// ADM-001 — shared types for medios feature
|
||||||
|
|
||||||
|
export interface MedioListItem {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedioDetail {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
fechaCreacion: string
|
||||||
|
fechaModificacion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MedioCreated {
|
||||||
|
id: number
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId: number | null
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMedioRequest {
|
||||||
|
codigo: string
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMedioRequest {
|
||||||
|
nombre: string
|
||||||
|
tipo: number
|
||||||
|
plataformaEmpresaId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediosQuery {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
activo?: boolean
|
||||||
|
tipo?: number
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/createSeccion.ts
Normal file
7
src/web/src/features/secciones/api/createSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { CreateSeccionRequest, SeccionCreated } from '../types'
|
||||||
|
|
||||||
|
export async function createSeccion(payload: CreateSeccionRequest): Promise<SeccionCreated> {
|
||||||
|
const response = await axiosClient.post<SeccionCreated>('/api/v1/admin/secciones', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/secciones/api/deactivateSeccion.ts
Normal file
5
src/web/src/features/secciones/api/deactivateSeccion.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function deactivateSeccion(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/secciones/${id}/deactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/getSeccion.ts
Normal file
7
src/web/src/features/secciones/api/getSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionDetail } from '../types'
|
||||||
|
|
||||||
|
export async function getSeccion(id: number): Promise<SeccionDetail> {
|
||||||
|
const response = await axiosClient.get<SeccionDetail>(`/api/v1/admin/secciones/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
18
src/web/src/features/secciones/api/listSecciones.ts
Normal file
18
src/web/src/features/secciones/api/listSecciones.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionListItem, SeccionesQuery, PagedResult } from '../types'
|
||||||
|
|
||||||
|
export async function listSecciones(query: SeccionesQuery): Promise<PagedResult<SeccionListItem>> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.page !== undefined) params.set('page', String(query.page))
|
||||||
|
if (query.pageSize !== undefined) params.set('pageSize', String(query.pageSize))
|
||||||
|
if (query.medioId !== undefined) params.set('medioId', String(query.medioId))
|
||||||
|
if (query.tipo !== undefined) params.set('tipo', query.tipo)
|
||||||
|
if (query.activo !== undefined) params.set('activo', String(query.activo))
|
||||||
|
if (query.q !== undefined && query.q !== '') params.set('q', query.q)
|
||||||
|
|
||||||
|
const response = await axiosClient.get<PagedResult<SeccionListItem>>(
|
||||||
|
'/api/v1/admin/secciones',
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
5
src/web/src/features/secciones/api/reactivateSeccion.ts
Normal file
5
src/web/src/features/secciones/api/reactivateSeccion.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
|
||||||
|
export async function reactivateSeccion(id: number): Promise<void> {
|
||||||
|
await axiosClient.post(`/api/v1/admin/secciones/${id}/reactivate`)
|
||||||
|
}
|
||||||
7
src/web/src/features/secciones/api/updateSeccion.ts
Normal file
7
src/web/src/features/secciones/api/updateSeccion.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { axiosClient } from '@/api/axiosClient'
|
||||||
|
import type { SeccionDetail, UpdateSeccionRequest } from '../types'
|
||||||
|
|
||||||
|
export async function updateSeccion(id: number, payload: UpdateSeccionRequest): Promise<SeccionDetail> {
|
||||||
|
const response = await axiosClient.put<SeccionDetail>(`/api/v1/admin/secciones/${id}`, payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDeactivateSeccion } from '../hooks/useDeactivateSeccion'
|
||||||
|
import { useReactivateSeccion } from '../hooks/useReactivateSeccion'
|
||||||
|
|
||||||
|
interface DeactivateSeccionModalProps {
|
||||||
|
seccionId: number
|
||||||
|
seccionNombre: string
|
||||||
|
activo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeactivateSeccionModal({ seccionId, seccionNombre, activo }: DeactivateSeccionModalProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { mutate: deactivate, isPending: deactivating } = useDeactivateSeccion()
|
||||||
|
const { mutate: reactivate, isPending: reactivating } = useReactivateSeccion()
|
||||||
|
|
||||||
|
const isPending = deactivating || reactivating
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (activo) {
|
||||||
|
deactivate(seccionId, { onSuccess: () => setOpen(false) })
|
||||||
|
} else {
|
||||||
|
reactivate(seccionId, { onSuccess: () => setOpen(false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{activo ? 'Desactivar sección' : 'Reactivar sección'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{activo
|
||||||
|
? `¿Confirmás que querés desactivar la sección "${seccionNombre}"?`
|
||||||
|
: `¿Confirmás que querés reactivar la sección "${seccionNombre}"?`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||||
|
{isPending ? 'Procesando...' : activo ? 'Desactivar' : 'Reactivar'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/web/src/features/secciones/components/SeccionForm.tsx
Normal file
195
src/web/src/features/secciones/components/SeccionForm.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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 { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||||
|
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
|
||||||
|
import type { SeccionDetail, TipoSeccion } from '../types'
|
||||||
|
|
||||||
|
const TIPO_SECCION_VALUES = ['clasificados', 'notables', 'suplementos'] as const
|
||||||
|
|
||||||
|
const seccionFormSchema = z.object({
|
||||||
|
medioId: z.coerce.number().refine((v) => v >= 1, 'Seleccioná un medio'),
|
||||||
|
codigo: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El código es requerido')
|
||||||
|
.max(20, 'Máximo 20 caracteres'),
|
||||||
|
nombre: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'El nombre es requerido')
|
||||||
|
.max(100, 'Máximo 100 caracteres'),
|
||||||
|
tipo: z.enum(TIPO_SECCION_VALUES, { errorMap: () => ({ message: 'Seleccioná un tipo válido' }) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SeccionFormValues = z.infer<typeof seccionFormSchema>
|
||||||
|
|
||||||
|
interface SeccionFormProps {
|
||||||
|
initialData?: SeccionDetail
|
||||||
|
isPending: boolean
|
||||||
|
error: unknown
|
||||||
|
onSubmit: (values: SeccionFormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBackendError(err: unknown): string | null {
|
||||||
|
if (!err) return null
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { error?: string; message?: string }
|
||||||
|
if (data.error === 'seccion_codigo_duplicado_en_medio') {
|
||||||
|
return data.message ?? 'Ya existe una sección con ese código en el medio'
|
||||||
|
}
|
||||||
|
return data.message ?? data.error ?? 'Error al guardar la sección'
|
||||||
|
}
|
||||||
|
return 'Error al guardar la sección'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionForm({ initialData, isPending, error, onSubmit }: SeccionFormProps) {
|
||||||
|
const isEdit = !!initialData
|
||||||
|
|
||||||
|
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200 })
|
||||||
|
const medios = mediosData?.items ?? []
|
||||||
|
|
||||||
|
const form = useForm<SeccionFormValues>({
|
||||||
|
resolver: zodResolver(seccionFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
medioId: initialData?.medioId ?? ('' as unknown as number),
|
||||||
|
codigo: initialData?.codigo ?? '',
|
||||||
|
nombre: initialData?.nombre ?? '',
|
||||||
|
tipo: initialData?.tipo ?? ('' as TipoSeccion),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
form.reset({
|
||||||
|
medioId: initialData.medioId,
|
||||||
|
codigo: initialData.codigo,
|
||||||
|
nombre: initialData.nombre,
|
||||||
|
tipo: initialData.tipo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [initialData, form])
|
||||||
|
|
||||||
|
const backendError = resolveBackendError(error)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||||
|
{backendError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{backendError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="medioId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Medio</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
aria-label="Medio"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un medio</option>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="codigo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Código</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending || isEdit}
|
||||||
|
placeholder="Ej: CLASIF01"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nombre"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Nombre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="text"
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="Nombre de la sección"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tipo"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<select
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Tipo de sección"
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Seleccioná un tipo</option>
|
||||||
|
{TIPO_SECCION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isPending} className="w-full">
|
||||||
|
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Crear sección'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
|
import { useMediosList } from '@/features/medios/hooks/useMediosList'
|
||||||
|
import { TIPO_SECCION_OPTIONS } from '../tipoSeccion'
|
||||||
|
import type { TipoSeccion } from '../types'
|
||||||
|
|
||||||
|
interface SeccionesFiltersProps {
|
||||||
|
onMedioIdChange: (medioId: number | undefined) => void
|
||||||
|
onTipoChange: (tipo: TipoSeccion | undefined) => void
|
||||||
|
onActivoChange: (activo: boolean | undefined) => void
|
||||||
|
onSearchChange: (q: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionesFilters({
|
||||||
|
onMedioIdChange,
|
||||||
|
onTipoChange,
|
||||||
|
onActivoChange,
|
||||||
|
onSearchChange,
|
||||||
|
}: SeccionesFiltersProps) {
|
||||||
|
const [searchRaw, setSearchRaw] = useState('')
|
||||||
|
const debouncedSearch = useDebouncedValue(searchRaw, 300)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSearchChange(debouncedSearch)
|
||||||
|
}, [debouncedSearch, onSearchChange])
|
||||||
|
|
||||||
|
// Load medios for the selector
|
||||||
|
const { data: mediosData } = useMediosList({ page: 1, pageSize: 200, activo: true })
|
||||||
|
const medios = mediosData?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por código, nombre..."
|
||||||
|
value={searchRaw}
|
||||||
|
onChange={(e) => setSearchRaw(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
aria-label="Buscar secciones"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Medio"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onMedioIdChange(v === '' ? undefined : Number(v))
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Todos los medios</option>
|
||||||
|
{medios.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Tipo de sección"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onTipoChange(v === '' ? undefined : (v as TipoSeccion))
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">Todos los tipos</option>
|
||||||
|
{TIPO_SECCION_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
aria-label="Estado"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (v === '') 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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="true">Activos</option>
|
||||||
|
<option value="false">Inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/web/src/features/secciones/components/SeccionesTable.tsx
Normal file
103
src/web/src/features/secciones/components/SeccionesTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { DataTable } from '@/components/ui/data-table'
|
||||||
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
|
import type { SeccionListItem } from '../types'
|
||||||
|
import { tipoSeccionLabel } from '../tipoSeccion'
|
||||||
|
import { DeactivateSeccionModal } from './DeactivateSeccionModal'
|
||||||
|
|
||||||
|
interface SeccionesTableProps {
|
||||||
|
rows: SeccionListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeccionesTable({ rows }: SeccionesTableProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<SeccionListItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'codigo',
|
||||||
|
header: 'Código',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.codigo}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'nombre',
|
||||||
|
header: 'Nombre',
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'tipo',
|
||||||
|
header: 'Tipo',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="secondary" className="capitalize">
|
||||||
|
{tipoSeccionLabel(row.original.tipo)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'medioId',
|
||||||
|
header: 'Medio ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">{row.original.medioId}</span>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'activo',
|
||||||
|
header: 'Estado',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.activo ? (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Activo
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||||
|
Inactivo
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { priority: 'medium' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'acciones',
|
||||||
|
header: 'Acciones',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CanPerform permission="administracion:secciones:gestionar">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/secciones/${row.original.id}/edit`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<DeactivateSeccionModal
|
||||||
|
seccionId={row.original.id}
|
||||||
|
seccionNombre={row.original.nombre}
|
||||||
|
activo={row.original.activo}
|
||||||
|
/>
|
||||||
|
</CanPerform>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { priority: 'high' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
onRowClick={(row) => navigate(`/admin/secciones/${row.id}`)}
|
||||||
|
getRowId={(row) => String(row.id)}
|
||||||
|
emptyMessage="Sin resultados — no se encontraron secciones con los filtros seleccionados."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useCreateSeccion.ts
Normal file
13
src/web/src/features/secciones/hooks/useCreateSeccion.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createSeccion } from '../api/createSeccion'
|
||||||
|
import type { CreateSeccionRequest } from '../types'
|
||||||
|
|
||||||
|
export function useCreateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreateSeccionRequest) => createSeccion(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/secciones/hooks/useDeactivateSeccion.ts
Normal file
12
src/web/src/features/secciones/hooks/useDeactivateSeccion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { deactivateSeccion } from '../api/deactivateSeccion'
|
||||||
|
|
||||||
|
export function useDeactivateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deactivateSeccion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/web/src/features/secciones/hooks/useReactivateSeccion.ts
Normal file
12
src/web/src/features/secciones/hooks/useReactivateSeccion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { reactivateSeccion } from '../api/reactivateSeccion'
|
||||||
|
|
||||||
|
export function useReactivateSeccion() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => reactivateSeccion(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['secciones'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/web/src/features/secciones/hooks/useSeccion.ts
Normal file
11
src/web/src/features/secciones/hooks/useSeccion.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getSeccion } from '../api/getSeccion'
|
||||||
|
|
||||||
|
export function useSeccion(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['secciones', 'detail', id],
|
||||||
|
queryFn: () => getSeccion(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useSeccionesList.ts
Normal file
13
src/web/src/features/secciones/hooks/useSeccionesList.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { listSecciones } from '../api/listSecciones'
|
||||||
|
import type { SeccionesQuery } from '../types'
|
||||||
|
|
||||||
|
export const seccionesListQueryKey = (query: SeccionesQuery) => ['secciones', 'list', query] as const
|
||||||
|
|
||||||
|
export function useSeccionesList(query: SeccionesQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: seccionesListQueryKey(query),
|
||||||
|
queryFn: () => listSecciones(query),
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/web/src/features/secciones/hooks/useUpdateSeccion.ts
Normal file
13
src/web/src/features/secciones/hooks/useUpdateSeccion.ts
Normal file
@@ -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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user