Añadir secciones/agrupaciones a las plantillas de activos y recursos

This commit is contained in:
David Arranz 2026-10-09 20:03:56 +02:00
parent 4be0517bdf
commit 91988a26b9
56 changed files with 1848 additions and 198 deletions

View File

@ -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<Response>,
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
);
}

View File

@ -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<Response>,
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: {} },

View File

@ -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<Response>,
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
);
}

View File

@ -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<UniqueID>; 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).

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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",

View File

@ -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) {

View File

@ -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],

View File

@ -118,8 +118,23 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
});
}
seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> {
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)),

View File

@ -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<UniqueID>;
stockKey: Maybe<string>;
@ -59,6 +61,11 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
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<DefinitionProps> {
);
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);
}

View File

@ -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,

View File

@ -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<typeof AssetInputSchema>;

View File

@ -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;
}
}

View File

@ -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<UniqueID>(), 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
);
});

View File

@ -0,0 +1,49 @@
import type { z } from "zod/v4";
import type { TemplateSectionSchema } from "./asset-definition";
export type TemplateSectionData = z.infer<typeof TemplateSectionSchema>;
/** 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
);
})
);
}

View File

@ -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
)
);
}

View File

@ -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<AssetSpecification, AssetCatalogError> {
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])
);

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -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) {
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt>
<dd>{workstations?.map((item) => item.values.name).join(" · ") || t("noWorkstation")}</dd>
</div>
{asset.specification.fields.map((field) => (
</dl>
<FieldSections
items={asset.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
@ -54,8 +58,11 @@ export function AssetDetail(props: AssetDetailProps) {
{display(asset.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
)}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
{asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
)}
@ -84,14 +91,18 @@ export function AssetDetail(props: AssetDetailProps) {
</p>
<details>
<summary className="cursor-pointer">{t("characteristics")}</summary>
<dl className="mt-2 grid gap-2 sm:grid-cols-2">
{asset.specification.fields.map((field) => (
<FieldSections
items={asset.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd>{display(revision.values.characteristics[field.fieldId])}</dd>
</div>
))}
</dl>
)}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}

View File

@ -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,

View File

@ -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 && (
<section className="space-y-3">
<h3 className="font-medium">{t("characteristics")}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) =>
<FieldSections
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
items={fields}
renderItem={(field) =>
field.fieldId === brandFieldId ? (
<BrandSelect
allowQuickCreate={allowQuickCreate}
@ -163,8 +167,10 @@ export function AssetForm(props: AssetFormProps) {
value={values.characteristics[field.fieldId]}
/>
)
)}
</div>
}
sections={sections}
ungroupedLabel={t("sections.ungrouped")}
/>
</section>
)}
<div className="space-y-2">

View File

@ -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<TemplateFormValues>({
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);

View File

@ -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) {
/>
</div>
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
<div className="divide-y rounded-md border">
{selected.map((binding) => {
const field = fields.find((entry) => entry.id === binding.fieldId);
return (
<div className="flex items-center justify-between gap-3 p-3" key={binding.fieldId}>
<div>
<p className="text-sm font-medium">{field?.values.name ?? binding.fieldId}</p>
{field && !field.effectiveActive && (
<p className="text-xs text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<div className="flex items-center gap-2">
<SwitchField
checked={binding.required}
disabled={mutation.isPending}
id={`${id}-required-${binding.fieldId}`}
label={
<>
{t("required")}
<span className="sr-only">
{": "}
{field?.values.name ?? ""}
</span>
</>
}
onCheckedChange={(checked) =>
form.setValue(
"fields",
selected.map((entry) =>
entry.fieldId === binding.fieldId
? { ...entry, required: checked === true }
: entry
),
{ shouldDirty: true }
)
}
/>
<Button
aria-label={t("remove", { name: field?.values.name ?? "" })}
disabled={mutation.isPending}
onClick={() =>
form.setValue(
"fields",
selected.filter((entry) => entry.fieldId !== binding.fieldId),
{ shouldDirty: true }
)
}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
</div>
);
})}
</div>
<TemplateSectionsEditor
createId={() => 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 && (
<p className="text-sm text-destructive" role="alert">
{t("errors.INVALID_SECTIONS")}
</p>
)}
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}

View File

@ -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")}
</p>
) : (
<ul className="divide-y rounded-md border">
{values.fields.map((binding) => {
<FieldSections
items={values.fields}
renderItem={(binding) => {
const field = items.find((entry) => entry.id === binding.fieldId);
return (
<li className="flex justify-between gap-4 p-3 text-sm" key={binding.fieldId}>
<div
className="flex justify-between gap-4 rounded-md border p-3 text-sm"
key={binding.fieldId}
>
<div>
<p className="font-medium">{field?.values.name}</p>
{field?.values.kind === "field" && (
@ -76,10 +81,12 @@ export function CatalogDefinitionDialog({
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</li>
</div>
);
})}
</ul>
}}
sections={values.sections}
ungroupedLabel={t("sections.ungrouped")}
/>
)}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />

View File

@ -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:*",

View File

@ -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) {

View File

@ -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;
}

View File

@ -121,8 +121,23 @@ export class ResourceCatalog extends AggregateRoot<CatalogProps> {
stockKey: string,
values: ResourceDefinitionValues
): Result<ResourceCatalog, ResourceCatalogError> {
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)),

View File

@ -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<UniqueID>;
stockKey: Maybe<string>;
@ -59,6 +61,11 @@ export class ResourceDefinition extends DomainEntity<DefinitionProps> {
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<DefinitionProps> {
);
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

View File

@ -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<typeof ResourceDefinitionSchema>;
export type ResourceFieldType = z.infer<typeof FieldTypeSchema>;

View File

@ -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<typeof ResourceInputSchema>;

View File

@ -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;
}
}

View File

@ -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<UniqueID>(), 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
);
});

View File

@ -0,0 +1,49 @@
import type { z } from "zod/v4";
import type { TemplateSectionSchema } from "./resource-definition";
export type TemplateSectionData = z.infer<typeof TemplateSectionSchema>;
/** 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
);
})
);
}

View File

@ -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
)
);
}

View File

@ -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<ResourceSpecification, ResourceCatalogError> {
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])
);

View File

@ -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) {

View File

@ -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."
}
}

View File

@ -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."
}
}

View File

@ -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<TemplateFormValues>({
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);

View File

@ -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) {
/>
</div>
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
<div className="divide-y rounded-md border">
{selected.map((binding) => {
const field = fields.find((entry) => entry.id === binding.fieldId);
return (
<div className="flex items-center justify-between gap-3 p-3" key={binding.fieldId}>
<div>
<p className="text-sm font-medium">{field?.values.name ?? binding.fieldId}</p>
{field && !field.effectiveActive && (
<p className="text-xs text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<div className="flex items-center gap-2">
<SwitchField
checked={binding.required}
disabled={mutation.isPending}
id={`${id}-required-${binding.fieldId}`}
label={
<>
{t("required")}
<span className="sr-only">
{": "}
{field?.values.name ?? ""}
</span>
</>
}
onCheckedChange={(checked) =>
form.setValue(
"fields",
selected.map((entry) =>
entry.fieldId === binding.fieldId
? { ...entry, required: checked === true }
: entry
),
{ shouldDirty: true }
)
}
/>
<Button
aria-label={t("remove", { name: field?.values.name ?? "" })}
disabled={mutation.isPending}
onClick={() =>
form.setValue(
"fields",
selected.filter((entry) => entry.fieldId !== binding.fieldId),
{ shouldDirty: true }
)
}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
</div>
);
})}
</div>
<TemplateSectionsEditor
createId={() => 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 && (
<p className="text-sm text-destructive" role="alert">
{t("errors.INVALID_SECTIONS")}
</p>
)}
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}

View File

@ -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({
)}
</dl>
) : (
<ul className="divide-y rounded-md border">
{values.fields.map((binding) => {
<FieldSections
items={values.fields}
renderItem={(binding) => {
const field = items.find((entry) => entry.id === binding.fieldId);
return (
<li className="flex justify-between gap-4 p-3 text-sm" key={binding.fieldId}>
<div
className="flex justify-between gap-4 rounded-md border p-3 text-sm"
key={binding.fieldId}
>
<div>
<p className="font-medium">{field?.values.name}</p>
{field?.values.kind === "field" && (
@ -71,10 +76,12 @@ export function CatalogDefinitionDialog({
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</li>
</div>
);
})}
</ul>
}}
sections={values.sections}
ungroupedLabel={t("sections.ungrouped")}
/>
)}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />

View File

@ -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({
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
</div>
{resource.specification.fields.map((field) => (
</dl>
<FieldSections
items={resource.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
@ -40,8 +44,11 @@ export function ResourceDetail({
{display(resource.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
)}
sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
<IntegrationSummary integration={resource.values.integration} references={references} />
{resource.values.notes && (
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
@ -62,16 +69,20 @@ export function ResourceDetail({
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<dl className="grid gap-2 sm:grid-cols-2">
{resource.specification.fields.map((field) => (
<FieldSections
items={resource.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd className="whitespace-pre-wrap break-words">
{display(revision.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
)}
sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
<IntegrationSummary integration={revision.values.integration} references={references} />
{revision.values.notes && (
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>

View File

@ -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,

View File

@ -22,6 +22,7 @@ export function resourceFields(
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
...(binding.sectionId ? { sectionId: binding.sectionId } : {}),
},
]
: [];

View File

@ -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 && (
<section className="space-y-3">
<h3 className="font-medium">{t("characteristics")}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<FieldSections
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
items={fields}
renderItem={(field) => (
<ResourceCharacteristicControl
disabled={mutation.isPending}
field={requireMailboxAddress(field, items, values, template)}
@ -111,8 +115,10 @@ export function ResourceForm(props: ResourceFormProps) {
prefix={prefix}
value={values.characteristics[field.fieldId]}
/>
))}
</div>
)}
sections={sections}
ungroupedLabel={t("sections.ungrouped")}
/>
</section>
)}
<div className="space-y-2">

View File

@ -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",

View File

@ -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<T extends { sectionId?: string | undefined }>({
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<HTMLDivElement>(null);
useEffect(() => {
if (hasErrors) {
for (const details of container.current?.querySelectorAll("details") ?? []) {
details.open = true;
}
}
}, [hasErrors]);
const content = (fields: readonly T[]) =>
values ? (
<dl className="grid gap-4 sm:grid-cols-2">{fields.map(renderItem)}</dl>
) : (
<div className="grid gap-4 sm:grid-cols-2">{fields.map(renderItem)}</div>
);
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 (
<div
className="space-y-3"
onInvalidCapture={(event) => {
// 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 (
<details className="rounded-md border" key={section.id} open>
<summary className="cursor-pointer rounded-md px-4 py-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring">
{section.name}
</summary>
<div className="space-y-3 border-t p-4">
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
{content(fields)}
</div>
</details>
);
})}
{ungrouped.length > 0 && (
<section className="space-y-3">
<h4 className="text-sm font-medium">{ungroupedLabel}</h4>
{content(ungrouped)}
</section>
)}
</div>
);
}

View File

@ -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<Record<string, string>>;
fieldNotes?: Readonly<Record<string, string>> | 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 (
<div className="space-y-2">
{!sectionId && sections.length > 0 && (
<h4 className="text-sm font-medium">{labels.ungrouped}</h4>
)}
{!grouped.length && <p className="text-xs text-muted-foreground">{labels.empty}</p>}
{grouped.map((field, index) => {
const name = fieldNames[field.fieldId] ?? field.fieldId;
return (
<div className="space-y-2 rounded-md border p-3" key={field.fieldId}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="break-words text-sm font-medium">{name}</p>
{fieldNotes?.[field.fieldId] && (
<p className="text-xs text-muted-foreground">{fieldNotes[field.fieldId]}</p>
)}
</div>
<div className="flex shrink-0 items-center">
<Button
aria-label={`${labels.moveUp}: ${name}`}
disabled={disabled || index === 0}
onClick={() => move(field.fieldId, -1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowUpIcon />
</Button>
<Button
aria-label={`${labels.moveDown}: ${name}`}
disabled={disabled || index === grouped.length - 1}
onClick={() => move(field.fieldId, 1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowDownIcon />
</Button>
<Button
aria-label={`${labels.removeField}: ${name}`}
disabled={disabled}
onClick={() => onChange(fields.filter((item) => item.fieldId !== field.fieldId))}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<SwitchField
checked={field.required}
disabled={disabled}
id={`${prefix}-${field.fieldId}-required`}
label={
<>
{labels.required}
<span className="sr-only">: {name}</span>
</>
}
onCheckedChange={(required) =>
onChange(
fields.map((item) =>
item.fieldId === field.fieldId ? { ...item, required } : item
)
)
}
/>
{sections.length > 0 && (
<div className="min-w-40 flex-1">
<SelectWithAction
disabled={disabled}
id={`${prefix}-${field.fieldId}-section`}
label={`${labels.assign}: ${name}`}
onValueChange={(section) =>
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 ?? ""}
/>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@ -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<Record<string, string>>;
fieldNotes?: Readonly<Record<string, string>> | 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) => (
<TemplateSectionFields
disabled={disabled}
fieldNames={fieldNames}
fieldNotes={fieldNotes}
fields={fields}
labels={labels}
onChange={(next) => onChange({ sections: [...sections], fields: next })}
sectionId={sectionId}
sections={sections}
/>
);
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">{labels.title}</h3>
<Button
disabled={disabled || sections.length >= 30}
onClick={() => saveSections([...sections, { id: createId(), name: "", description: "" }])}
size="sm"
type="button"
variant="outline"
>
<PlusIcon aria-hidden="true" />
{labels.add}
</Button>
</div>
<p className="text-xs text-muted-foreground">{labels.help}</p>
{sections.map((section, index) => (
<fieldset className="space-y-3 rounded-md border p-3" disabled={disabled} key={section.id}>
<legend className="sr-only">{section.name || labels.name}</legend>
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-2">
<Label htmlFor={`${prefix}-${section.id}-name`}>{labels.name}</Label>
<Input
id={`${prefix}-${section.id}-name`}
maxLength={100}
onChange={(event) =>
saveSections(
sections.map((item) =>
item.id === section.id ? { ...item, name: event.target.value } : item
)
)
}
required
value={section.name}
/>
</div>
<Button
aria-label={`${labels.moveUp}: ${section.name}`}
disabled={disabled || index === 0}
onClick={() => moveSection(index, -1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowUpIcon />
</Button>
<Button
aria-label={`${labels.moveDown}: ${section.name}`}
disabled={disabled || index === sections.length - 1}
onClick={() => moveSection(index, 1)}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowDownIcon />
</Button>
<Button
aria-label={`${labels.remove}: ${section.name}`}
disabled={disabled}
onClick={() => removeSection(section.id)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2Icon />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-${section.id}-description`}>{labels.description}</Label>
<Textarea
id={`${prefix}-${section.id}-description`}
maxLength={1000}
onChange={(event) =>
saveSections(
sections.map((item) =>
item.id === section.id ? { ...item, description: event.target.value } : item
)
)
}
rows={2}
value={section.description}
/>
</div>
{fieldList(section.id)}
</fieldset>
))}
{fieldList()}
</section>
);
}

View File

@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { FieldSections } from "./components/field-sections";
import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections";
const sections = [
{ id: "hardware", name: "Hardware", description: "Datos técnicos" },
{ id: "purchase", name: "Compra", description: "" },
];
const fields = [
{ fieldId: "ram", required: false, sectionId: "hardware" },
{ fieldId: "date", required: true, sectionId: "purchase" },
{ fieldId: "notes", required: false },
];
test("quitar sección conserva todos los campos y su obligatoriedad sin mutar el origen", () => {
const result = removeTemplateSection(sections, fields, "purchase");
assert.equal(result.sections.length, 1);
assert.deepEqual(result.fields, [fields[0], { fieldId: "date", required: true }, fields[2]]);
assert.equal(fields[1]?.sectionId, "purchase");
});
test("copiar remapea secciones conservando campos y reglas con identidades independientes", () => {
let next = 0;
const copy = copyTemplateLayout(sections, fields, () => `copy-${++next}`);
assert.deepEqual(
copy.sections.map((section) => section.id),
["copy-1", "copy-2"]
);
assert.deepEqual(copy.fields, [
{ fieldId: "ram", required: false, sectionId: "copy-1" },
{ fieldId: "date", required: true, sectionId: "copy-2" },
fields[2],
]);
assert.equal(sections[0]?.id, "hardware");
});
test("secciones visuales ordenadas, abiertas inicialmente y campos sin sección", () => {
const html = renderToStaticMarkup(
<FieldSections
items={fields}
renderItem={(field) => <div key={field.fieldId}>{field.fieldId}</div>}
sections={[...sections].reverse()}
ungroupedLabel="Sin sección"
/>
);
assert.ok(html.includes("<summary"));
assert.ok(html.includes("open="));
assert.ok(html.indexOf("Compra") < html.indexOf("Hardware"));
assert.ok(html.includes("Datos técnicos"));
assert.ok(html.includes("Sin sección"));
assert.equal((html.match(/>ram</g) ?? []).length, 1);
const plain = renderToStaticMarkup(
<FieldSections
items={fields.map(({ fieldId }) => ({ fieldId, sectionId: undefined }))}
renderItem={(field) => <div key={field.fieldId}>{field.fieldId}</div>}
ungroupedLabel="Sin sección"
/>
);
assert.equal(plain.includes("<summary"), false);
assert.equal(plain.includes("Sin sección"), false);
});

View File

@ -0,0 +1,36 @@
import type { FieldSection } from "../components/field-sections";
import type { SectionBinding } from "../components/template-sections-editor";
/** Quitar presentación conserva las referencias a campos y sus reglas. */
export function removeTemplateSection(
sections: readonly FieldSection[],
fields: readonly SectionBinding[],
id: string
) {
return {
sections: sections.filter((section) => section.id !== id),
fields: fields.map((field) =>
field.sectionId === id ? { fieldId: field.fieldId, required: field.required } : { ...field }
),
};
}
/** Una copia tiene identidades propias, sin compartir secciones con la plantilla original. */
export function copyTemplateLayout(
sections: readonly FieldSection[],
fields: readonly SectionBinding[],
createId: () => string
) {
const ids = new Map(sections.map((section) => [section.id, createId()]));
return {
sections: sections.map((section) => ({ ...section, id: ids.get(section.id) ?? section.id })),
fields: fields.map((field) => {
const sectionId = field.sectionId ? ids.get(field.sectionId) : undefined;
return {
fieldId: field.fieldId,
required: field.required,
...(sectionId ? { sectionId } : {}),
};
}),
};
}