From 91988a26b9cbb8f8f8f33eab3b11609508003e4c Mon Sep 17 00:00:00 2001 From: David Date: Fri, 9 Oct 2026 20:03:56 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20secciones/agrupaciones=20a=20las?= =?UTF-8?q?=20plantillas=20de=20activos=20y=20recursos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/assets.integration.ts | 44 ++- apps/server/src/inventory.integration.ts | 8 +- apps/server/src/resources.integration.ts | 46 ++- .../modelo-ddd-e-identificadores.md | 2 + docs/campos-y-plantillas-de-activos.md | 4 + docs/componentes-de-interfaz.md | 6 + docs/recursos-campos-y-plantillas.md | 4 + docs/secciones-de-campos-en-plantillas.md | 36 +++ modules/assets/package.json | 2 +- .../src/api/application/asset-input.mapper.ts | 2 + .../src/api/domain/asset-catalog-seed.ts | 36 ++- .../src/api/domain/asset-catalog.aggregate.ts | 19 +- .../src/api/domain/asset-definition.entity.ts | 38 ++- .../assets/src/api/domain/asset-definition.ts | 31 +- modules/assets/src/api/domain/asset-values.ts | 4 +- .../src/api/domain/template-section.entity.ts | 21 ++ .../src/api/domain/template-sections.test.ts | 270 ++++++++++++++++++ .../src/api/domain/template-sections.ts | 49 ++++ .../asset-definition-values.vo.ts | 8 +- .../domain/value-objects/asset-values.vo.ts | 17 +- modules/assets/src/common/locales/en.json | 18 +- modules/assets/src/common/locales/es.json | 18 +- .../src/web/assets/detail/ui/asset-detail.tsx | 25 +- .../controllers/use-asset-form-controller.ts | 6 + .../src/web/assets/shared/ui/asset-form.tsx | 14 +- .../use-catalog-template-form-controller.ts | 10 +- .../shared/ui/catalog-template-form.tsx | 97 +++---- .../detail/ui/catalog-definition-dialog.tsx | 19 +- modules/resources/package.json | 2 +- .../api/application/resource-input.mapper.ts | 2 + .../src/api/domain/resource-catalog-seed.ts | 32 ++- .../api/domain/resource-catalog.aggregate.ts | 19 +- .../api/domain/resource-definition.entity.ts | 38 ++- .../src/api/domain/resource-definition.ts | 33 ++- .../src/api/domain/resource-values.ts | 4 +- .../src/api/domain/template-section.entity.ts | 21 ++ .../src/api/domain/template-sections.test.ts | 259 +++++++++++++++++ .../src/api/domain/template-sections.ts | 49 ++++ .../resource-definition-values.vo.ts | 8 +- .../value-objects/resource-values.vo.ts | 17 +- .../persistence/migrate-legacy-resources.ts | 2 + modules/resources/src/common/locales/en.json | 18 +- modules/resources/src/common/locales/es.json | 18 +- .../use-catalog-template-form-controller.ts | 10 +- .../shared/ui/catalog-template-form.tsx | 97 +++---- .../detail/ui/catalog-definition-dialog.tsx | 19 +- .../resources/detail/ui/resource-detail.tsx | 25 +- .../use-resource-form-controller.ts | 5 + .../shared/helpers/resource-form-values.ts | 1 + .../web/resources/shared/ui/resource-form.tsx | 14 +- packages/rdx-ui/package.json | 2 +- .../rdx-ui/src/components/field-sections.tsx | 82 ++++++ .../components/template-section-fields.tsx | 153 ++++++++++ .../components/template-sections-editor.tsx | 163 +++++++++++ packages/rdx-ui/src/field-sections.test.tsx | 63 ++++ packages/rdx-ui/src/lib/template-sections.ts | 36 +++ 56 files changed, 1848 insertions(+), 198 deletions(-) create mode 100644 docs/secciones-de-campos-en-plantillas.md create mode 100644 modules/assets/src/api/domain/template-section.entity.ts create mode 100644 modules/assets/src/api/domain/template-sections.test.ts create mode 100644 modules/assets/src/api/domain/template-sections.ts create mode 100644 modules/resources/src/api/domain/template-section.entity.ts create mode 100644 modules/resources/src/api/domain/template-sections.test.ts create mode 100644 modules/resources/src/api/domain/template-sections.ts create mode 100644 packages/rdx-ui/src/components/field-sections.tsx create mode 100644 packages/rdx-ui/src/components/template-section-fields.tsx create mode 100644 packages/rdx-ui/src/components/template-sections-editor.tsx create mode 100644 packages/rdx-ui/src/field-sections.test.tsx create mode 100644 packages/rdx-ui/src/lib/template-sections.ts diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts index f73f18a..a5f680a 100644 --- a/apps/server/src/assets.integration.ts +++ b/apps/server/src/assets.integration.ts @@ -4,6 +4,8 @@ import { CatalogResponseSchema, SaveDefinitionResponseSchema } from "@metis/asse import { createAssetModule } from "@metis/assets/module"; import type { Sequelize } from "sequelize"; import { z } from "zod/v4"; + +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; export async function verifyAssets( request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, companyId: string, @@ -98,12 +100,18 @@ export async function verifyAssets( { values: { ...localValues, type: "number" } }, "FIELD_TYPE_FIXED" ); + const section = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Datos técnicos", + description: "", + }; const templateValues = { kind: "template", name: "Equipo especial", + sections: [section], description: "", fields: [ - { fieldId: field.id, required: false }, + { fieldId: field.id, required: false, sectionId: section.id }, { fieldId: stock.id, required: false }, ], }; @@ -111,7 +119,8 @@ export async function verifyAssets( await error(other, { values: templateValues }, "FIELD_UNAVAILABLE"); const template = await save(company, templateValues); const saved = template.items.find((item) => item.id === template.savedId); - assert.ok(saved); + assert.ok(saved && saved.values.kind === "template"); + assert.deepEqual(saved.values.sections, [section]); const edited = await save(`${company}/${saved.id}/update`, { ...templateValues, name: "Equipo especial actualizado", @@ -208,4 +217,35 @@ export async function verifyAssets( "INVALID_REQUEST", 400 ); + const globalSection = { + id: UniqueID.generateNewID().toPrimitive(), + name: "General", + description: "", + }; + const groupedGlobal = await save(global, { + kind: "template", + name: "Plantilla global agrupada assets", + description: "", + sections: [globalSection], + fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }], + }); + const groupedStored = (await read(global)).items.find( + (item) => item.id === groupedGlobal.savedId + ); + assert.ok(groupedStored?.values.kind === "template"); + assert.deepEqual(groupedStored.values.sections, [globalSection]); + await error( + global, + { + values: { + kind: "template", + name: "Sección ajena assets", + description: "", + sections: [], + fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }], + }, + }, + "INVALID_REQUEST", + 400 + ); } diff --git a/apps/server/src/inventory.integration.ts b/apps/server/src/inventory.integration.ts index 121842d..e45f5e6 100644 --- a/apps/server/src/inventory.integration.ts +++ b/apps/server/src/inventory.integration.ts @@ -9,6 +9,8 @@ import { import { CompanyResponseSchema } from "@metis/companies/common"; import type { Sequelize } from "sequelize"; import { z } from "zod/v4"; + +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; export async function verifyInventory( request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, companyId: string, @@ -142,12 +144,14 @@ export async function verifyInventory( assert.equal(retired.history.length, 4); assert.equal(retired.history[1]?.values.personId, ownPerson.id); + const section = { id: UniqueID.generateNewID().toPrimitive(), name: "Pantalla", description: "" }; const templateResponse = await request(`/asset-catalog/${companyId}`, { values: { kind: "template", name: "Monitor obligatorio inventario", + sections: [section], description: "", - fields: [{ fieldId: inch.id, required: true }], + fields: [{ fieldId: inch.id, required: true, sectionId: section.id }], }, }); assert.equal(templateResponse.status, 201); @@ -178,6 +182,8 @@ export async function verifyInventory( assert.ok(current); assert.equal(current.specification.templateName, "Monitor obligatorio inventario"); assert.equal(current.specification.fields[0]?.required, true); + assert.deepEqual(current.specification.sections, [section]); + assert.equal(current.specification.fields[0]?.sectionId, section.id); await error( `${path}/${custom.id}/update`, { ...custom.values, characteristics: {} }, diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index bb936ad..1cf0d59 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -12,6 +12,8 @@ import type { Sequelize } from "sequelize"; import { QueryTypes } from "sequelize"; import { z } from "zod/v4"; +import { UniqueID } from "../../../packages/rdx-ddd/src/index"; + export async function verifyResources( request: (path: string, body?: unknown, origin?: string, token?: string) => Promise, companyId: string, @@ -192,11 +194,17 @@ export async function verifyResources( const assetField = assets.items.find((item) => item.values.kind === "field"); const assetTemplate = assets.items.find((item) => item.values.kind === "template"); assert.ok(assetField && assetTemplate); + const section = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Datos técnicos", + description: "", + }; const template = { kind: "template", name: "Recurso propio", + sections: [section], description: "", - fields: [{ fieldId, required: true }], + fields: [{ fieldId, required: true, sectionId: section.id }], }; await error( company, @@ -243,6 +251,8 @@ export async function verifyResources( 403 ); const created = await resource(inventory, first); + assert.deepEqual(created.specification.sections, [section]); + assert.equal(created.specification.fields[0]?.sectionId, section.id); assert.equal(created.id[14], "7"); assert.equal(created.history[0]?.id[14], "7"); const repeated = await resource(`${inventory}/${created.id}/update`, first); @@ -273,6 +283,7 @@ export async function verifyResources( await save(`${company}/${createdTemplate.savedId}/update`, { ...template, name: "Plantilla revisada", + sections: [], fields: [{ fieldId: stock.id, required: false }], }); const listResponse = await request(inventory); @@ -280,6 +291,8 @@ export async function verifyResources( (item) => item.id === created.id ); assert.ok(stored); + assert.deepEqual(stored.specification.sections, [section]); + assert.equal(stored.specification.fields[0]?.sectionId, section.id); assert.equal(stored.specification.templateName, "Recurso propio"); assert.equal(stored.specification.fields[0]?.fieldId, fieldId); await resource(`${inventory}/${created.id}/update`, { @@ -347,4 +360,35 @@ export async function verifyResources( assert.equal((await request(`${inventory}?unexpected=1`)).status, 400); assert.equal((await request("/resources/not-a-uuid")).status, 400); assert.equal((await request("/resources/82be74bb-894f-4cca-ac7b-dedc96c15689")).status, 404); + const globalSection = { + id: UniqueID.generateNewID().toPrimitive(), + name: "General", + description: "", + }; + const groupedGlobal = await save(global, { + kind: "template", + name: "Plantilla global agrupada resources", + description: "", + sections: [globalSection], + fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }], + }); + const groupedStored = (await read(global)).items.find( + (item) => item.id === groupedGlobal.savedId + ); + assert.ok(groupedStored?.values.kind === "template"); + assert.deepEqual(groupedStored.values.sections, [globalSection]); + await error( + global, + { + values: { + kind: "template", + name: "Sección ajena resources", + description: "", + sections: [], + fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }], + }, + }, + "INVALID_REQUEST", + 400 + ); } diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md index e9f2e25..1bb9342 100644 --- a/docs/arquitectura/modelo-ddd-e-identificadores.md +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -116,3 +116,5 @@ Intervention es AggregateRoot con compañía, referencia, clave idempotente y pr Brand es la raíz del catálogo global de marcas, con Name y BrandLogo como Value Objects, Maybe para el SVG opcional y UniqueID propio. Su repositorio conserva identidad y protege las marcas de serie. AssetValues reconstruye brandId opcional como Maybe; los activos y sus revisiones conservan una instantánea del nombre junto a la referencia. Consultar [Catálogo global de marcas](../catalogo-global-de-marcas.md). + +TemplateSection es una entidad interna de cada plantilla de activos o recursos, con UniqueID y Name. Se persiste dentro de la definición validada; la especificación captura sus valores e identidades. Los catálogos y UUID de ambos módulos permanecen independientes. Consultar [Secciones de campos](../secciones-de-campos-en-plantillas.md). diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md index b23733b..32c1bde 100644 --- a/docs/campos-y-plantillas-de-activos.md +++ b/docs/campos-y-plantillas-de-activos.md @@ -44,3 +44,7 @@ Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /a ## Inventario y relaciones El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. El mismo catálogo incorpora los [tipos de relación](relaciones-entre-activos.md). Los [puestos de trabajo](puestos-de-trabajo.md) agrupan los activos registrados. + +## Organización de campos + +Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index fc4d62e..2f45ca9 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -279,3 +279,9 @@ contrae el menú y conservan la selección y las ayudas del botón de navegació ## Avatar y logotipo de compañía `CompanyAvatar`, `CompanyIdentity` y `CompanyLogoField` reúnen la presentación y selección del logotipo. Su contrato, límites y persistencia se documentan en [Logotipos de compañías](logotipos-de-companias.md). Se usan tamaños de 24–28 px en navegación/listas y 40–56 px en detalle/formulario. + +## FieldSections y TemplateSectionsEditor + +Componentes de @repo/rdx-ui independientes de los DTO, consultas y endpoints de Metis Desk. FieldSections recibe secciones, elementos con sectionId opcional y renderItem; presenta una cuadrícula plana cuando no hay secciones, o bloques desplegables accesibles con descripción y «Sin sección». Admite presentación de valores como listas de descripción y apertura ante errores externos. + +TemplateSectionsEditor recibe secciones, referencias de campos, nombres, textos traducidos, generador de identidad y onChange. Permite ordenar secciones/campos, editar nombre/descripción, asignar campos y mantener su obligatoriedad mediante SwitchField. El módulo consumidor conserva el estado en React Hook Form y genera UUID v7. Los helpers removeTemplateSection y copyTemplateLayout conservan referencias y remapean identidades respectivamente. No se modifican los componentes vendor. diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index a730cea..f15922f 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -88,3 +88,7 @@ Nombre, plantilla y estado siguen el contrato común. Todas las características Se reutilizan proveedor, referencia, caducidad y descripción del catálogo de recursos. Los siete campos nuevos y la plantilla se incorporan de forma idempotente al arrancar, sin cambiar identidades, fichas existentes ni disponibilidad configurada. La plantilla de serie está protegida y se puede copiar para personalizar; se puede desactivar globalmente o para una compañía. La ayuda específica aparece al final del modal, antes de Guardar y Cancelar. La ficha captura su especificación y conserva revisiones como cualquier recurso. Registrar una licencia no crea asignaciones, vínculos con aplicaciones/cuentas/equipos, avisos de vencimiento ni cálculos de capacidad. Estas relaciones y avisos quedan pendientes de una entrega posterior. + +## Organización de campos + +Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes. diff --git a/docs/secciones-de-campos-en-plantillas.md b/docs/secciones-de-campos-en-plantillas.md new file mode 100644 index 0000000..00f229e --- /dev/null +++ b/docs/secciones-de-campos-en-plantillas.md @@ -0,0 +1,36 @@ +# Secciones de campos en plantillas + +## Alcance + +Activos y recursos organizan sus características mediante secciones dentro de cada plantilla. No existe un catálogo independiente de grupos. Los dos catálogos siguen separados: ni campos, ni plantillas, ni secciones se comparten entre activos y recursos. + +La plantilla global define su presentación para todas las compañías. Una plantilla propia de compañía puede utilizar campos globales y propios de esa compañía, con su propia organización. Personalizar una plantilla global o de serie se realiza mediante una copia en el ámbito correspondiente. Copiar genera UUID v7 nuevos para las secciones y remapea las asociaciones; los campos conservan sus referencias. + +## Reglas + +- Un único nivel de secciones, sin anidamiento ni bloques repetibles. +- Cada sección tiene UUID, nombre obligatorio, descripción opcional y posición dada por el orden del array. Máximo 30 secciones, nombres únicos dentro de la plantilla ignorando espacios extremos y mayúsculas. +- Cada campo pertenece como máximo a una sección de esa plantilla. Su pertenencia no cambia la definición reutilizable del campo. +- Los campos sin sección son válidos; aparecen bajo «Sin sección» cuando existen otras secciones. Una plantilla sin secciones mantiene la presentación sencilla. +- El editor permite añadir, renombrar, describir, ordenar y eliminar secciones; asignar campos y ordenarlos dentro de su grupo. Todas las operaciones son locales al formulario hasta Guardar; Cancelar no modifica la plantilla. +- Eliminar una sección mueve sus campos a «Sin sección», conservando sus referencias y obligatoriedad. Quitar un campo de una plantilla tampoco elimina su definición ni los valores de registros anteriores. +- Plegar una sección solo modifica su presentación, sin desactivar campos ni omitir validaciones. +- La activación sigue perteneciendo a los campos y plantillas, con la disponibilidad global y las excepciones por compañía existentes. Una sección no incorpora otro estado de disponibilidad. + +## Formularios y fichas + +El editor se integra en los modales existentes de plantillas, con ayuda inmediatamente antes de Guardar/Cancelar. Los formularios de activos y recursos, sus fichas e historial y el detalle de las plantillas reutilizan FieldSections. Las secciones comienzan abiertas y permiten plegarse con teclado; los campos se distribuyen en dos columnas cuando el espacio permite y en una en pantallas pequeñas. Las secciones sin campos no se muestran en la ficha, pero sí en el editor. + +Ante controles inválidos se abre la sección antes de que el navegador enfoque el control; ante errores del formulario o servidor se abren las secciones para facilitar la revisión. La obligación de rellenar un campo continúa siendo independiente de su sección. + +## Datos existentes y persistencia + +Las secciones y asociaciones se persisten dentro del JSON validado de definición y especificación, usando las tablas y transacciones actuales; no hay cambio de estructura SQL. TemplateSection es una entidad interna con UniqueID y Name. El contrato admite definiciones y especificaciones anteriores sin sections/sectionId. + +Las especificaciones de activos y recursos capturan el orden, nombre, descripción e identidad de las secciones y la pertenencia de cada campo al crear el registro. Editar una plantilla posteriormente no modifica estas instantáneas, valores ni historial. Los registros anteriores sin secciones permanecen planos incluso al editarlos. No se realiza una migración silenciosa de sus especificaciones. + +La inicialización amplía una sola vez la presentación de las plantillas de serie existentes, conservando UUID de plantilla/campos/asociaciones, obligatoriedad, orden previo y estados globales y por compañía. La ampliación se guarda mediante la transacción del catálogo al arrancar. Repetir el arranque no cambia identidades ni configuración. Las plantillas personalizadas permanecen intactas. Los nuevos catálogos reciben la misma agrupación inicial; las plantillas pequeñas de recursos conservan una presentación plana. + +## Verificación + +Pruebas de contratos, nombres/UUID duplicados, referencias a secciones ajenas, límites/anidamiento, igualdad semántica, ámbito global y local, persistencia JSON, ampliación idempotente, conservación de instantáneas y reglas obligatorias. Pruebas compartidas para eliminar secciones, copiar/remapear identidades y presentación agrupada o plana. Comprobación de interacción en navegador para el editor y el formulario de características. diff --git a/modules/assets/package.json b/modules/assets/package.json index 054f6e1..d77a874 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts" }, "dependencies": { "@hookform/resolvers": "5.2.2", diff --git a/modules/assets/src/api/application/asset-input.mapper.ts b/modules/assets/src/api/application/asset-input.mapper.ts index 7839eee..a5995ff 100644 --- a/modules/assets/src/api/application/asset-input.mapper.ts +++ b/modules/assets/src/api/application/asset-input.mapper.ts @@ -43,11 +43,13 @@ export function mapAssetInput( unit: field.values.data.unit, choices: [...field.values.data.choices], required: binding.required, + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }; }); const created = AssetSpecification.create({ templateId: template.id.toPrimitive(), templateName: template.values.data.name, + ...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}), fields, }); if (!created.isSuccess) { diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts index bcc14fb..d661281 100644 --- a/modules/assets/src/api/domain/asset-catalog-seed.ts +++ b/modules/assets/src/api/domain/asset-catalog-seed.ts @@ -1,3 +1,5 @@ +import { UniqueID } from "@repo/rdx-ddd"; + import type { AssetCatalog } from "./asset-catalog.aggregate"; import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; @@ -72,6 +74,18 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [ ["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]], ["ups", "SAI", ["power", "battery", "connections"]], ]; +const SECTION_FIELDS: readonly [string, readonly string[]][] = [ + ["Identificación", ["brand", "model", "serial"]], + ["Hardware", ["processor", "memory", "storage", "capacity", "disks", "raid"]], + ["Sistema", ["os", "hostname"]], + ["Pantalla y conexiones", ["screen_size", "resolution", "connections"]], + ["Red", ["ip", "mac", "ports", "speed", "poe", "wifi"]], + ["Impresión", ["printing", "color", "duplex"]], + ["Telefonía", ["imei", "phone", "carrier"]], + ["Alimentación", ["power", "battery"]], + ["Compra y garantía", ["purchased_on", "warranty_until"]], +]; + export function seedAssetCatalog(catalog: AssetCatalog) { let current = catalog; const add = (key: string, input: AssetDefinitionSpec) => { @@ -89,6 +103,15 @@ export function seedAssetCatalog(catalog: AssetCatalog) { add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] }); } for (const [key, name, fields] of TEMPLATES) { + const sectionFields = [...COMMON, ...fields]; + const sections = SECTION_FIELDS.filter(([, keys]) => + keys.some((field) => sectionFields.includes(field)) + ).map(([name, keys]) => ({ + id: UniqueID.generateNewID().toPrimitive(), + name, + description: "", + keys, + })); const references = [...COMMON, ...fields].map((field) => { const id = current.definitions .find((item) => item.stockKey.getOrUndefined() === `field:${field}`) @@ -96,9 +119,18 @@ export function seedAssetCatalog(catalog: AssetCatalog) { if (!id) { throw new Error("Campo inicial de activos ausente."); } - return { fieldId: id, required: false }; + const sectionId = sections.find((section) => section.keys.includes(field))?.id; + return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) }; + }); + add(`template:${key}`, { + kind: "template", + name, + description: "", + fields: references, + ...(sections.length + ? { sections: sections.map(({ id, name, description }) => ({ id, name, description })) } + : {}), }); - add(`template:${key}`, { kind: "template", name, description: "", fields: references }); } for (const [key, name, inverseName, symmetric] of [ ["connected", "Conectado a", "Conectado a", true], diff --git a/modules/assets/src/api/domain/asset-catalog.aggregate.ts b/modules/assets/src/api/domain/asset-catalog.aggregate.ts index 2f6e79e..fcbde10 100644 --- a/modules/assets/src/api/domain/asset-catalog.aggregate.ts +++ b/modules/assets/src/api/domain/asset-catalog.aggregate.ts @@ -118,8 +118,23 @@ export class AssetCatalog extends AggregateRoot { }); } seed(stockKey: string, values: AssetDefinitionValues): Result { - if (this.definitions.some((item) => item.stockKey.getOrUndefined() === stockKey)) { - return Result.ok(this); + const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); + if (previous) { + const upgraded = previous.initializeSections(values); + if (upgraded === previous) { + return Result.ok(this); + } + return Result.ok( + new AssetCatalog( + { + ...this.props, + definitions: this.definitions.map((item) => + item.id.equals(previous.id) ? upgraded : item + ), + }, + this.id + ) + ); } return this.replace( AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)), diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts index a0706f5..43a95ff 100644 --- a/modules/assets/src/api/domain/asset-definition.entity.ts +++ b/modules/assets/src/api/domain/asset-definition.entity.ts @@ -3,7 +3,8 @@ import { DomainEntity } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { AssetCatalogError } from "./asset-definition"; -import type { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; +import { TemplateSection } from "./template-section.entity"; +import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: boolean }> { static create(fieldId: UniqueID, required: boolean, id?: UniqueID) { @@ -19,6 +20,7 @@ export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: b return this.props.required; } } + type DefinitionProps = { companyId: Maybe; stockKey: Maybe; @@ -59,6 +61,11 @@ export class AssetDefinition extends DomainEntity { get active() { return this.props.active; } + get sections() { + return this.values.data.kind === "template" + ? (this.values.data.sections ?? []).map((section) => TemplateSection.rehydrate(section)) + : []; + } get bindings() { return this.props.bindings; } @@ -96,6 +103,35 @@ export class AssetDefinition extends DomainEntity { ); return Result.ok(new AssetDefinition({ ...this.props, values, bindings }, this.id)); } + /** Ampliación idempotente de presentación de serie; conserva datos y disponibilidad. */ + initializeSections(source: AssetDefinitionValues) { + const previous = this.values.data; + const next = source.data; + if ( + !this.stockKey.getOrUndefined() || + previous.kind !== "template" || + next.kind !== "template" || + previous.sections !== undefined || + !next.sections + ) { + return this; + } + // Solo asociar secciones a campos ya presentes, sin cambiar orden ni obligatoriedad. + const known = new Set(previous.fields.map((field) => field.fieldId)); + const sections = next.sections.filter((section) => + next.fields.some((field) => field.sectionId === section.id && known.has(field.fieldId)) + ); + const sectionIds = new Set(sections.map((section) => section.id)); + const fields = previous.fields.map((field) => { + const sectionId = next.fields.find((entry) => entry.fieldId === field.fieldId)?.sectionId; + return { ...field, ...(sectionId && sectionIds.has(sectionId) ? { sectionId } : {}) }; + }); + const values = AssetDefinitionValues.create({ ...previous, sections, fields }); + if (!values.isSuccess) { + throw new Error("Secciones iniciales de plantilla inconsistentes."); + } + return new AssetDefinition({ ...this.props, values: values.data }, this.id); + } setActive(active: boolean) { return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); } diff --git a/modules/assets/src/api/domain/asset-definition.ts b/modules/assets/src/api/domain/asset-definition.ts index e9c33a0..eac2273 100644 --- a/modules/assets/src/api/domain/asset-definition.ts +++ b/modules/assets/src/api/domain/asset-definition.ts @@ -1,8 +1,19 @@ import { z } from "zod/v4"; +import { validTemplateSections } from "./template-sections"; + const title = z.string().min(1).max(100); export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]); -export const TemplateFieldSchema = z.strictObject({ fieldId: z.uuid(), required: z.boolean() }); +export const TemplateFieldSchema = z.strictObject({ + fieldId: z.uuid(), + required: z.boolean(), + sectionId: z.uuid().optional(), +}); +export const TemplateSectionSchema = z.strictObject({ + id: z.uuid(), + name: z.string().trim().min(1).max(100), + description: z.string().trim().max(1000), +}); export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("field"), @@ -12,12 +23,18 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ unit: z.string().max(30), choices: z.array(title).max(100), }), - z.strictObject({ - kind: z.literal("template"), - name: title, - description: z.string().max(1000), - fields: z.array(TemplateFieldSchema).min(1).max(100), - }), + z + .strictObject({ + kind: z.literal("template"), + name: title, + description: z.string().max(1000), + fields: z.array(TemplateFieldSchema).min(1).max(100), + sections: z.array(TemplateSectionSchema).max(30).optional(), + }) + .refine((template) => validTemplateSections(template.sections ?? [], template.fields), { + path: ["sections"], + message: "INVALID_SECTIONS", + }), z.strictObject({ kind: z.literal("relation"), name: title, diff --git a/modules/assets/src/api/domain/asset-values.ts b/modules/assets/src/api/domain/asset-values.ts index 2f67c76..d46a299 100644 --- a/modules/assets/src/api/domain/asset-values.ts +++ b/modules/assets/src/api/domain/asset-values.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -import { FieldTypeSchema } from "./asset-definition"; +import { FieldTypeSchema, TemplateSectionSchema } from "./asset-definition"; export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]); export const AssetCharacteristicSchema = z.strictObject({ @@ -10,6 +10,7 @@ export const AssetCharacteristicSchema = z.strictObject({ unit: z.string().max(30), choices: z.array(z.string().min(1).max(100)), required: z.boolean(), + sectionId: z.uuid().optional(), }); export const AssetValueSchema = z.union([z.string().max(10000), z.number().finite(), z.boolean()]); export const AssetInputSchema = z.strictObject({ @@ -25,6 +26,7 @@ export const AssetInputSchema = z.strictObject({ export const AssetSpecificationSchema = z.strictObject({ templateId: z.uuid(), templateName: z.string().min(1).max(100), + sections: z.array(TemplateSectionSchema).max(30).optional(), fields: z.array(AssetCharacteristicSchema).min(1).max(100), }); export type AssetInput = z.infer; diff --git a/modules/assets/src/api/domain/template-section.entity.ts b/modules/assets/src/api/domain/template-section.entity.ts new file mode 100644 index 0000000..0abe1e8 --- /dev/null +++ b/modules/assets/src/api/domain/template-section.entity.ts @@ -0,0 +1,21 @@ +import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd"; + +import type { TemplateSectionData } from "./template-sections"; + +/** Entidad interna de la plantilla, persistida junto a su definición JSON. */ +export class TemplateSection extends DomainEntity<{ name: Name; description: string }> { + static rehydrate(input: TemplateSectionData) { + const id = UniqueID.create(input.id); + const name = Name.create(input.name, 100); + if (!(id.isSuccess && name.isSuccess)) { + throw new Error("Sección de plantilla persistida inconsistente."); + } + return new TemplateSection({ name: name.data, description: input.description }, id.data); + } + get name() { + return this.props.name; + } + get description() { + return this.props.description; + } +} diff --git a/modules/assets/src/api/domain/template-sections.test.ts b/modules/assets/src/api/domain/template-sections.test.ts new file mode 100644 index 0000000..e7f6549 --- /dev/null +++ b/modules/assets/src/api/domain/template-sections.test.ts @@ -0,0 +1,270 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { mapAssetInput } from "../application/asset-input.mapper"; +import { AssetCatalogPersistenceMapper } from "../infrastructure/persistence/asset-catalog-persistence.mapper"; + +import { Asset } from "./asset.aggregate"; +import { AssetCatalog } from "./asset-catalog.aggregate"; +import { seedAssetCatalog } from "./asset-catalog-seed"; +import { AssetDefinitionSchema } from "./asset-definition"; +import { AssetDefinition } from "./asset-definition.entity"; +import type { AssetInput } from "./asset-values"; +import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; +import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const fieldId = UniqueID.generateNewID().toPrimitive(); +const section = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Hardware", + description: "Datos técnicos", +}; +const otherSection = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Compra", + description: "", +}; +const template = { + kind: "template" as const, + name: "Plantilla", + description: "", + sections: [section, otherSection], + fields: [{ fieldId, required: false, sectionId: section.id }], +}; +function valid(input = template) { + const result = AssetDefinitionValues.create(input); + assert.ok(result.isSuccess); + return result.data; +} + +test("secciones: nombres, identidades, referencias y un solo nivel", () => { + assert.ok(AssetDefinitionSchema.safeParse(template).success); + for (const candidate of [ + { ...template, sections: [section, { ...otherSection, name: " hardware " }] }, + { ...template, sections: [section, { ...otherSection, id: section.id.toUpperCase() }] }, + { ...template, sections: [{ ...section, name: " " }] }, + { ...template, sections: [], fields: template.fields }, + { ...template, sections: [{ ...section, sections: [] }] }, + { + ...template, + sections: Array.from({ length: 31 }, (_, index) => ({ + ...section, + id: UniqueID.generateNewID().toPrimitive(), + name: `Sección ${index}`, + })), + }, + ]) { + assert.equal(AssetDefinitionSchema.safeParse(candidate).success, false); + } + assert.ok( + AssetDefinitionSchema.safeParse({ + ...template, + sections: [], + fields: [{ fieldId, required: false }], + }).success + ); +}); + +test("secciones: igualdad, orden, identidad y persistencia JSON", () => { + const first = valid(); + assert.ok(first.equals(valid())); + assert.equal(first.equals(valid({ ...template, sections: [otherSection, section] })), false); + assert.equal( + first.equals( + valid({ + ...template, + fields: [{ ...template.fields[0], fieldId, required: false, sectionId: otherSection.id }], + }) + ), + false + ); + const definition = AssetDefinition.create(Maybe.none(), first, true); + assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); + assert.equal(definition.sections[0]?.name.toPrimitive(), "Hardware"); + const parsed = AssetDefinitionSchema.parse(JSON.parse(JSON.stringify(first.data))); + const restored = AssetDefinitionValues.create(parsed); + assert.ok(restored.isSuccess && first.equals(restored.data)); + assert.ok(Object.isFrozen(first.data.kind === "template" ? first.data.sections : [])); +}); + +test("secciones: plantillas globales y locales conservan separación y UUID al editar", () => { + const field = AssetDefinitionValues.create({ + kind: "field", + name: "Campo", + description: "", + type: "text", + unit: "", + choices: [], + }); + assert.ok(field.isSuccess); + const root = AssetCatalog.create().save(Maybe.none(), Maybe.none(), field.data); + assert.ok(root.isSuccess); + const id = root.data.definitions[0]?.id.toPrimitive(); + assert.ok(id); + const grouped = valid({ + ...template, + fields: [{ fieldId: id, required: false, sectionId: section.id }], + }); + for (const scope of [Maybe.none(), Maybe.some(company)]) { + const saved = root.data.save(scope, Maybe.none(), grouped); + assert.ok(saved.isSuccess); + const definition = saved.data.definitions.at(-1); + assert.ok(definition); + assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); + const edited = AssetDefinitionValues.create({ ...grouped.data, name: "Editada" }); + assert.ok(edited.isSuccess); + const updated = saved.data.save(scope, Maybe.some(definition.id), edited.data); + assert.ok(updated.isSuccess); + const next = updated.data.definitions.at(-1); + assert.ok(next); + assert.equal(next.id.toPrimitive(), definition.id.toPrimitive()); + assert.equal(next.sections[0]?.id.toPrimitive(), section.id); + assert.equal(next.bindings[0]?.id.toPrimitive(), definition.bindings[0]?.id.toPrimitive()); + const rows = new AssetCatalogPersistenceMapper().toPersistence(updated.data); + const serialized: (typeof rows.definitions)[number] | undefined = rows.definitions.find( + (item) => item.id === definition.id.toPrimitive() + ); + assert.ok(serialized?.values.kind === "template"); + assert.deepEqual(serialized.values.sections, template.sections); + } + const local = root.data.save(Maybe.some(company), Maybe.none(), field.data); + assert.ok(!local.isSuccess); // nombre visible duplicado + const unrelated = root.data.save(Maybe.some(company), Maybe.none(), valid()); + assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía +}); + +test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni referencias", () => { + const seeded = seedAssetCatalog(AssetCatalog.create()); + const grouped = seeded.definitions.find( + (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 + ); + assert.ok(grouped && grouped.values.data.kind === "template"); + const { sections: omitted, ...flat } = grouped.values.data; + assert.ok(omitted); + const oldValues = AssetDefinitionValues.create({ + ...flat, + fields: flat.fields.map(({ fieldId, required }) => ({ fieldId, required })), + }); + assert.ok(oldValues.isSuccess); + const old = AssetDefinition.rehydrate( + { + values: oldValues.data, + active: false, + companyId: grouped.companyId, + stockKey: grouped.stockKey, + bindings: grouped.bindings, + }, + grouped.id + ); + const catalog = AssetCatalog.rehydrate( + { + definitions: seeded.definitions.map((item) => (item.id.equals(old.id) ? old : item)), + availability: seeded.availability, + }, + seeded.id + ); + const disabled = catalog.setActive(Maybe.some(company), old.id, false); + assert.ok(disabled.isSuccess); + const upgraded = seedAssetCatalog(disabled.data); + const next = upgraded.definitions.find((item) => item.id.equals(old.id)); + assert.ok(next); + assert.equal(next.active, false); + assert.equal(upgraded.active(next, Maybe.some(company)), false); + assert.deepEqual( + next.bindings.map((item) => item.id.toPrimitive()), + old.bindings.map((item) => item.id.toPrimitive()) + ); + assert.ok(next.sections.length > 0); + assert.equal(seedAssetCatalog(upgraded), upgraded); + assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7")); +}); + +test("especificación: secciones capturadas, edición posterior y obligatoriedad independiente", () => { + const catalog = seedAssetCatalog(AssetCatalog.create()); + const selected = catalog.definitions.find( + (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 + ); + assert.ok(selected && selected.values.data.kind === "template"); + const input: AssetInput = { + name: "Registro", + templateId: selected.id.toPrimitive(), + siteId: null, + personId: null, + status: "available", + notes: "", + characteristics: {}, + }; + const context = { + catalog, + brands: [], + workstations: [], + assets: [], + relationships: [], + sites: [], + people: [], + actorNames: new Map(), + }; + const mapped = mapAssetInput(input, company, context); + assert.ok(mapped.isSuccess); + assert.deepEqual(mapped.data.specification.toPrimitive().sections, selected.values.data.sections); + assert.deepEqual( + mapped.data.specification.toPrimitive().fields.map((item) => item.sectionId), + selected.values.data.fields.map((item) => item.sectionId) + ); + const created = Asset.create( + company, + mapped.data.specification, + mapped.data.values, + actor, + "2026-10-09T12:00:00.000Z" + ); + assert.ok(created.isSuccess); + const flatSpec = AssetSpecification.create({ + templateId: selected.id.toPrimitive(), + templateName: "Anterior", + fields: mapped.data.specification + .toPrimitive() + .fields.map(({ sectionId: omitted, ...field }) => { + assert.ok(omitted); + return field; + }), + }); + assert.ok(flatSpec.isSuccess); + const previousValues = AssetValues.create(input, flatSpec.data); + assert.ok(previousValues.isSuccess); + const previous = Asset.create( + company, + flatSpec.data, + previousValues.data, + actor, + "2026-10-09T11:00:00.000Z" + ); + assert.ok(previous.isSuccess); + const edit = mapAssetInput({ ...input, name: "Cambio" }, company, context, previous.data); + assert.ok(edit.isSuccess); + assert.equal(edit.data.specification, previous.data.specification); + assert.equal(edit.data.specification.toPrimitive().sections, undefined); + const spec = mapped.data.specification.toPrimitive(); + const firstField = spec.fields[0]; + assert.ok(firstField); + const required = AssetSpecification.create({ + ...spec, + fields: spec.fields.map((field) => ({ + ...field, + required: field.fieldId === firstField.fieldId, + })), + }); + assert.ok(required.isSuccess); + assert.equal(AssetValues.create(input, required.data).isSuccess, false); + assert.ok( + AssetValues.create( + { ...input, characteristics: { [firstField.fieldId]: "Dato" } }, + required.data + ).isSuccess + ); +}); diff --git a/modules/assets/src/api/domain/template-sections.ts b/modules/assets/src/api/domain/template-sections.ts new file mode 100644 index 0000000..bdd59a3 --- /dev/null +++ b/modules/assets/src/api/domain/template-sections.ts @@ -0,0 +1,49 @@ +import type { z } from "zod/v4"; + +import type { TemplateSectionSchema } from "./asset-definition"; + +export type TemplateSectionData = z.infer; + +/** Secciones de un solo nivel; las referencias pertenecen exclusivamente a esta plantilla. */ +export function validTemplateSections( + sections: readonly TemplateSectionData[], + fields: readonly { sectionId?: string | undefined }[] +) { + const ids = new Set(sections.map((section) => section.id.toLowerCase())); + const names = new Set(sections.map((section) => section.name.trim().toLocaleLowerCase("es"))); + return ( + ids.size === sections.length && + names.size === sections.length && + fields.every((field) => !field.sectionId || ids.has(field.sectionId.toLowerCase())) + ); +} + +export function freezeTemplateSections(sections: readonly TemplateSectionData[]) { + const frozen = sections.map((section) => + Object.freeze({ + id: section.id.toLowerCase(), + name: section.name.trim(), + description: section.description.trim(), + }) + ); + Object.freeze(frozen); + return frozen; +} + +export function sameTemplateSections( + first: readonly TemplateSectionData[], + second: readonly TemplateSectionData[] +) { + return ( + first.length === second.length && + first.every((section, index) => { + const candidate = second[index]; + return ( + candidate !== undefined && + section.id === candidate.id && + section.name === candidate.name && + section.description === candidate.description + ); + }) + ); +} diff --git a/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts b/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts index 8d0b89c..f2a0158 100644 --- a/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts @@ -3,6 +3,7 @@ import { Result } from "@repo/rdx-utils"; import type { AssetDefinitionSpec } from "../asset-definition"; import { AssetCatalogError, AssetDefinitionSchema } from "../asset-definition"; +import { freezeTemplateSections, sameTemplateSections } from "../template-sections"; /** Definición inmutable con nombre y referencias reconstruidos como Value Objects. */ export class AssetDefinitionValues { @@ -55,6 +56,7 @@ export class AssetDefinitionValues { const fields = data.fields.map((field, index) => ({ fieldId: ids[index]?.toPrimitive() ?? field.fieldId, required: field.required, + ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), })); fields.forEach(Object.freeze); Object.freeze(fields); @@ -65,6 +67,7 @@ export class AssetDefinitionValues { name: name.data.toPrimitive(), description: data.description.trim(), fields, + ...(data.sections ? { sections: freezeTemplateSections(data.sections) } : {}), }), name.data, Object.freeze(ids) @@ -109,10 +112,13 @@ export class AssetDefinitionValues { return ( a.kind === "template" && b.kind === "template" && + sameTemplateSections(a.sections ?? [], b.sections ?? []) && a.fields.length === b.fields.length && a.fields.every( (field, index) => - field.fieldId === b.fields[index]?.fieldId && field.required === b.fields[index]?.required + field.fieldId === b.fields[index]?.fieldId && + field.required === b.fields[index]?.required && + field.sectionId === b.fields[index]?.sectionId ) ); } diff --git a/modules/assets/src/api/domain/value-objects/asset-values.vo.ts b/modules/assets/src/api/domain/value-objects/asset-values.vo.ts index 16d750f..085c786 100644 --- a/modules/assets/src/api/domain/value-objects/asset-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/asset-values.vo.ts @@ -5,6 +5,11 @@ import { z } from "zod/v4"; import { AssetCatalogError } from "../asset-definition"; import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values"; import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values"; +import { + freezeTemplateSections, + sameTemplateSections, + validTemplateSections, +} from "../template-sections"; export class AssetSpecification { private constructor( @@ -20,7 +25,7 @@ export class AssetSpecification { static create(input: AssetSpecificationData): Result { const parsed = AssetSpecificationSchema.safeParse(input); if ( - !parsed.success || + !(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) || new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length ) { return Result.fail(new AssetCatalogError("INVALID_REQUEST")); @@ -33,7 +38,12 @@ export class AssetSpecification { const frozenFields = input.fields.map((field) => { const choices = [...field.choices]; Object.freeze(choices); - return Object.freeze({ ...field, fieldId: field.fieldId.toLowerCase(), choices }); + return Object.freeze({ + ...field, + fieldId: field.fieldId.toLowerCase(), + choices, + ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), + }); }); Object.freeze(frozenFields); return Result.ok( @@ -42,6 +52,7 @@ export class AssetSpecification { templateId: template.data.toPrimitive(), templateName: name.data.toPrimitive(), fields: frozenFields, + ...(input.sections ? { sections: freezeTemplateSections(input.sections) } : {}), }), template.data, name.data @@ -57,6 +68,7 @@ export class AssetSpecification { return ( first.templateId === second.templateId && first.templateName === second.templateName && + sameTemplateSections(first.sections ?? [], second.sections ?? []) && first.fields.length === second.fields.length && first.fields.every((field, index) => { const candidate = second.fields[index]; @@ -67,6 +79,7 @@ export class AssetSpecification { field.type === candidate.type && field.unit === candidate.unit && field.required === candidate.required && + field.sectionId === candidate.sectionId && field.choices.length === candidate.choices.length && field.choices.every((choice, position) => choice === candidate.choices[position]) ); diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index b3a8009..ee1db54 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -282,6 +282,22 @@ "COMPANY_NOT_FOUND": "Company not found.", "INTERNAL_ERROR": "The operation could not be completed.", "FORBIDDEN": "Your session does not allow this operation. Sign in again.", - "UNAUTHENTICATED": "Sign in again." + "UNAUTHENTICATED": "Sign in again.", + "INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template." + }, + "sections": { + "title": "Template sections", + "add": "Add section", + "name": "Section name", + "description": "Description (optional)", + "ungrouped": "Unsectioned", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove section", + "removeField": "Remove field", + "required": "Required", + "assign": "Section", + "help": "Organize fields without changing their values or requirements. Removing a section moves its fields to Unsectioned.", + "empty": "This section has no fields yet." } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 9719a1c..802892f 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -282,6 +282,22 @@ "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "INTERNAL_ERROR": "No se ha podido completar la operación.", "FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.", - "UNAUTHENTICATED": "Vuelve a iniciar sesión." + "UNAUTHENTICATED": "Vuelve a iniciar sesión.", + "INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla." + }, + "sections": { + "title": "Secciones de la plantilla", + "add": "Añadir sección", + "name": "Nombre de la sección", + "description": "Descripción (opcional)", + "ungrouped": "Sin sección", + "moveUp": "Mover hacia arriba", + "moveDown": "Mover hacia abajo", + "remove": "Eliminar sección", + "removeField": "Quitar campo", + "required": "Obligatorio", + "assign": "Sección", + "help": "Organiza los campos sin cambiar sus valores ni su obligatoriedad. Al eliminar una sección, sus campos pasan a «Sin sección».", + "empty": "Esta sección aún no tiene campos." } } diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index 8b910e6..ccf7c33 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -1,3 +1,4 @@ +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { useTranslation } from "react-i18next"; import { useAssetDetailController } from "../controllers"; @@ -44,7 +45,10 @@ export function AssetDetail(props: AssetDetailProps) {
{t("workstationInAsset")}
{workstations?.map((item) => item.values.name).join(" · ") || t("noWorkstation")}
- {asset.specification.fields.map((field) => ( + + (
{field.name} @@ -54,8 +58,11 @@ export function AssetDetail(props: AssetDetailProps) { {display(asset.values.characteristics[field.fieldId])}
- ))} - + )} + sections={asset.specification.sections} + ungroupedLabel={t("sections.ungrouped")} + values + /> {asset.values.notes && (

{asset.values.notes}

)} @@ -84,14 +91,18 @@ export function AssetDetail(props: AssetDetailProps) {

{t("characteristics")} -
- {asset.specification.fields.map((field) => ( + (
{field.name}
{display(revision.values.characteristics[field.fieldId])}
- ))} -
+ )} + sections={asset.specification.sections} + ungroupedLabel={t("sections.ungrouped")} + values + /> {revision.values.notes && (

{revision.values.notes}

)} diff --git a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts index 4bea8ab..77a5a68 100644 --- a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts +++ b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts @@ -55,11 +55,16 @@ export function useAssetFormController({ unit: field.values.unit, choices: field.values.choices, required: binding.required, + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }, ] : []; }) : []); + const sections = + asset?.specification.sections ?? + (template?.values.kind === "template" ? template.values.sections : undefined) ?? + []; const mutation = useMutation({ mutationFn: (input: AssetInput) => saveAsset(companyId, input, csrfToken, asset?.id), onSuccess: async (response) => { @@ -120,6 +125,7 @@ export function useAssetFormController({ brandFieldId, templates, fields, + sections, mutation, setCharacteristic, submit, diff --git a/modules/assets/src/web/assets/shared/ui/asset-form.tsx b/modules/assets/src/web/assets/shared/ui/asset-form.tsx index 6d6d970..2b74577 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-form.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-form.tsx @@ -1,4 +1,5 @@ import { ContextHelp } from "@repo/rdx-ui/components/context-help"; +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { Button } from "@repo/shadcn-ui/components/button"; @@ -27,6 +28,7 @@ export function AssetForm(props: AssetFormProps) { brandFieldId, templates, fields, + sections, mutation, setCharacteristic, submit, @@ -131,8 +133,10 @@ export function AssetForm(props: AssetFormProps) { {fields.length > 0 && (

{t("characteristics")}

-
- {fields.map((field) => + 0} + items={fields} + renderItem={(field) => field.fieldId === brandFieldId ? ( ) - )} -
+ } + sections={sections} + ungroupedLabel={t("sections.ungrouped")} + />
)}
diff --git a/modules/assets/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts b/modules/assets/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts index 10c6bb4..9264e03 100644 --- a/modules/assets/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts +++ b/modules/assets/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts @@ -1,4 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { UniqueID } from "@repo/rdx-ddd"; +import { copyTemplateLayout } from "@repo/rdx-ui/lib/template-sections"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -19,13 +21,19 @@ export function useCatalogTemplateFormController({ const [creatingField, setCreatingField] = useState(false); const source = item ?? copy; const current = source?.values.kind === "template" ? source.values : undefined; + const layout = copy + ? copyTemplateLayout(current?.sections ?? [], current?.fields ?? [], () => + UniqueID.generateNewID().toPrimitive() + ) + : { sections: current?.sections ?? [], fields: current?.fields ?? [] }; const form = useForm({ resolver: zodResolver(TemplateFormSchema), defaultValues: { kind: "template", name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""), description: current?.description ?? "", - fields: current?.fields.map((field) => ({ ...field })) ?? [], + fields: layout.fields, + sections: layout.sections, }, }); const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id); diff --git a/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index ab20a48..6a8d476 100644 --- a/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -1,6 +1,7 @@ +import { UniqueID } from "@repo/rdx-ddd"; import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; -import { SwitchField } from "@repo/rdx-ui/components/switch-field"; +import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; @@ -61,64 +62,42 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) { />
{!selected.length &&

{t("noFields")}

} -
- {selected.map((binding) => { - const field = fields.find((entry) => entry.id === binding.fieldId); - return ( -
-
-

{field?.values.name ?? binding.fieldId}

- {field && !field.effectiveActive && ( -

{t("disabledField")}

- )} -
-
- - {t("required")} - - {": "} - {field?.values.name ?? ""} - - - } - onCheckedChange={(checked) => - form.setValue( - "fields", - selected.map((entry) => - entry.fieldId === binding.fieldId - ? { ...entry, required: checked === true } - : entry - ), - { shouldDirty: true } - ) - } - /> - -
-
- ); - })} -
+ UniqueID.generateNewID().toPrimitive()} + disabled={mutation.isPending} + fieldNames={Object.fromEntries(fields.map((field) => [field.id, field.values.name]))} + fieldNotes={Object.fromEntries( + fields + .filter((field) => !field.effectiveActive) + .map((field) => [field.id, t("disabledField")]) + )} + fields={selected} + labels={{ + title: t("sections.title"), + add: t("sections.add"), + name: t("sections.name"), + description: t("sections.description"), + ungrouped: t("sections.ungrouped"), + moveUp: t("sections.moveUp"), + moveDown: t("sections.moveDown"), + remove: t("sections.remove"), + removeField: t("sections.removeField"), + required: t("sections.required"), + assign: t("sections.assign"), + help: t("sections.help"), + empty: t("sections.empty"), + }} + onChange={({ sections, fields: next }) => { + form.setValue("sections", sections, { shouldDirty: true }); + form.setValue("fields", next, { shouldDirty: true }); + }} + sections={form.watch("sections") ?? []} + /> + {form.formState.errors.sections && ( +

+ {t("errors.INVALID_SECTIONS")} +

+ )} 0} diff --git a/modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx b/modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx index 7d4d863..57d412b 100644 --- a/modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx +++ b/modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx @@ -1,3 +1,4 @@ +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { useTranslation } from "react-i18next"; @@ -58,11 +59,15 @@ export function CatalogDefinitionDialog({ {t(values.symmetric ? "symmetric" : "directional")}

) : ( -
    - {values.fields.map((binding) => { + { const field = items.find((entry) => entry.id === binding.fieldId); return ( -
  • +

    {field?.values.name}

    {field?.values.kind === "field" && ( @@ -76,10 +81,12 @@ export function CatalogDefinitionDialog({ )}
    {t(binding.required ? "required" : "optional")} -
  • + ); - })} -
+ }} + sections={values.sections} + ungroupedLabel={t("sections.ungrouped")} + /> )} {item.builtin &&

{t("protected")}

} diff --git a/modules/resources/package.json b/modules/resources/package.json index 029191d..2da31c2 100644 --- a/modules/resources/package.json +++ b/modules/resources/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts" }, "dependencies": { "@metis/core": "workspace:*", diff --git a/modules/resources/src/api/application/resource-input.mapper.ts b/modules/resources/src/api/application/resource-input.mapper.ts index 9ae97f6..49e4d19 100644 --- a/modules/resources/src/api/application/resource-input.mapper.ts +++ b/modules/resources/src/api/application/resource-input.mapper.ts @@ -68,11 +68,13 @@ function captureSpecification( unit: field.values.data.unit, choices: [...field.values.data.choices], required: binding.required, + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }; }); const created = ResourceSpecification.create({ templateId: template.id.toPrimitive(), templateName: template.values.data.name, + ...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}), fields, }); if (!created.isSuccess) { diff --git a/modules/resources/src/api/domain/resource-catalog-seed.ts b/modules/resources/src/api/domain/resource-catalog-seed.ts index c14b962..b610b66 100644 --- a/modules/resources/src/api/domain/resource-catalog-seed.ts +++ b/modules/resources/src/api/domain/resource-catalog-seed.ts @@ -1,3 +1,5 @@ +import { UniqueID } from "@repo/rdx-ddd"; + import type { ResourceCatalog } from "./resource-catalog.aggregate"; import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition"; import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; @@ -54,6 +56,14 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [ ], ]; /** Solo alimenta definiciones; no crea recursos ni migra registros anteriores. */ +const SECTION_FIELDS: readonly [string, readonly string[]][] = [ + ["Identificación", ["holder", "issuer", "fingerprint"]], + ["Producto", ["software_product", "software_edition", "provider"]], + ["Contratación", ["license_model", "license_metric", "license_quantity", "reference"]], + ["Vigencia y renovación", ["expires", "renews", "auto_renew"]], + ["Uso y observaciones", ["purpose", "description"]], +]; + export function seedResourceCatalog(catalog: ResourceCatalog) { let current = catalog; const add = (key: string, input: ResourceDefinitionSpec) => { @@ -78,6 +88,15 @@ export function seedResourceCatalog(catalog: ResourceCatalog) { }); } for (const [key, name, fields] of TEMPLATES) { + const sectionFields = fields; + const sections = (fields.length > 3 ? SECTION_FIELDS : []) + .filter(([, keys]) => keys.some((field) => sectionFields.includes(field))) + .map(([name, keys]) => ({ + id: UniqueID.generateNewID().toPrimitive(), + name, + description: "", + keys, + })); const references = fields.map((field) => { const id = current.definitions .find((item) => item.stockKey.getOrUndefined() === `field:${field}`) @@ -85,9 +104,18 @@ export function seedResourceCatalog(catalog: ResourceCatalog) { if (!id) { throw new Error("Campo inicial de recursos ausente."); } - return { fieldId: id, required: false }; + const sectionId = sections.find((section) => section.keys.includes(field))?.id; + return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) }; + }); + add(`template:${key}`, { + kind: "template", + name, + description: "", + fields: references, + ...(sections.length + ? { sections: sections.map(({ id, name, description }) => ({ id, name, description })) } + : {}), }); - add(`template:${key}`, { kind: "template", name, description: "", fields: references }); } return current; } diff --git a/modules/resources/src/api/domain/resource-catalog.aggregate.ts b/modules/resources/src/api/domain/resource-catalog.aggregate.ts index 9ed508d..06d15ab 100644 --- a/modules/resources/src/api/domain/resource-catalog.aggregate.ts +++ b/modules/resources/src/api/domain/resource-catalog.aggregate.ts @@ -121,8 +121,23 @@ export class ResourceCatalog extends AggregateRoot { stockKey: string, values: ResourceDefinitionValues ): Result { - if (this.definitions.some((item) => item.stockKey.getOrUndefined() === stockKey)) { - return Result.ok(this); + const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); + if (previous) { + const upgraded = previous.initializeSections(values); + if (upgraded === previous) { + return Result.ok(this); + } + return Result.ok( + new ResourceCatalog( + { + ...this.props, + definitions: this.definitions.map((item) => + item.id.equals(previous.id) ? upgraded : item + ), + }, + this.id + ) + ); } return this.replace( ResourceDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)), diff --git a/modules/resources/src/api/domain/resource-definition.entity.ts b/modules/resources/src/api/domain/resource-definition.entity.ts index 45b0791..868efed 100644 --- a/modules/resources/src/api/domain/resource-definition.entity.ts +++ b/modules/resources/src/api/domain/resource-definition.entity.ts @@ -3,7 +3,8 @@ import { DomainEntity } from "@repo/rdx-ddd"; import { Maybe, Result } from "@repo/rdx-utils"; import { ResourceCatalogError } from "./resource-definition"; -import type { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; +import { TemplateSection } from "./template-section.entity"; +import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: boolean }> { static create(fieldId: UniqueID, required: boolean, id?: UniqueID) { @@ -19,6 +20,7 @@ export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: b return this.props.required; } } + type DefinitionProps = { companyId: Maybe; stockKey: Maybe; @@ -59,6 +61,11 @@ export class ResourceDefinition extends DomainEntity { get active() { return this.props.active; } + get sections() { + return this.values.data.kind === "template" + ? (this.values.data.sections ?? []).map((section) => TemplateSection.rehydrate(section)) + : []; + } get bindings() { return this.props.bindings; } @@ -90,6 +97,35 @@ export class ResourceDefinition extends DomainEntity { ); return Result.ok(new ResourceDefinition({ ...this.props, values, bindings }, this.id)); } + /** Ampliación idempotente de presentación de serie; conserva datos y disponibilidad. */ + initializeSections(source: ResourceDefinitionValues) { + const previous = this.values.data; + const next = source.data; + if ( + !this.stockKey.getOrUndefined() || + previous.kind !== "template" || + next.kind !== "template" || + previous.sections !== undefined || + !next.sections + ) { + return this; + } + // Solo asociar secciones a campos ya presentes, sin cambiar orden ni obligatoriedad. + const known = new Set(previous.fields.map((field) => field.fieldId)); + const sections = next.sections.filter((section) => + next.fields.some((field) => field.sectionId === section.id && known.has(field.fieldId)) + ); + const sectionIds = new Set(sections.map((section) => section.id)); + const fields = previous.fields.map((field) => { + const sectionId = next.fields.find((entry) => entry.fieldId === field.fieldId)?.sectionId; + return { ...field, ...(sectionId && sectionIds.has(sectionId) ? { sectionId } : {}) }; + }); + const values = ResourceDefinitionValues.create({ ...previous, sections, fields }); + if (!values.isSuccess) { + throw new Error("Secciones iniciales de plantilla inconsistentes."); + } + return new ResourceDefinition({ ...this.props, values: values.data }, this.id); + } setActive(active: boolean) { return active === this.active ? this diff --git a/modules/resources/src/api/domain/resource-definition.ts b/modules/resources/src/api/domain/resource-definition.ts index 50c7e6a..06e1969 100644 --- a/modules/resources/src/api/domain/resource-definition.ts +++ b/modules/resources/src/api/domain/resource-definition.ts @@ -1,8 +1,19 @@ import { z } from "zod/v4"; +import { validTemplateSections } from "./template-sections"; + const title = z.string().min(1).max(100); export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]); -export const TemplateFieldSchema = z.strictObject({ fieldId: z.uuid(), required: z.boolean() }); +export const TemplateFieldSchema = z.strictObject({ + fieldId: z.uuid(), + required: z.boolean(), + sectionId: z.uuid().optional(), +}); +export const TemplateSectionSchema = z.strictObject({ + id: z.uuid(), + name: z.string().trim().min(1).max(100), + description: z.string().trim().max(1000), +}); export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("field"), @@ -12,13 +23,19 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [ unit: z.string().max(30), choices: z.array(title).max(100), }), - z.strictObject({ - kind: z.literal("template"), - name: title, - description: z.string().max(1000), - fields: z.array(TemplateFieldSchema).min(1).max(100), - usage: z.enum(["application", "certificate"]).optional(), - }), + z + .strictObject({ + kind: z.literal("template"), + name: title, + description: z.string().max(1000), + fields: z.array(TemplateFieldSchema).min(1).max(100), + sections: z.array(TemplateSectionSchema).max(30).optional(), + usage: z.enum(["application", "certificate"]).optional(), + }) + .refine((template) => validTemplateSections(template.sections ?? [], template.fields), { + path: ["sections"], + message: "INVALID_SECTIONS", + }), ]); export type ResourceDefinitionSpec = z.infer; export type ResourceFieldType = z.infer; diff --git a/modules/resources/src/api/domain/resource-values.ts b/modules/resources/src/api/domain/resource-values.ts index 8eb1bc3..553f88f 100644 --- a/modules/resources/src/api/domain/resource-values.ts +++ b/modules/resources/src/api/domain/resource-values.ts @@ -1,6 +1,6 @@ import { z } from "zod/v4"; -import { FieldTypeSchema } from "./resource-definition"; +import { FieldTypeSchema, TemplateSectionSchema } from "./resource-definition"; export const ResourceCharacteristicSchema = z.strictObject({ fieldId: z.uuid(), @@ -9,6 +9,7 @@ export const ResourceCharacteristicSchema = z.strictObject({ unit: z.string().max(30), choices: z.array(z.string().min(1).max(100)), required: z.boolean(), + sectionId: z.uuid().optional(), }); export const ResourceValueSchema = z.union([ z.string().max(10000), @@ -43,6 +44,7 @@ export const ResourceInputSchema = z.strictObject({ export const ResourceSpecificationSchema = z.strictObject({ templateId: z.uuid(), templateName: z.string().min(1).max(100), + sections: z.array(TemplateSectionSchema).max(30).optional(), fields: z.array(ResourceCharacteristicSchema).min(1).max(100), }); export type ResourceInput = z.infer; diff --git a/modules/resources/src/api/domain/template-section.entity.ts b/modules/resources/src/api/domain/template-section.entity.ts new file mode 100644 index 0000000..0abe1e8 --- /dev/null +++ b/modules/resources/src/api/domain/template-section.entity.ts @@ -0,0 +1,21 @@ +import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd"; + +import type { TemplateSectionData } from "./template-sections"; + +/** Entidad interna de la plantilla, persistida junto a su definición JSON. */ +export class TemplateSection extends DomainEntity<{ name: Name; description: string }> { + static rehydrate(input: TemplateSectionData) { + const id = UniqueID.create(input.id); + const name = Name.create(input.name, 100); + if (!(id.isSuccess && name.isSuccess)) { + throw new Error("Sección de plantilla persistida inconsistente."); + } + return new TemplateSection({ name: name.data, description: input.description }, id.data); + } + get name() { + return this.props.name; + } + get description() { + return this.props.description; + } +} diff --git a/modules/resources/src/api/domain/template-sections.test.ts b/modules/resources/src/api/domain/template-sections.test.ts new file mode 100644 index 0000000..7ab94ef --- /dev/null +++ b/modules/resources/src/api/domain/template-sections.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { mapResourceInput } from "../application/resource-input.mapper"; +import { ResourceCatalogPersistenceMapper } from "../infrastructure/persistence/resource-catalog-persistence.mapper"; + +import { Resource } from "./resource.aggregate"; +import { ResourceCatalog } from "./resource-catalog.aggregate"; +import { seedResourceCatalog } from "./resource-catalog-seed"; +import { ResourceDefinitionSchema } from "./resource-definition"; +import { ResourceDefinition } from "./resource-definition.entity"; +import type { ResourceInput } from "./resource-values"; +import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; +import { ResourceSpecification, ResourceValues } from "./value-objects/resource-values.vo"; + +const company = UniqueID.generateNewID(); +const actor = UniqueID.generateNewID(); +const fieldId = UniqueID.generateNewID().toPrimitive(); +const section = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Hardware", + description: "Datos técnicos", +}; +const otherSection = { + id: UniqueID.generateNewID().toPrimitive(), + name: "Compra", + description: "", +}; +const template = { + kind: "template" as const, + name: "Plantilla", + description: "", + sections: [section, otherSection], + fields: [{ fieldId, required: false, sectionId: section.id }], +}; +function valid(input = template) { + const result = ResourceDefinitionValues.create(input); + assert.ok(result.isSuccess); + return result.data; +} + +test("secciones: nombres, identidades, referencias y un solo nivel", () => { + assert.ok(ResourceDefinitionSchema.safeParse(template).success); + for (const candidate of [ + { ...template, sections: [section, { ...otherSection, name: " hardware " }] }, + { ...template, sections: [section, { ...otherSection, id: section.id.toUpperCase() }] }, + { ...template, sections: [{ ...section, name: " " }] }, + { ...template, sections: [], fields: template.fields }, + { ...template, sections: [{ ...section, sections: [] }] }, + { + ...template, + sections: Array.from({ length: 31 }, (_, index) => ({ + ...section, + id: UniqueID.generateNewID().toPrimitive(), + name: `Sección ${index}`, + })), + }, + ]) { + assert.equal(ResourceDefinitionSchema.safeParse(candidate).success, false); + } + assert.ok( + ResourceDefinitionSchema.safeParse({ + ...template, + sections: [], + fields: [{ fieldId, required: false }], + }).success + ); +}); + +test("secciones: igualdad, orden, identidad y persistencia JSON", () => { + const first = valid(); + assert.ok(first.equals(valid())); + assert.equal(first.equals(valid({ ...template, sections: [otherSection, section] })), false); + assert.equal( + first.equals( + valid({ + ...template, + fields: [{ ...template.fields[0], fieldId, required: false, sectionId: otherSection.id }], + }) + ), + false + ); + const definition = ResourceDefinition.create(Maybe.none(), first, true); + assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); + assert.equal(definition.sections[0]?.name.toPrimitive(), "Hardware"); + const parsed = ResourceDefinitionSchema.parse(JSON.parse(JSON.stringify(first.data))); + const restored = ResourceDefinitionValues.create(parsed); + assert.ok(restored.isSuccess && first.equals(restored.data)); + assert.ok(Object.isFrozen(first.data.kind === "template" ? first.data.sections : [])); +}); + +test("secciones: plantillas globales y locales conservan separación y UUID al editar", () => { + const field = ResourceDefinitionValues.create({ + kind: "field", + name: "Campo", + description: "", + type: "text", + unit: "", + choices: [], + }); + assert.ok(field.isSuccess); + const root = ResourceCatalog.create().save(Maybe.none(), Maybe.none(), field.data); + assert.ok(root.isSuccess); + const id = root.data.definitions[0]?.id.toPrimitive(); + assert.ok(id); + const grouped = valid({ + ...template, + fields: [{ fieldId: id, required: false, sectionId: section.id }], + }); + for (const scope of [Maybe.none(), Maybe.some(company)]) { + const saved = root.data.save(scope, Maybe.none(), grouped); + assert.ok(saved.isSuccess); + const definition = saved.data.definitions.at(-1); + assert.ok(definition); + assert.equal(definition.sections[0]?.id.toPrimitive(), section.id); + const edited = ResourceDefinitionValues.create({ ...grouped.data, name: "Editada" }); + assert.ok(edited.isSuccess); + const updated = saved.data.save(scope, Maybe.some(definition.id), edited.data); + assert.ok(updated.isSuccess); + const next = updated.data.definitions.at(-1); + assert.ok(next); + assert.equal(next.id.toPrimitive(), definition.id.toPrimitive()); + assert.equal(next.sections[0]?.id.toPrimitive(), section.id); + assert.equal(next.bindings[0]?.id.toPrimitive(), definition.bindings[0]?.id.toPrimitive()); + const rows = new ResourceCatalogPersistenceMapper().toPersistence(updated.data); + const serialized: (typeof rows.definitions)[number] | undefined = rows.definitions.find( + (item) => item.id === definition.id.toPrimitive() + ); + assert.ok(serialized?.values.kind === "template"); + assert.deepEqual(serialized.values.sections, template.sections); + } + const local = root.data.save(Maybe.some(company), Maybe.none(), field.data); + assert.ok(!local.isSuccess); // nombre visible duplicado + const unrelated = root.data.save(Maybe.some(company), Maybe.none(), valid()); + assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía +}); + +test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni referencias", () => { + const seeded = seedResourceCatalog(ResourceCatalog.create()); + const grouped = seeded.definitions.find( + (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 + ); + assert.ok(grouped && grouped.values.data.kind === "template"); + const { sections: omitted, ...flat } = grouped.values.data; + assert.ok(omitted); + const oldValues = ResourceDefinitionValues.create({ + ...flat, + fields: flat.fields.map(({ fieldId, required }) => ({ fieldId, required })), + }); + assert.ok(oldValues.isSuccess); + const old = ResourceDefinition.rehydrate( + { + values: oldValues.data, + active: false, + companyId: grouped.companyId, + stockKey: grouped.stockKey, + bindings: grouped.bindings, + }, + grouped.id + ); + const catalog = ResourceCatalog.rehydrate( + { + definitions: seeded.definitions.map((item) => (item.id.equals(old.id) ? old : item)), + availability: seeded.availability, + }, + seeded.id + ); + const disabled = catalog.setActive(Maybe.some(company), old.id, false); + assert.ok(disabled.isSuccess); + const upgraded = seedResourceCatalog(disabled.data); + const next = upgraded.definitions.find((item) => item.id.equals(old.id)); + assert.ok(next); + assert.equal(next.active, false); + assert.equal(upgraded.active(next, Maybe.some(company)), false); + assert.deepEqual( + next.bindings.map((item) => item.id.toPrimitive()), + old.bindings.map((item) => item.id.toPrimitive()) + ); + assert.ok(next.sections.length > 0); + assert.equal(seedResourceCatalog(upgraded), upgraded); + assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7")); +}); + +test("especificación: secciones capturadas, edición posterior y obligatoriedad independiente", () => { + const catalog = seedResourceCatalog(ResourceCatalog.create()); + const selected = catalog.definitions.find( + (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 + ); + assert.ok(selected && selected.values.data.kind === "template"); + const input: ResourceInput = { + name: "Registro", + templateId: selected.id.toPrimitive(), + active: true, + notes: "", + characteristics: {}, + }; + const context = { catalog, resources: [], references: [], actorNames: new Map() }; + const mapped = mapResourceInput(input, company, context); + assert.ok(mapped.isSuccess); + assert.deepEqual(mapped.data.specification.toPrimitive().sections, selected.values.data.sections); + assert.deepEqual( + mapped.data.specification.toPrimitive().fields.map((item) => item.sectionId), + selected.values.data.fields.map((item) => item.sectionId) + ); + const created = Resource.create( + company, + mapped.data.specification, + mapped.data.values, + actor, + "2026-10-09T12:00:00.000Z" + ); + assert.ok(created.isSuccess); + const flatSpec = ResourceSpecification.create({ + templateId: selected.id.toPrimitive(), + templateName: "Anterior", + fields: mapped.data.specification + .toPrimitive() + .fields.map(({ sectionId: omitted, ...field }) => { + assert.ok(omitted); + return field; + }), + }); + assert.ok(flatSpec.isSuccess); + const previousValues = ResourceValues.create(input, flatSpec.data); + assert.ok(previousValues.isSuccess); + const previous = Resource.create( + company, + flatSpec.data, + previousValues.data, + actor, + "2026-10-09T11:00:00.000Z" + ); + assert.ok(previous.isSuccess); + const edit = mapResourceInput({ ...input, name: "Cambio" }, company, context, previous.data); + assert.ok(edit.isSuccess); + assert.equal(edit.data.specification, previous.data.specification); + assert.equal(edit.data.specification.toPrimitive().sections, undefined); + const spec = mapped.data.specification.toPrimitive(); + const firstField = spec.fields[0]; + assert.ok(firstField); + const required = ResourceSpecification.create({ + ...spec, + fields: spec.fields.map((field) => ({ + ...field, + required: field.fieldId === firstField.fieldId, + })), + }); + assert.ok(required.isSuccess); + assert.equal(ResourceValues.create(input, required.data).isSuccess, false); + assert.ok( + ResourceValues.create( + { ...input, characteristics: { [firstField.fieldId]: "Dato" } }, + required.data + ).isSuccess + ); +}); diff --git a/modules/resources/src/api/domain/template-sections.ts b/modules/resources/src/api/domain/template-sections.ts new file mode 100644 index 0000000..3eae848 --- /dev/null +++ b/modules/resources/src/api/domain/template-sections.ts @@ -0,0 +1,49 @@ +import type { z } from "zod/v4"; + +import type { TemplateSectionSchema } from "./resource-definition"; + +export type TemplateSectionData = z.infer; + +/** Secciones de un solo nivel; las referencias pertenecen exclusivamente a esta plantilla. */ +export function validTemplateSections( + sections: readonly TemplateSectionData[], + fields: readonly { sectionId?: string | undefined }[] +) { + const ids = new Set(sections.map((section) => section.id.toLowerCase())); + const names = new Set(sections.map((section) => section.name.trim().toLocaleLowerCase("es"))); + return ( + ids.size === sections.length && + names.size === sections.length && + fields.every((field) => !field.sectionId || ids.has(field.sectionId.toLowerCase())) + ); +} + +export function freezeTemplateSections(sections: readonly TemplateSectionData[]) { + const frozen = sections.map((section) => + Object.freeze({ + id: section.id.toLowerCase(), + name: section.name.trim(), + description: section.description.trim(), + }) + ); + Object.freeze(frozen); + return frozen; +} + +export function sameTemplateSections( + first: readonly TemplateSectionData[], + second: readonly TemplateSectionData[] +) { + return ( + first.length === second.length && + first.every((section, index) => { + const candidate = second[index]; + return ( + candidate !== undefined && + section.id === candidate.id && + section.name === candidate.name && + section.description === candidate.description + ); + }) + ); +} diff --git a/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts b/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts index 5e5b91d..988bec3 100644 --- a/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts +++ b/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts @@ -3,6 +3,7 @@ import { Result } from "@repo/rdx-utils"; import type { ResourceDefinitionSpec } from "../resource-definition"; import { ResourceCatalogError, ResourceDefinitionSchema } from "../resource-definition"; +import { freezeTemplateSections, sameTemplateSections } from "../template-sections"; /** Definición inmutable con nombre y referencias reconstruidos como Value Objects. */ export class ResourceDefinitionValues { @@ -54,6 +55,7 @@ export class ResourceDefinitionValues { const fields = data.fields.map((field, index) => ({ fieldId: ids[index]?.toPrimitive() ?? field.fieldId, required: field.required, + ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), })); fields.forEach(Object.freeze); Object.freeze(fields); @@ -64,6 +66,7 @@ export class ResourceDefinitionValues { name: name.data.toPrimitive(), description: data.description.trim(), fields, + ...(data.sections ? { sections: freezeTemplateSections(data.sections) } : {}), }), name.data, Object.freeze(ids) @@ -88,10 +91,13 @@ export class ResourceDefinitionValues { a.kind === "template" && b.kind === "template" && a.usage === b.usage && + sameTemplateSections(a.sections ?? [], b.sections ?? []) && a.fields.length === b.fields.length && a.fields.every( (field, index) => - field.fieldId === b.fields[index]?.fieldId && field.required === b.fields[index]?.required + field.fieldId === b.fields[index]?.fieldId && + field.required === b.fields[index]?.required && + field.sectionId === b.fields[index]?.sectionId ) ); } diff --git a/modules/resources/src/api/domain/value-objects/resource-values.vo.ts b/modules/resources/src/api/domain/value-objects/resource-values.vo.ts index 17db6d4..2520109 100644 --- a/modules/resources/src/api/domain/value-objects/resource-values.vo.ts +++ b/modules/resources/src/api/domain/value-objects/resource-values.vo.ts @@ -9,6 +9,11 @@ import type { ResourceSpecificationData, } from "../resource-values"; import { ResourceInputSchema, ResourceSpecificationSchema } from "../resource-values"; +import { + freezeTemplateSections, + sameTemplateSections, + validTemplateSections, +} from "../template-sections"; export class ResourceSpecification { private constructor( @@ -26,7 +31,7 @@ export class ResourceSpecification { ): Result { const parsed = ResourceSpecificationSchema.safeParse(input); if ( - !parsed.success || + !(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) || new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length ) { return Result.fail(new ResourceCatalogError("INVALID_REQUEST")); @@ -39,7 +44,12 @@ export class ResourceSpecification { const frozenFields = input.fields.map((field) => { const choices = [...field.choices]; Object.freeze(choices); - return Object.freeze({ ...field, fieldId: field.fieldId.toLowerCase(), choices }); + return Object.freeze({ + ...field, + fieldId: field.fieldId.toLowerCase(), + choices, + ...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}), + }); }); Object.freeze(frozenFields); return Result.ok( @@ -48,6 +58,7 @@ export class ResourceSpecification { templateId: template.data.toPrimitive(), templateName: name.data.toPrimitive(), fields: frozenFields, + ...(input.sections ? { sections: freezeTemplateSections(input.sections) } : {}), }), template.data, name.data @@ -63,6 +74,7 @@ export class ResourceSpecification { return ( first.templateId === second.templateId && first.templateName === second.templateName && + sameTemplateSections(first.sections ?? [], second.sections ?? []) && first.fields.length === second.fields.length && first.fields.every((field, index) => { const candidate = second.fields[index]; @@ -73,6 +85,7 @@ export class ResourceSpecification { field.type === candidate.type && field.unit === candidate.unit && field.required === candidate.required && + field.sectionId === candidate.sectionId && field.choices.length === candidate.choices.length && field.choices.every((choice, position) => choice === candidate.choices[position]) ); diff --git a/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts b/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts index 10740d0..0fffb14 100644 --- a/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts +++ b/modules/resources/src/api/infrastructure/persistence/migrate-legacy-resources.ts @@ -83,11 +83,13 @@ function spec(catalog: ResourceCatalog, kind: "mailbox" | "access") { unit: field.values.data.unit, choices: [...field.values.data.choices], required: binding.required, + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }; }); const result = ResourceSpecification.create({ templateId: template.id.toPrimitive(), templateName: template.values.data.name, + ...(template.values.data.sections ? { sections: [...template.values.data.sections] } : {}), fields, }); if (!result.isSuccess) { diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index 51895ce..95dc34d 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -135,6 +135,22 @@ "COMPANY_NOT_FOUND": "Company not found.", "INTERNAL_ERROR": "The operation could not be completed.", "FORBIDDEN": "Your session does not allow this operation. Sign in again.", - "UNAUTHENTICATED": "Sign in again." + "UNAUTHENTICATED": "Sign in again.", + "INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template." + }, + "sections": { + "title": "Template sections", + "add": "Add section", + "name": "Section name", + "description": "Description (optional)", + "ungrouped": "Unsectioned", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove section", + "removeField": "Remove field", + "required": "Required", + "assign": "Section", + "help": "Organize fields without changing their values or requirements. Removing a section moves its fields to Unsectioned.", + "empty": "This section has no fields yet." } } diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 4c81217..91dfccd 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -135,6 +135,22 @@ "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "INTERNAL_ERROR": "No se ha podido completar la operación.", "FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.", - "UNAUTHENTICATED": "Vuelve a iniciar sesión." + "UNAUTHENTICATED": "Vuelve a iniciar sesión.", + "INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla." + }, + "sections": { + "title": "Secciones de la plantilla", + "add": "Añadir sección", + "name": "Nombre de la sección", + "description": "Descripción (opcional)", + "ungrouped": "Sin sección", + "moveUp": "Mover hacia arriba", + "moveDown": "Mover hacia abajo", + "remove": "Eliminar sección", + "removeField": "Quitar campo", + "required": "Obligatorio", + "assign": "Sección", + "help": "Organiza los campos sin cambiar sus valores ni su obligatoriedad. Al eliminar una sección, sus campos pasan a «Sin sección».", + "empty": "Esta sección aún no tiene campos." } } diff --git a/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts index 83cc7c8..4b6de99 100644 --- a/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts +++ b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts @@ -1,4 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { UniqueID } from "@repo/rdx-ddd"; +import { copyTemplateLayout } from "@repo/rdx-ui/lib/template-sections"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -20,6 +22,11 @@ export function useCatalogTemplateFormController({ const [creatingField, setCreatingField] = useState(false); const source = item ?? copy; const current = source?.values.kind === "template" ? source.values : undefined; + const layout = copy + ? copyTemplateLayout(current?.sections ?? [], current?.fields ?? [], () => + UniqueID.generateNewID().toPrimitive() + ) + : { sections: current?.sections ?? [], fields: current?.fields ?? [] }; const form = useForm({ resolver: zodResolver(TemplateFormSchema), defaultValues: { @@ -27,7 +34,8 @@ export function useCatalogTemplateFormController({ name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""), description: current?.description ?? "", usage: templateUsage(source), - fields: current?.fields.map((field) => ({ ...field })) ?? [], + fields: layout.fields, + sections: layout.sections, }, }); const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id); diff --git a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index d0fb6bb..ed97369 100644 --- a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -1,6 +1,7 @@ +import { UniqueID } from "@repo/rdx-ddd"; import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; -import { SwitchField } from "@repo/rdx-ui/components/switch-field"; +import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; @@ -83,64 +84,42 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) { /> {!selected.length &&

{t("noFields")}

} -
- {selected.map((binding) => { - const field = fields.find((entry) => entry.id === binding.fieldId); - return ( -
-
-

{field?.values.name ?? binding.fieldId}

- {field && !field.effectiveActive && ( -

{t("disabledField")}

- )} -
-
- - {t("required")} - - {": "} - {field?.values.name ?? ""} - - - } - onCheckedChange={(checked) => - form.setValue( - "fields", - selected.map((entry) => - entry.fieldId === binding.fieldId - ? { ...entry, required: checked === true } - : entry - ), - { shouldDirty: true } - ) - } - /> - -
-
- ); - })} -
+ UniqueID.generateNewID().toPrimitive()} + disabled={mutation.isPending} + fieldNames={Object.fromEntries(fields.map((field) => [field.id, field.values.name]))} + fieldNotes={Object.fromEntries( + fields + .filter((field) => !field.effectiveActive) + .map((field) => [field.id, t("disabledField")]) + )} + fields={selected} + labels={{ + title: t("sections.title"), + add: t("sections.add"), + name: t("sections.name"), + description: t("sections.description"), + ungrouped: t("sections.ungrouped"), + moveUp: t("sections.moveUp"), + moveDown: t("sections.moveDown"), + remove: t("sections.remove"), + removeField: t("sections.removeField"), + required: t("sections.required"), + assign: t("sections.assign"), + help: t("sections.help"), + empty: t("sections.empty"), + }} + onChange={({ sections, fields: next }) => { + form.setValue("sections", sections, { shouldDirty: true }); + form.setValue("fields", next, { shouldDirty: true }); + }} + sections={form.watch("sections") ?? []} + /> + {form.formState.errors.sections && ( +

+ {t("errors.INVALID_SECTIONS")} +

+ )} 0} diff --git a/modules/resources/src/web/catalog/detail/ui/catalog-definition-dialog.tsx b/modules/resources/src/web/catalog/detail/ui/catalog-definition-dialog.tsx index bbbbf62..55241ca 100644 --- a/modules/resources/src/web/catalog/detail/ui/catalog-definition-dialog.tsx +++ b/modules/resources/src/web/catalog/detail/ui/catalog-definition-dialog.tsx @@ -1,3 +1,4 @@ +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { useTranslation } from "react-i18next"; @@ -53,11 +54,15 @@ export function CatalogDefinitionDialog({ )} ) : ( -
    - {values.fields.map((binding) => { + { const field = items.find((entry) => entry.id === binding.fieldId); return ( -
  • +

    {field?.values.name}

    {field?.values.kind === "field" && ( @@ -71,10 +76,12 @@ export function CatalogDefinitionDialog({ )}
    {t(binding.required ? "required" : "optional")} -
  • + ); - })} -
+ }} + sections={values.sections} + ungroupedLabel={t("sections.ungrouped")} + /> )} {item.builtin &&

{t("protected")}

} diff --git a/modules/resources/src/web/resources/detail/ui/resource-detail.tsx b/modules/resources/src/web/resources/detail/ui/resource-detail.tsx index 984ddba..1db0865 100644 --- a/modules/resources/src/web/resources/detail/ui/resource-detail.tsx +++ b/modules/resources/src/web/resources/detail/ui/resource-detail.tsx @@ -1,3 +1,4 @@ +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { useTranslation } from "react-i18next"; import type { ResourceDTO, ResourceReferenceDTO } from "../../../../common/inventory"; @@ -30,7 +31,10 @@ export function ResourceDetail({
{t("status")}
{t(resource.values.active ? "active" : "inactive")}
- {resource.specification.fields.map((field) => ( + + (
{field.name} @@ -40,8 +44,11 @@ export function ResourceDetail({ {display(resource.values.characteristics[field.fieldId])}
- ))} - + )} + sections={resource.specification.sections} + ungroupedLabel={t("sections.ungrouped")} + values + /> {resource.values.notes && (

{resource.values.notes}

@@ -62,16 +69,20 @@ export function ResourceDetail({

{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}

-
- {resource.specification.fields.map((field) => ( + (
{field.name}
{display(revision.values.characteristics[field.fieldId])}
- ))} -
+ )} + sections={resource.specification.sections} + ungroupedLabel={t("sections.ungrouped")} + values + /> {revision.values.notes && (

{revision.values.notes}

diff --git a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts index 1ade3a6..9d7f7cb 100644 --- a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts +++ b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts @@ -55,6 +55,10 @@ export function useResourceFormController({ ); const template = templates.find((item) => item.id === values.templateId); const fields = resourceFields(resource, template, items); + const sections = + resource?.specification.sections ?? + (template?.values.kind === "template" ? template.values.sections : undefined) ?? + []; const isSoftwareLicense = items.some( (item) => item.id === values.templateId && item.builtinKey === "template:software_license" ); @@ -127,6 +131,7 @@ export function useResourceFormController({ templates, template, fields, + sections, helpPrefix, mutation, setCharacteristic, diff --git a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts index 5f3b391..0c535d4 100644 --- a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts +++ b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts @@ -22,6 +22,7 @@ export function resourceFields( unit: field.values.unit, choices: field.values.choices, required: binding.required, + ...(binding.sectionId ? { sectionId: binding.sectionId } : {}), }, ] : []; diff --git a/modules/resources/src/web/resources/shared/ui/resource-form.tsx b/modules/resources/src/web/resources/shared/ui/resource-form.tsx index 00c708a..bf259d3 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-form.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-form.tsx @@ -1,4 +1,5 @@ import { ContextHelp } from "@repo/rdx-ui/components/context-help"; +import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { Button } from "@repo/shadcn-ui/components/button"; @@ -28,6 +29,7 @@ export function ResourceForm(props: ResourceFormProps) { templates, template, fields, + sections, helpPrefix, mutation, setCharacteristic, @@ -101,8 +103,10 @@ export function ResourceForm(props: ResourceFormProps) { {fields.length > 0 && (

{t("characteristics")}

-
- {fields.map((field) => ( + 0} + items={fields} + renderItem={(field) => ( - ))} -
+ )} + sections={sections} + ungroupedLabel={t("sections.ungrouped")} + />
)}
diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index 78343de..f601d10 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx" }, "dependencies": { "@base-ui/react": "1.8.0", diff --git a/packages/rdx-ui/src/components/field-sections.tsx b/packages/rdx-ui/src/components/field-sections.tsx new file mode 100644 index 0000000..b90db6e --- /dev/null +++ b/packages/rdx-ui/src/components/field-sections.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef } from "react"; + +export type FieldSection = { id: string; name: string; description: string }; + +/** Presentación compartida; no conoce catálogos, valores ni endpoints. */ +export function FieldSections({ + sections = [], + items, + ungroupedLabel, + renderItem, + values = false, + hasErrors = false, +}: { + sections?: readonly FieldSection[] | undefined; + items: readonly T[]; + ungroupedLabel: string; + renderItem: (item: T) => ReactNode; + values?: boolean; + hasErrors?: boolean; +}) { + const container = useRef(null); + useEffect(() => { + if (hasErrors) { + for (const details of container.current?.querySelectorAll("details") ?? []) { + details.open = true; + } + } + }, [hasErrors]); + const content = (fields: readonly T[]) => + values ? ( +
{fields.map(renderItem)}
+ ) : ( +
{fields.map(renderItem)}
+ ); + if (!sections.length) { + return content(items); + } + const known = new Set(sections.map((section) => section.id)); + const ungrouped = items.filter((item) => !(item.sectionId && known.has(item.sectionId))); + return ( +
{ + // Abrir antes de que el navegador intente enfocar un control inválido oculto. + if (event.target instanceof HTMLElement) { + const details = event.target.closest("details"); + if (details) { + details.open = true; + } + } + }} + ref={container} + > + {sections.map((section) => { + const fields = items.filter((item) => item.sectionId === section.id); + if (!fields.length) { + return null; + } + return ( +
+ + {section.name} + +
+ {section.description && ( +

{section.description}

+ )} + {content(fields)} +
+
+ ); + })} + {ungrouped.length > 0 && ( +
+

{ungroupedLabel}

+ {content(ungrouped)} +
+ )} +
+ ); +} diff --git a/packages/rdx-ui/src/components/template-section-fields.tsx b/packages/rdx-ui/src/components/template-section-fields.tsx new file mode 100644 index 0000000..dd191e7 --- /dev/null +++ b/packages/rdx-ui/src/components/template-section-fields.tsx @@ -0,0 +1,153 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { ArrowDownIcon, ArrowUpIcon, Trash2Icon } from "lucide-react"; +import { useId } from "react"; + +import type { FieldSection } from "./field-sections"; +import { SelectWithAction } from "./select-with-action"; +import { SwitchField } from "./switch-field"; +import type { SectionBinding, SectionEditorLabels } from "./template-sections-editor"; + +export function TemplateSectionFields({ + sectionId, + sections, + fields, + fieldNames, + fieldNotes, + labels, + disabled, + onChange, +}: { + sectionId?: string | undefined; + sections: readonly FieldSection[]; + fields: readonly SectionBinding[]; + fieldNames: Readonly>; + fieldNotes?: Readonly> | undefined; + labels: SectionEditorLabels; + disabled: boolean; + onChange: (fields: SectionBinding[]) => void; +}) { + const prefix = useId(); + const grouped = fields.filter((field) => field.sectionId === sectionId); + const move = (fieldId: string, offset: number) => { + const position = grouped.findIndex((field) => field.fieldId === fieldId); + const neighbor = grouped[position + offset]; + const current = grouped[position]; + if (!(neighbor && current)) { + return; + } + const next = [...fields]; + const index = fields.indexOf(current); + const other = fields.indexOf(neighbor); + next[index] = neighbor; + next[other] = current; + onChange(next); + }; + if (!(sectionId || grouped.length)) { + return null; + } + return ( +
+ {!sectionId && sections.length > 0 && ( +

{labels.ungrouped}

+ )} + {!grouped.length &&

{labels.empty}

} + {grouped.map((field, index) => { + const name = fieldNames[field.fieldId] ?? field.fieldId; + return ( +
+
+
+

{name}

+ {fieldNotes?.[field.fieldId] && ( +

{fieldNotes[field.fieldId]}

+ )} +
+
+ + + +
+
+
+ + {labels.required} + : {name} + + } + onCheckedChange={(required) => + onChange( + fields.map((item) => + item.fieldId === field.fieldId ? { ...item, required } : item + ) + ) + } + /> + {sections.length > 0 && ( +
+ + onChange( + fields.map((item) => + item.fieldId === field.fieldId + ? { + fieldId: item.fieldId, + required: item.required, + ...(section ? { sectionId: section } : {}), + } + : item + ) + ) + } + options={[ + ...sections.map((section) => ({ + value: section.id, + label: section.name || labels.name, + })), + ]} + placeholder={labels.ungrouped} + value={field.sectionId ?? ""} + /> +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/packages/rdx-ui/src/components/template-sections-editor.tsx b/packages/rdx-ui/src/components/template-sections-editor.tsx new file mode 100644 index 0000000..480c414 --- /dev/null +++ b/packages/rdx-ui/src/components/template-sections-editor.tsx @@ -0,0 +1,163 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useId } from "react"; + +import { removeTemplateSection } from "../lib/template-sections"; + +import type { FieldSection } from "./field-sections"; +import { TemplateSectionFields } from "./template-section-fields"; + +export type SectionBinding = { fieldId: string; required: boolean; sectionId?: string | undefined }; +export type SectionEditorLabels = { + title: string; + add: string; + name: string; + description: string; + ungrouped: string; + moveUp: string; + moveDown: string; + remove: string; + removeField: string; + required: string; + assign: string; + help: string; + empty: string; +}; + +export function TemplateSectionsEditor({ + sections, + fields, + fieldNames, + fieldNotes, + labels, + disabled = false, + createId, + onChange, +}: { + sections: readonly FieldSection[]; + fields: readonly SectionBinding[]; + fieldNames: Readonly>; + fieldNotes?: Readonly> | undefined; + labels: SectionEditorLabels; + disabled?: boolean; + createId: () => string; + onChange: (value: { sections: FieldSection[]; fields: SectionBinding[] }) => void; +}) { + const prefix = useId(); + const saveSections = (next: FieldSection[]) => onChange({ sections: next, fields: [...fields] }); + const moveSection = (index: number, offset: number) => { + const next = [...sections]; + const first = next[index]; + const second = next[index + offset]; + if (first && second) { + next[index] = second; + next[index + offset] = first; + saveSections(next); + } + }; + const removeSection = (id: string) => onChange(removeTemplateSection(sections, fields, id)); + const fieldList = (sectionId?: string | undefined) => ( + onChange({ sections: [...sections], fields: next })} + sectionId={sectionId} + sections={sections} + /> + ); + return ( +
+
+

{labels.title}

+ +
+

{labels.help}

+ {sections.map((section, index) => ( +
+ {section.name || labels.name} +
+
+ + + saveSections( + sections.map((item) => + item.id === section.id ? { ...item, name: event.target.value } : item + ) + ) + } + required + value={section.name} + /> +
+ + + +
+
+ +