Repaso a las plantillas de activos y de recursos

This commit is contained in:
David Arranz 2026-10-10 22:22:28 +02:00
parent 832d69c6fa
commit 6d6f807486
55 changed files with 1101 additions and 389 deletions

View File

@ -1,5 +1,2 @@
- En la ficha de una SEDE necesito un campo opcional para el teléfono
- En el avatar de la compañía, cuando no hay logotipo, aparecen las iniciles. El tamaño de letra de esas iniciales es muy pequeño. Incrementa el tamaño de forma adeucada a las dimensiones del avatar.
- Poder eliminar un activo global personalizado
- En toda la aplicaicón, si hay alguna columna en un listado que es "activo/inactivo", el color para el estado activo es verde.
- En el listado de personas de una compañía, muestra el email que tiene esa persona en su ficha
- En los campos de recursos, añade texto de ayuda a cada campo que no lo tenga ya.
- Quiero buscar un sistema de nombrado para puestos de trabajo. Cuando se da de alta un puesto de trabajo Quiero que el campo "nombre" se rellene dinámicamente cuando el usuario ha elegido la sede y la persona del puesto. Por ejemplo, "Puesto de Fulanito en Uecko Fábrica". El usuario tiene la opción de borrar eso y poner lo que quiera antes de guardar los cambios.

View File

@ -44,21 +44,22 @@ export async function verifyAssets(
const initial = await read(global);
assert.equal(initial.items.length, 75);
assert.equal(initial.items.length, 72);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12);
const laptop = initial.items.find(
(item) => item.values.kind === "template" && item.values.name === "Portátil"
);
assert.ok(laptop?.values.kind === "template");
assert.equal(laptop.values.fields.length, 39);
assert.equal(laptop.values.sections?.length, 10);
const batteryState = initial.items.find(
(item) => item.values.kind === "field" && item.values.name === "Estado de batería"
assert.equal(laptop.values.fields.length, 25);
assert.equal(laptop.values.sections?.length, 8);
const ports = initial.items.find(
(item) => item.values.kind === "field" && item.values.name === "Puertos físicos"
);
assert.ok(batteryState?.values.kind === "field");
assert.deepEqual(batteryState.values.choices, ["Normal", "Desgastada", "Averiada", "Hinchada"]);
assert.ok(ports?.values.kind === "field");
assert.equal(ports.values.type, "multiline");
assert.ok(ports.values.description.includes("RJ45"));
assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
assert.ok(

View File

@ -50,7 +50,7 @@ export async function verifyInventory(
const laptop = catalog.items.find((item) => item.values.name === "Portátil");
assert.ok(laptop);
const inch = catalog.items.find((item) => item.values.name === "Pulgadas");
const inch = catalog.items.find((item) => item.values.name === "Tamaño de pantalla");
assert.ok(inch);
@ -90,7 +90,7 @@ export async function verifyInventory(
assert.equal((await read(`/assets/${otherId}`)).assets.length, 0);
await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404);
await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404);
await error(path, { ...input, characteristics: { [inch.id]: "24" } }, "INVALID_CHARACTERISTICS");
await error(path, { ...input, characteristics: { [inch.id]: 24 } }, "INVALID_CHARACTERISTICS");
await error(
path,
{ ...input, characteristics: { [foreignSite.id]: 24 } },
@ -103,7 +103,7 @@ export async function verifyInventory(
...input,
siteId: ownSite.id,
status: "in_use",
characteristics: { [inch.id]: 24 },
characteristics: { [inch.id]: "24 pulgadas" },
});
assert.equal(inUse.id, created.id);
@ -143,7 +143,7 @@ export async function verifyInventory(
...input,
templateId,
name: "MON-02",
characteristics: { [inch.id]: 0 },
characteristics: { [inch.id]: "0" },
});
const revisionId = custom.history[0]?.id;
@ -186,5 +186,24 @@ export async function verifyInventory(
const stored = (await read()).assets.find((asset) => asset.id === updated.id);
assert.deepEqual(stored, updated);
const router = catalog.items.find((item) => item.values.name === "Router");
const ports = catalog.items.find((item) => item.values.name === "Puertos físicos");
assert.ok(router && ports);
const networkAsset = await save(path, {
...input,
name: "Router texto largo",
templateId: router.id,
characteristics: { [ports.id]: "24 RJ45\n4 SFP+" },
});
const networkUpdated = await save(`${path}/${networkAsset.id}/update`, {
...networkAsset.values,
characteristics: { [ports.id]: "24 RJ45\n4 SFP+\nUSB de servicio" },
});
const networkStored = (await read()).assets.find((asset) => asset.id === networkUpdated.id);
assert.deepEqual(networkStored, networkUpdated);
assert.equal(networkStored?.history[0]?.values.characteristics[ports.id], "24 RJ45\n4 SFP+");
assert.equal(networkStored?.values.characteristics[ports.id], "24 RJ45\n4 SFP+\nUSB de servicio");
assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404);
}

View File

@ -7,7 +7,7 @@ El Catálogo de activos de la navegación principal contiene las definiciones gl
Las altas y ediciones se abren mediante el botón de cabecera o la acción del listado. Los formularios son modales, con ayuda inferior y Guardar / Cancelar. Ver consulta una definición sin modificarla. Crear copia inicia una nueva plantilla personalizable.
Un campo representa una característica reutilizable: texto, número, fecha, sí/no o lista de valores. Tiene nombre, descripción y unidad opcionales. Una lista necesita valores únicos; el tipo queda fijo tras el alta y sus valores existentes no pueden eliminarse. Se pueden añadir valores. Las credenciales no pertenecen a este catálogo.
Un campo representa una característica reutilizable: texto, texto largo, número, fecha, sí/no o lista de valores. Tiene nombre, descripción y unidad opcionales. Una lista necesita valores únicos; el tipo queda fijo tras el alta y sus valores existentes no pueden eliminarse. Se pueden añadir valores. Las credenciales no pertenecen a este catálogo.
Una plantilla reúne campos para un tipo de activo. No representa un activo concreto ni un modelo comercial. Cada campo comienza como opcional; el técnico puede marcarlo obligatorio en plantillas propias. Se pueden añadir y retirar campos. Retirar una asociación no elimina la definición del campo. El orden de incorporación se conserva.
@ -15,9 +15,9 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d
## Datos de serie
Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 58 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI.
Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 55 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI.
Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía.
Los campos describen identidad física y comercial, hardware, conectividad, batería, alimentación, software precargado, compra, garantía y soporte. IP, nombre de equipo, usuario asignado y mediciones operativas actuales no forman parte del catálogo de serie.
Todas las características iniciales de las plantillas de serie son opcionales. Las plantillas globales, incluidas las de serie, permiten editar nombre, descripción, icono, grupos, campos incluidos y obligatoriedad. Los campos de serie admiten edición; su tipo permanece fijo y las opciones existentes no se pueden retirar. Los tipos de relación de serie mantienen su protección. Se pueden activar o desactivar. Para particularizar una plantilla para una compañía, crear una copia. No hay borrado de definiciones.
@ -27,7 +27,7 @@ El arranque incorpora únicamente definiciones ausentes mediante claves de serie
Una definición global está disponible para todas las compañías. Una adición local solo está visible en su compañía. No se pueden editar definiciones globales desde una compañía; se puede crear una copia de una plantilla.
La disponibilidad global es el valor predeterminado. Cada compañía puede establecer una activación o desactivación propia de una definición global, sin afectar a otras. Usar configuración global elimina el efecto del ajuste local, conservando su UUID.
La disponibilidad global es el valor predeterminado. Cada compañía puede establecer una activación o desactivación propia de una definición global, sin afectar a otras. Usar configuración global elimina el efecto del ajuste local, conservando su UUID. Las definiciones globales personalizadas se pueden eliminar tras confirmación si no están usadas por activos, historial, relaciones o plantillas; las definiciones de serie y las adiciones de compañía se conservan.
No se aceptan nombres duplicados del mismo tipo en el catálogo visible, ignorando mayúsculas y espacios exteriores. Una nueva definición global tampoco puede colisionar con una adición local.
@ -39,7 +39,7 @@ El módulo assets sigue domain, application e infrastructure. AssetCatalog es la
Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_availability tienen claves primarias y foráneas explícitas. Las migraciones son idempotentes. Un bloqueo de catálogo serializa altas, cambios de nombres, campos asociados y disponibilidad para evitar colisiones concurrentes. El mapper reconstruye y comprueba definiciones y asociaciones persistidas.
Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update y POST /api/asset-catalog/:scope/:id/status requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito.
Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update, POST /api/asset-catalog/:scope/:id/status y POST /api/asset-catalog/:scope/:id/delete requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito.
## Inventario y relaciones
@ -61,19 +61,36 @@ Se persiste la clave validada del icono en la columna nullable `asset_definition
## Plantilla Portátil
La plantilla incluye diez grupos, con todos los campos opcionales:
La plantilla incluye 25 campos opcionales en ocho grupos:
- Identificación: marca, modelo, número de serie y código de producto / Service Tag.
- Hardware: procesador, tarjeta gráfica, webcam, Wi-Fi y Bluetooth.
- Memoria RAM: capacidad, tipo, distribución, ranuras libres y memoria soldada.
- Almacenamiento: descripción/capacidad, modelo, interfaz, formato y ranuras libres.
- Pantalla: pulgadas, resolución, pantalla táctil y referencia del panel.
- Teclado: distribución/idioma, teclado numérico y retroiluminación.
- Batería: referencia, capacidad de diseño y máxima actual (Wh), ciclos, estado y fecha de comprobación. Estados: Normal, Desgastada, Averiada e Hinchada; sin información se deja vacío.
- Alimentación: potencia del cargador (W), conector y referencia.
- Compra y garantía: fecha de compra y fin de garantía.
- Soporte y reparación: enlace al manual de servicio y observaciones técnicas.
- Hardware: procesador, tarjeta gráfica, webcam, memoria RAM, almacenamiento, pantalla integrada, micrófono, idioma del teclado, teclado numérico y retroiluminación.
- Conectividad: descripción de las interfaces y estándares disponibles.
- Batería: referencia/modelo y capacidad de fábrica.
- Alimentación: potencia, conector y referencia/modelo del cargador.
- Software: sistema operativo precargado.
- Compra y garantía: fecha de compra, fin de garantía y proveedor.
- Soporte y reparación: enlace a la página de soporte.
Se reutilizan los campos existentes de RAM, almacenamiento y teclado numérico; los diagnósticos detallados y reparaciones siguen perteneciendo a las intervenciones. Sistema, Red y Conexiones no forman parte de esta plantilla.
No incluye capacidad actual de la batería, ciclos, estado ni fecha de comprobación.
Estas mediciones pertenecen a comprobaciones o intervenciones fechadas.
La plantilla inicial Portátil incluye estos campos y grupos. El arranque no sobrescribe las plantillas existentes; pueden revisarse desde el catálogo global conservando su UUID y disponibilidad. Las definiciones de campos retirados siguen disponibles para otras plantillas. No se modifican las copias personalizadas ni las instantáneas, características o historial de activos ya registrados.
## Catálogo revisado del 10/10/2026
El catálogo de serie refleja el Excel aprobado por el usuario: 55 campos compartidos,
12 plantillas y 161 asociaciones de campos. Conserva los nombres, tipos, grupos y
orden indicados. Todos los campos de características siguen siendo opcionales.
Los ejemplos ilustrativos se utilizan como texto de ayuda; cuando varias plantillas
comparten un campo, se reúnen sus ejemplos sin duplicados en la definición global.
Los tipos son texto, texto largo, fecha y booleano. Texto largo se representa con
un área de texto y se persiste en la columna relacional `textValue`, conservando
los saltos de línea. La actualización explícita e idempotente de las restricciones
CHECK admite `multiline` sin desactivar la integridad referencial ni guardar JSON.
Las ediciones posteriores de las definiciones se conservan al reiniciar.
Los activos describen identidad física/comercial y características de fábrica.
El sistema operativo precargado no representa el sistema operativo actualmente
instalado, que se registra como recurso. Las personas se relacionan mediante
puestos de trabajo y perfiles de usuario. La plantilla Servidor conserva los dos
campos de sistema operativo con nombres diferentes que aparecen en el Excel.

View File

@ -6,7 +6,7 @@ Tras iniciar sesión, abrir Compañías. El listado permite buscar por nombre, c
- Compañía: nombre obligatorio (100 caracteres como máximo) y estado activo/inactivo.
- Persona: nombre obligatorio; puesto, email y teléfono opcionales; estado activo/inactivo y clasificación como contacto. Nombre y puesto admiten 100 caracteres. El email se normaliza a minúsculas; el teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión.
- Sede: nombre obligatorio y dirección opcional, multilínea, de hasta 10.000 caracteres. Una dirección omitida, vacía o con solo espacios se guarda como ausencia.
- Sede: nombre obligatorio, teléfono opcional y dirección opcional, multilínea, de hasta 10.000 caracteres. El teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión. Una dirección o teléfono omitidos, vacíos o con solo espacios se guardan como ausencia.
Las compañías representan empresas clientes atendidas, no la organización que presta soporte ni usuarios con acceso. Todos los usuarios autenticados activos pueden gestionarlas. El administrador inicial conserva su autorización exclusiva sobre la gestión de usuarios.
@ -43,6 +43,6 @@ Los contratos Zod se declaran en common/dto/request, response y shared. Las muta
## Validación
Las pruebas cubren identidad UUID, invariantes, pertenencia al agregado, actualizaciones, dirección opcional y límites. Las pruebas HTTP usan exclusivamente metis_desk_test y comprueban autenticación, CSRF/origen, persistencia, relaciones y direcciones de 10.000 caracteres Unicode.
Las pruebas cubren identidad UUID, invariantes, pertenencia al agregado, actualizaciones, teléfono y dirección opcionales y límites. Las pruebas HTTP usan exclusivamente metis_desk_test y comprueban autenticación, CSRF/origen, persistencia, relaciones y direcciones de 10.000 caracteres Unicode.
También se ha probado en el navegador la búsqueda, la creación de compañías y contactos, y la creación y edición de sedes con direcciones multilínea, usando datos ficticios en el entorno de pruebas aislado.

View File

@ -62,4 +62,15 @@ export class AssetCatalogUseCases {
catalog.setActive(parent.data, id.data, active)
);
}
async remove(scope: string, identity: string) {
const parent = catalogScope(scope);
const id = UniqueID.create(identity);
if (!(parent.isSuccess && id.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return this.repository.update(parent.data, (catalog) => catalog.remove(parent.data, id.data));
}
}

View File

@ -6,150 +6,302 @@ import type { AssetCatalog } from "./asset-catalog.aggregate";
import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
const FIELDS: readonly [string, string, AssetFieldType, string, (readonly string[])?][] = [
["product_code", "Código de producto / Service Tag", "text", ""],
["memory_type", "Tipo de RAM", "text", ""],
["memory_layout", "Distribución de RAM", "text", ""],
["memory_free_slots", "Ranuras libres de RAM", "number", ""],
["memory_soldered", "Memoria soldada", "boolean", ""],
["disk_model", "Modelo del disco", "text", ""],
["disk_interface", "Interfaz del disco", "text", ""],
["disk_format", "Formato del disco", "text", ""],
["disk_free_slots", "Ranuras libres de almacenamiento", "number", ""],
["touchscreen", "Pantalla táctil", "boolean", ""],
["panel_reference", "Referencia del panel", "text", ""],
["keyboard_layout", "Distribución / idioma del teclado", "text", ""],
["keyboard_backlight", "Teclado retroiluminado", "boolean", ""],
["battery_reference", "Referencia / modelo de batería", "text", ""],
["battery_design_capacity", "Capacidad de diseño de batería", "number", "Wh"],
["battery_full_capacity", "Capacidad máxima actual de batería", "number", "Wh"],
["battery_cycles", "Número de ciclos de batería", "number", ""],
// Catálogo revisado por el usuario en el Excel del 10/10/2026. Todos los campos son opcionales.
const FIELDS: readonly [string, string, AssetFieldType, string][] = [
[
"battery_status",
"Estado de batería",
"choice",
"",
["Normal", "Desgastada", "Averiada", "Hinchada"],
"brand",
"Marca",
"text",
"Lenovo\nHP\nDell\nKyocera\nMikroTik\nFortinet\nAruba\nUbiquiti\nSynology\nSamsung\nAPC",
],
["battery_checked_on", "Fecha de comprobación de batería", "date", ""],
["charger_power", "Potencia del cargador", "number", "W"],
["charger_connector", "Tipo de conector del cargador", "text", ""],
["charger_reference", "Referencia / modelo del cargador", "text", ""],
["service_manual", "Enlace al manual de servicio", "text", ""],
["technical_notes", "Observaciones técnicas", "text", ""],
["brand", "Marca", "text", ""],
["model", "Modelo", "text", ""],
["serial", "Número de serie", "text", ""],
["processor", "Procesador", "text", ""],
["memory", "Memoria RAM", "number", "GB"],
["storage", "Almacenamiento", "text", ""],
["graphics", "Tarjeta gráfica", "text", ""],
["webcam", "Webcam", "text", ""],
["bluetooth", "Versión de Bluetooth", "text", ""],
["numeric_keypad", "Teclado numérico", "boolean", ""],
["os", "Sistema operativo", "text", ""],
["hostname", "Nombre de equipo", "text", ""],
["screen_size", "Pulgadas", "number", "pulgadas"],
["resolution", "Resolución", "text", ""],
["connections", "Conexiones", "text", ""],
["ip", "Dirección IP", "text", ""],
["mac", "Dirección MAC", "text", ""],
["ports", "Número de puertos", "number", ""],
["speed", "Velocidad de red", "text", ""],
["poe", "PoE", "boolean", ""],
["wifi", "Estándar Wi-Fi", "text", ""],
["capacity", "Capacidad útil", "text", ""],
["disks", "Discos", "text", ""],
["raid", "RAID", "text", ""],
["printing", "Tecnología de impresión", "text", ""],
["color", "Impresión en color", "boolean", ""],
["duplex", "Doble cara", "boolean", ""],
["imei", "IMEI", "text", ""],
["phone", "Número de teléfono", "text", ""],
["carrier", "Operador móvil", "text", ""],
["power", "Potencia", "number", "VA"],
["battery", "Autonomía", "number", "minutos"],
["purchased_on", "Fecha de compra", "date", ""],
["warranty_until", "Fin de garantía", "date", ""],
];
const COMMON = ["brand", "model", "serial", "purchased_on", "warranty_until"];
const LAPTOP_SECTION_FIELDS: readonly [string, readonly string[]][] = [
["Identificación", ["brand", "model", "serial", "product_code"]],
["Hardware", ["processor", "graphics", "webcam", "wifi", "bluetooth"]],
[
"Memoria RAM",
["memory", "memory_type", "memory_layout", "memory_free_slots", "memory_soldered"],
"model",
"Modelo",
"text",
"ThinkPad T14 Gen 5\nProDesk (variante por confirmar)\nUltraSharp (variante por confirmar)\nECOSYS (variante por confirmar)\nRouterBOARD (variante por confirmar)\nFortiGate (variante por confirmar)\nInstant On (variante por confirmar)\nUniFi (variante por confirmar)\nDiskStation (variante por confirmar)\nPowerEdge (variante por confirmar)\nGalaxy (variante por confirmar)\nSmart-UPS (variante por confirmar)",
],
["Almacenamiento", ["storage", "disk_model", "disk_interface", "disk_format", "disk_free_slots"]],
["Pantalla", ["screen_size", "resolution", "touchscreen", "panel_reference"]],
["Teclado", ["keyboard_layout", "numeric_keypad", "keyboard_backlight"]],
["serial", "Número de serie", "text", "PF12AB34"],
["product_code", "Código de producto / Service Tag", "text", "21ML000TSP"],
["processor", "Procesador", "text", "Intel Core Ultra 7 155U"],
["graphics", "Tarjeta gráfica", "text", "Intel Arc integrada\nNVIDIA RTX 4060 8 GB"],
["webcam", "Webcam integrada", "text", "1080p, obturador físico, IR"],
["memory", "Memoria RAM", "text", "32 GB DDR5-5600"],
[
"Batería",
[
"battery_reference",
"battery_design_capacity",
"battery_full_capacity",
"battery_cycles",
"battery_status",
"battery_checked_on",
],
"storage",
"Almacenamiento",
"text",
"1 TB M.2 2280 PCIe 4.0 NVMe SSD\n1 TB M.2 2280 PCIe 4.0 NVMe SSD, 4 bahías SATA de 3,5 / 2,5 pulgadas",
],
["Alimentación", ["charger_power", "charger_connector", "charger_reference"]],
["Compra y garantía", ["purchased_on", "warranty_until"]],
["Soporte y reparación", ["service_manual", "technical_notes"]],
["integrated_screen", "Pantalla integrada", "text", "14 pulgadas 1920x1200 IPS táctil"],
["microphone", "Micrófono integrado", "boolean", "Si/No"],
["keyboard_language", "Idioma del teclado", "text", "Español ISO"],
["numeric_keypad", "Teclado numérico", "text", "Sin teclado numérico"],
["keyboard_backlight", "Teclado retroiluminado", "boolean", "Si/No"],
[
"connectivity",
"Conectividad",
"text",
"Intel AX211, Wi-Fi 6E, Bluetooth 5.3, Ethernet 1Gbps\nEthernet, Wifi",
],
["battery_reference", "Referencia / modelo de batería", "text", "L22D4P71"],
["battery_capacity", "Capacidad de la batería", "text", "52,5 Wh, 4 celdas"],
["charger_power", "Potencia del cargador", "text", "65 W"],
["charger_connector", "Tipo de conector del cargador", "text", "USB-C PD"],
["charger_reference", "Referencia / modelo del cargador", "text", "ADLX65YLC3A"],
[
"factory_os",
"Sistema operativo precargado",
"text",
"Windows 11 Pro 64-bit\nWindows 11 Pro 64-bit precargado",
],
["purchased_on", "Fecha de compra", "date", "2026-03-12"],
["warranty_until", "Fin de garantía", "date", "2029-03-12"],
["supplier", "Proveedor", "text", "Amazon, PCComponentes, etc."],
[
"support_page",
"Enlace a la página de soporte",
"text",
"https://fabricante.example/manual-servicio",
],
["mpn", "Código de producto / MPN", "text", "21ML000TSP"],
["motherboard", "Placa base / chipset", "text", "Intel B760; referencia OEM"],
["factory_memory", "Memoria RAM de fábrica", "text", "32 GB DDR5-5600"],
["factory_storage", "Almacenamiento de fábrica", "text", "1 TB M.2 2280 PCIe 4.0 NVMe SSD"],
[
"connections",
"Conexiones",
"text",
"2 × HDMI 2.0; DisplayPort 1.4; USB-C PD 65 W\nEthernet, USB",
],
[
"power_supply",
"Fuente de alimentación",
"text",
"500 W, 80 Plus Bronze\nInterna; 100–240 V AC\nExterna 100 W",
],
["screen_size", "Tamaño de pantalla", "text", "14 pulgadas"],
["resolution", "Resolución", "text", "1920 × 1200"],
["panel_technology", "Tecnología del panel", "text", "IPS mate, 350 nits"],
["mounting_adjustment", "Anclaje y ajuste", "text", "VESA 100 × 100; altura y giro"],
["printing", "Tecnología de impresión", "text", "Láser"],
["color", "Impresión en color", "boolean", "SI/No"],
["duplex", "Doble cara", "text", "Dúplex automático"],
["paper_trays", "Formatos de papel y bandejas", "text", "A4; bandeja 250 hojas"],
["consumables", "Referencias de consumibles", "text", "Tóner TK-xxxx; tambor DK-xxxx"],
[
"maintenance_parts",
"Kit de mantenimiento / otras piezas",
"text",
"Referencia del fusor y kit de mantenimiento",
],
["physical_ports", "Puertos físicos", "multiline", "24 × RJ45 1 GbE; 4 × SFP+ 10 GbE"],
["wifi", "Versión Wi-Fi", "text", "Intel AX211, Wi-Fi 6E"],
["power_adapter", "Alimentación y adaptador", "text", "12 V DC, 2 A"],
["poe", "PoE", "text", "PoE+ 802.3at, presupuesto 370 W"],
[
"supported_storage",
"Almacenamiento admitido",
"multiline",
"4 bahías SATA de 3,5 / 2,5 pulgadas",
],
["equipped_storage", "Almacenamiento equipado", "multiline", "4 discos 3,5 de 20 TB WD"],
[
"service_manual",
"Enlace al manual de servicio",
"text",
"https://fabricante.example/manual-servicio",
],
[
"cpu_configuration",
"Formato y configuración de CPU",
"text",
"Rack 2U; 2 sockets; 1 CPU de fábrica",
],
["redundant_power", "Fuentes y redundancia", "text", "2 × 750 W, redundantes"],
[
"factory_preloaded_os",
"Sistema operativo de fábrica / precargado",
"text",
"Sin SO precargado",
],
["imei", "IMEI", "text", "356123456789012"],
["ups_battery", "Referencia de batería", "text", "RBC7; 2 × 12 V / 18 Ah"],
["power_connections", "Conexiones eléctricas", "text", "Line interactive; 6 salidas IEC C13"],
["form_factor", "Formato y montaje", "text", "Torre / rack 2U"],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
const TEMPLATES: readonly [
string,
string,
AssetTemplateIconKey,
readonly (readonly [string, readonly string[]])[],
][] = [
[
"laptop",
"Portátil",
LAPTOP_SECTION_FIELDS.flatMap(([, keys]) => keys).filter((key) => !COMMON.includes(key)),
"laptop",
[
["Identificación", ["brand", "model", "serial", "product_code"]],
[
"Hardware",
[
"processor",
"graphics",
"webcam",
"memory",
"storage",
"integrated_screen",
"microphone",
"keyboard_language",
"numeric_keypad",
"keyboard_backlight",
],
],
["Conectividad", ["connectivity"]],
["Batería", ["battery_reference", "battery_capacity"]],
["Alimentación", ["charger_power", "charger_connector", "charger_reference"]],
["Software", ["factory_os"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"desktop",
"PC de sobremesa",
["processor", "memory", "storage", "os", "hostname", "connections", "ip", "mac"],
"desktop",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Hardware", ["processor", "graphics", "motherboard", "factory_memory", "factory_storage"]],
["Conectividad", ["connectivity", "connections"]],
["Alimentación", ["power_supply"]],
["Software", ["factory_os"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"monitor",
"Monitor",
"monitor",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Pantalla", ["screen_size", "resolution", "panel_technology"]],
["Conectividad", ["connections"]],
["Montaje", ["mounting_adjustment"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"printer",
"Impresora",
"printer",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["connectivity"]],
["Impresión", ["printing", "color", "duplex", "paper_trays"]],
["Consumibles y mantenimiento", ["consumables", "maintenance_parts"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"router",
"Router",
"router",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["physical_ports", "wifi"]],
["Alimentación", ["power_adapter"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"firewall",
"Firewall",
"shield",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["physical_ports"]],
["Compra y garantía", ["supplier", "purchased_on", "warranty_until"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"switch",
"Switch",
"network",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["physical_ports", "poe"]],
["Alimentación", ["power_supply"]],
["Compra y garantía", ["supplier", "purchased_on", "warranty_until"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"access_point",
"Punto de acceso Wi-Fi",
"wifi",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["wifi", "poe"]],
["Compra y garantía", ["supplier", "purchased_on", "warranty_until"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"nas",
"NAS",
"hard-drive",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Almacenamiento", ["supported_storage", "equipped_storage"]],
["Alimentación", ["power_supply"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["service_manual"]],
],
],
["monitor", "Monitor", ["screen_size", "resolution", "connections"]],
["printer", "Impresora", ["printing", "color", "duplex", "connections", "ip", "mac"]],
["router", "Router", ["ports", "speed", "wifi", "ip", "mac"]],
["firewall", "Firewall", ["ports", "speed", "ip", "mac"]],
["switch", "Switch", ["ports", "speed", "poe", "ip", "mac"]],
["access_point", "Punto de acceso Wi-Fi", ["wifi", "poe", "ip", "mac"]],
["nas", "NAS", ["processor", "memory", "disks", "capacity", "raid", "os", "ip", "mac"]],
[
"server",
"Servidor",
["processor", "memory", "storage", "disks", "raid", "os", "hostname", "ip", "mac"],
"server",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Hardware", ["processor", "cpu_configuration", "memory", "storage"]],
["Alimentación", ["redundant_power", "power_supply"]],
["Configuración de fábrica", ["factory_preloaded_os"]],
["Conectividad", ["connectivity", "connections"]],
["Software", ["factory_os"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["support_page"]],
],
],
[
"smartphone",
"Smartphone",
"smartphone",
[
["Identificación", ["brand", "model", "serial", "imei", "mpn"]],
["Compra y garantía", ["purchased_on", "warranty_until", "supplier"]],
["Soporte y reparación", ["service_manual"]],
],
],
[
"ups",
"SAI",
"battery",
[
["Identificación", ["brand", "model", "serial", "mpn"]],
["Conectividad", ["connections"]],
["Batería", ["ups_battery"]],
["Compra y garantía", ["supplier", "purchased_on", "warranty_until"]],
["Alimentación", ["power_connections"]],
["Montaje", ["form_factor"]],
["Soporte y reparación", ["service_manual"]],
],
],
["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]],
["ups", "SAI", ["power", "battery", "connections"]],
];
const TEMPLATE_ICONS: Readonly<Record<string, AssetTemplateIconKey>> = {
laptop: "laptop",
desktop: "desktop",
monitor: "monitor",
printer: "printer",
router: "router",
firewall: "shield",
switch: "network",
access_point: "wifi",
nas: "hard-drive",
server: "server",
smartphone: "smartphone",
ups: "battery",
};
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) {
@ -169,49 +321,37 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
current = result.data;
};
for (const [key, name, type, unit, choices = []] of FIELDS) {
add(`field:${key}`, {
kind: "field",
name,
description: "",
type,
unit,
choices: [...choices],
});
for (const [key, name, type, description] of FIELDS) {
add(`field:${key}`, { kind: "field", name, type, description, unit: "", choices: [] });
}
for (const [key, name, fields] of TEMPLATES) {
const sectionFields = [...COMMON, ...fields];
const sectionLayout = key === "laptop" ? LAPTOP_SECTION_FIELDS : SECTION_FIELDS;
const sections = sectionLayout
.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}`)
?.id.toPrimitive();
for (const [key, name, icon, layout] of TEMPLATES) {
const sections = layout.map(([sectionName]) => ({
id: UniqueID.generateNewID().toPrimitive(),
name: sectionName,
description: "",
}));
const references = layout.flatMap(([, keys], index) =>
keys.map((field) => {
const definition = current.definitions.find(
(item) => item.stockKey.getOrUndefined() === `field:${field}`
);
const section = sections[index];
if (!id) {
throw new Error("Campo inicial de activos ausente.");
}
const sectionId = sections.find((section) => section.keys.includes(field))?.id;
if (!(definition && section)) {
throw new Error("Campo inicial de activos ausente.");
}
return { fieldId: id, required: false, ...(sectionId ? { sectionId } : {}) };
});
return { fieldId: definition.id.toPrimitive(), required: false, sectionId: section.id };
})
);
add(`template:${key}`, {
kind: "template",
icon: TEMPLATE_ICONS[key],
icon,
name,
description: "",
fields: references,
...(sections.length
? { sections: sections.map(({ id, name, description }) => ({ id, name, description })) }
: {}),
sections,
});
}
for (const [key, name, inverseName, symmetric] of [

View File

@ -229,6 +229,39 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
)
);
}
remove(scope: Maybe<UniqueID>, id: UniqueID): Result<AssetCatalog, AssetCatalogError> {
const item = this.definitions.find((entry) => entry.id.equals(id));
if (!item) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
}
if (
scope.getOrUndefined() ||
item.companyId.getOrUndefined() ||
item.stockKey.getOrUndefined()
) {
return Result.fail(new AssetCatalogError("DEFINITION_PROTECTED"));
}
if (
this.definitions.some(
(entry) =>
!entry.id.equals(id) && entry.values.fieldIds.some((fieldId) => fieldId.equals(id))
)
) {
return Result.fail(new AssetCatalogError("DEFINITION_IN_USE"));
}
return Result.ok(
new AssetCatalog(
{
definitions: this.definitions.filter((entry) => !entry.id.equals(id)),
availability: this.availability.filter((entry) => !entry.definitionId.equals(id)),
},
this.id
)
);
}
}
function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) {

View File

@ -8,7 +8,7 @@ import { AssetCatalog } from "./asset-catalog.aggregate";
import { seedAssetCatalog } from "./asset-catalog-seed";
import type { AssetDefinitionSpec } from "./asset-definition";
import { AssetDefinitionSchema } from "./asset-definition";
import { AssetDefinition, TemplateField } from "./asset-definition.entity";
import { AssetDefinition } from "./asset-definition.entity";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
const global = Maybe.none<UniqueID>();
@ -34,7 +34,7 @@ function changed(result: ReturnType<AssetCatalog["save"]>) {
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
assert.equal(catalog.definitions.length, 75);
assert.equal(catalog.definitions.length, 72);
assert.equal(seedAssetCatalog(catalog), catalog);
const ids = [
catalog.id,
@ -77,7 +77,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva
assert.equal(catalog.active(stock, first), false);
assert.equal(catalog.active(stock, second), true);
assert.equal(stock.active, true);
assert.equal(catalog.definitions.length, 75);
assert.equal(catalog.definitions.length, 72);
const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId);
@ -166,6 +166,41 @@ test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", (
);
});
test("solo elimina definiciones globales personalizadas que no usan otras plantillas", () => {
let catalog = AssetCatalog.create();
catalog = changed(catalog.save(global, Maybe.none(), field("Campo temporal")));
const removable = catalog.definitions[0];
assert.ok(removable);
catalog = changed(catalog.remove(global, removable.id));
assert.equal(catalog.definitions.length, 0);
assert.equal(catalog.remove(global, removable.id).isSuccess, false);
catalog = changed(catalog.save(global, Maybe.none(), field("Campo compartido")));
const shared = catalog.definitions[0];
assert.ok(shared);
catalog = changed(
catalog.save(
global,
Maybe.none(),
values({
kind: "template",
name: "Plantilla compartida",
description: "",
fields: [{ fieldId: shared.id.toPrimitive(), required: false }],
})
)
);
const blocked = catalog.remove(global, shared.id);
assert.equal(blocked.isSuccess, false);
if (!blocked.isSuccess) {
assert.equal(blocked.error.code, "DEFINITION_IN_USE");
}
});
test("iconos opcionales: cambio y retirada conservan identidad y asociaciones", () => {
let catalog = seedAssetCatalog(AssetCatalog.create());
const fieldId = catalog.definitions[0]?.id.toPrimitive();
@ -274,65 +309,40 @@ test("los iconos desconocidos o asociados a campos se rechazan", () => {
);
});
test("revisión explícita de Portátil conserva identidades, copias y otras plantillas", () => {
test("revisión explícita conserva identidades, copias y otras plantillas", () => {
const initial = seedAssetCatalog(AssetCatalog.create());
const laptop = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:laptop"
);
assert.ok(laptop?.values.data.kind === "template");
const oldLayout: [string, string[]][] = [
["Identificación", ["brand", "model", "serial"]],
["Hardware", ["processor", "memory", "storage"]],
["Sistema", ["os", "hostname"]],
["Pantalla y conexiones", ["screen_size", "resolution", "connections"]],
["Red", ["ip", "mac"]],
["Compra y garantía", ["purchased_on", "warranty_until"]],
];
const sections = oldLayout.map(([name]) => ({
id: UniqueID.generateNewID().toPrimitive(),
name,
description: "",
}));
const fields = oldLayout.flatMap(([, keys], index) =>
keys.map((key) => {
const field = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === `field:${key}`
);
assert.ok(field);
return { fieldId: field.id.toPrimitive(), required: false, sectionId: sections[index]?.id };
})
);
const oldValues = values({ ...laptop.values.data, fields, sections });
const bindings = fields.map((field) => {
const definition = initial.definitions.find((item) => item.id.toPrimitive() === field.fieldId);
assert.ok(definition);
return TemplateField.create(definition.id, false);
const oldValues = values({
...laptop.values.data,
fields: laptop.values.data.fields
.slice(0, 1)
.map((field) => ({ fieldId: field.fieldId, required: field.required })),
sections: [],
});
const oldLaptop = AssetDefinition.rehydrate(
const oldTemplate = AssetDefinition.rehydrate(
{
companyId: laptop.companyId,
stockKey: laptop.stockKey,
values: oldValues,
active: false,
bindings,
bindings: laptop.bindings.slice(0, 1),
},
laptop.id
);
const copy = AssetDefinition.create(
global,
values({ ...oldLaptop.values.data, name: "Copia personalizada" }),
values({ ...oldValues.data, name: "Copia personalizada" }),
true
);
const before = AssetCatalog.rehydrate(
{
definitions: [
...initial.definitions.filter((item) => !item.id.equals(laptop.id)),
oldLaptop,
oldTemplate,
copy,
],
availability: initial.availability,
@ -342,103 +352,29 @@ test("revisión explícita de Portátil conserva identidades, copias y otras pla
const revision = before.seed("template:laptop", laptop.values, { refreshTemplate: true });
assert.ok(revision.isSuccess);
const after = revision.data;
const updated = after.definitions.find((item) => item.id.equals(laptop.id));
const updated = revision.data.definitions.find((item) => item.id.equals(laptop.id));
assert.ok(updated?.values.data.kind === "template");
assert.equal(updated.active, false);
assert.equal(updated.values.data.fields.length, 25);
assert.deepEqual(
updated.values.data.sections?.map((section) => section.name),
[
"Identificación",
"Hardware",
"Memoria RAM",
"Almacenamiento",
"Pantalla",
"Teclado",
"Conectividad",
"Batería",
"Alimentación",
"Software",
"Compra y garantía",
"Soporte y reparación",
]
);
const names = updated.values.data.fields.map(
(field) =>
after.definitions.find((item) => item.id.toPrimitive() === field.fieldId)?.values.data.name
);
for (const name of [
"Tarjeta gráfica",
"Webcam",
"Estándar Wi-Fi",
"Versión de Bluetooth",
"Teclado numérico",
]) {
assert.ok(names.includes(name));
}
for (const name of [
"Sistema operativo",
"Nombre de equipo",
"Dirección IP",
"Dirección MAC",
"Conexiones",
]) {
assert.equal(names.includes(name), false);
}
for (const binding of updated.bindings) {
const previous = oldLaptop.bindings.find((item) => item.fieldId.equals(binding.fieldId));
if (previous) {
assert.ok(previous.id.equals(binding.id));
}
}
assert.equal(
updated.values.data.sections?.find((section) => section.name === "Hardware")?.id,
sections.find((section) => section.name === "Hardware")?.id
);
assert.equal(
updated.values.data.sections?.find((section) => section.name === "Pantalla")?.id,
sections.find((section) => section.name === "Pantalla y conexiones")?.id
);
assert.equal(
after.definitions.find((item) => item.id.equals(copy.id)),
revision.data.definitions.find((item) => item.id.equals(copy.id)),
copy
);
const desktop = before.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:desktop"
);
assert.equal(
after.definitions.find((item) => item.stockKey.getOrUndefined() === "template:desktop"),
desktop
);
assert.equal(oldLaptop.values, oldValues);
assert.equal(updated.values.data.fields.length, 39);
const batteryState = after.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:battery_status"
);
assert.ok(batteryState?.values.data.kind === "field");
assert.equal(batteryState.values.data.type, "choice");
assert.deepEqual(batteryState.values.data.choices, [
"Normal",
"Desgastada",
"Averiada",
"Hinchada",
]);
for (const key of ["memory", "storage", "numeric_keypad"]) {
const field = after.definitions.find(
(item) => item.stockKey.getOrUndefined() === `field:${key}`
);
assert.ok(field);
assert.equal(
after.definitions.filter((item) => item.stockKey.getOrUndefined() === `field:${key}`).length,
1
);
}
assert.ok(updated.values.data.fields.every((field) => !field.required));
assert.equal(seedAssetCatalog(after), after);
assert.equal(seedAssetCatalog(revision.data), revision.data);
});
test("plantillas globales de serie editables: conserva identidad, asociaciones, disponibilidad y cambios al reiniciar", () => {
@ -499,7 +435,21 @@ test("plantillas globales de serie editables: conserva identidad, asociaciones,
});
test("campos globales de serie editables: listas ampliables, tipo fijo y opciones existentes conservadas", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
const seeded = seedAssetCatalog(AssetCatalog.create());
const addedField = seeded.seed(
"field:test_choice",
values({
kind: "field",
name: "Lista de prueba",
type: "choice",
description: "",
unit: "",
choices: ["Inicial"],
})
);
assert.ok(addedField.isSuccess);
const catalog = addedField.data;
const field = catalog.definitions.find(
(item) => item.values.data.kind === "field" && item.values.data.type === "choice"
);

View File

@ -6,7 +6,7 @@ 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 FieldTypeSchema = z.enum(["text", "multiline", "number", "date", "boolean", "choice"]);
export const TemplateFieldSchema = z.strictObject({
fieldId: z.uuid(),

View File

@ -117,3 +117,28 @@ test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes",
assert.ok(AssetValues.create({ ...next.data.toPrimitive(), status: "retired" }, spec).isSuccess);
assert.ok(Object.isFrozen(next.data.toPrimitive().characteristics));
});
test("texto largo conserva saltos de línea y rechaza tipos no textuales", () => {
const fieldId = UniqueID.generateNewID().toPrimitive();
const created = AssetSpecification.create({
templateId,
templateName: "Switch",
fields: [
{
fieldId,
name: "Puertos físicos",
type: "multiline",
unit: "",
choices: [],
required: false,
},
],
});
assert.ok(created.isSuccess);
const result = AssetValues.create(input({ [fieldId]: "24 RJ45\n4 SFP+" }), created.data);
assert.ok(result.isSuccess);
assert.equal(result.data.toPrimitive().characteristics[fieldId], "24 RJ45\n4 SFP+");
assert.equal(AssetValues.create(input({ [fieldId]: 24 }), created.data).isSuccess, false);
});

View File

@ -102,6 +102,7 @@ export class AssetSpecification {
function validCharacteristic(field: AssetCharacteristic, value: unknown) {
switch (field.type) {
case "text":
case "multiline":
return typeof value === "string";
case "number":
return typeof value === "number" && Number.isFinite(value);

View File

@ -122,5 +122,18 @@ export function registerAssetCatalogRoutes(
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
router.post("/:scope/:id/delete", (request, response, next) => {
const params = CatalogItemParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.body).success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.scope, params.data.id)
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
app.use("/api/asset-catalog", authenticate, router);
}

View File

@ -84,6 +84,7 @@ export function decodeCharacteristic(
}
switch (row.valueType) {
case "text":
case "multiline":
case "choice":
if (row.textValue !== null) {
return row.textValue;
@ -126,7 +127,8 @@ function characteristicRows(
...(revisionId ? { revisionId } : {}),
fieldId,
valueType: type,
textValue: type === "text" || type === "choice" ? String(value) : null,
textValue:
type === "text" || type === "multiline" || type === "choice" ? String(value) : null,
numberValue: type === "number" && typeof value === "number" ? value : null,
booleanValue: type === "boolean" && typeof value === "boolean" ? value : null,
dateValue: type === "date" ? String(value) : null,

View File

@ -3,6 +3,9 @@ import { QueryTypes } from "sequelize";
import { sqlIdentifier } from "./asset-relational-sql";
const ASSET_DEFINITION_CHECK =
"CHECK ((kind='field' AND fieldType IN ('text','multiline','number','date','boolean','choice') AND unit IS NOT NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='template' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='relation' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NOT NULL AND symmetric IS NOT NULL))";
/** Restricciones de integridad del modelo relacional de activos. */
export async function enforceAssetRelationalConstraints(database: Sequelize) {
const constraints = await database.query<{ tableName: string; name: string }>(
@ -11,18 +14,18 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
);
const add = async (table: string, name: string, definition: string) => {
if (!constraints.some((item) => item.tableName === table && item.name === name)) {
const existing = constraints.some((item) => item.tableName === table && item.name === name);
if (existing && definition.startsWith("CHECK") && definition.includes("multiline")) {
await upgradeAssetTextConstraint(database, table, name, definition);
} else if (!existing) {
await database.query(
`ALTER TABLE ${sqlIdentifier(table)} ADD CONSTRAINT ${sqlIdentifier(name)} ${definition}`
);
}
};
await add(
"asset_definitions",
"asset_definition_kind",
"CHECK ((kind='field' AND fieldType IN ('text','number','date','boolean','choice') AND unit IS NOT NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='template' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NULL AND symmetric IS NULL) OR (kind='relation' AND fieldType IS NULL AND unit IS NULL AND inverseName IS NOT NULL AND symmetric IS NOT NULL))"
);
await add("asset_definitions", "asset_definition_kind", ASSET_DEFINITION_CHECK);
for (const table of ["assets", "asset_revisions"]) {
await add(table, `${table}_brand_presence`, "CHECK (brandId IS NULL OR brandDefined=1)");
await add(
@ -63,7 +66,34 @@ export async function enforceAssetRelationalConstraints(database: Sequelize) {
await add(
table,
`${table}_typed_value`,
"CHECK ((valueType IN ('text','choice') AND textValue IS NOT NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='number' AND textValue IS NULL AND numberValue IS NOT NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='boolean' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IN (0,1) AND booleanValue IS NOT NULL AND dateValue IS NULL) OR (valueType='date' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NOT NULL AND dateValue REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'))"
"CHECK ((valueType IN ('text','multiline','choice') AND textValue IS NOT NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='number' AND textValue IS NULL AND numberValue IS NOT NULL AND booleanValue IS NULL AND dateValue IS NULL) OR (valueType='boolean' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IN (0,1) AND booleanValue IS NOT NULL AND dateValue IS NULL) OR (valueType='date' AND textValue IS NULL AND numberValue IS NULL AND booleanValue IS NULL AND dateValue IS NOT NULL AND dateValue REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'))"
);
}
}
async function upgradeAssetTextConstraint(
database: Sequelize,
table: string,
name: string,
definition: string
) {
const checks = await database.query<{ clause: string }>(
"SELECT CHECK_CLAUSE AS clause FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME=:table AND CONSTRAINT_NAME=:name",
{ replacements: { table, name }, type: QueryTypes.SELECT }
);
if (checks.length && !checks[0]?.clause.includes("multiline")) {
await database.query(
`ALTER TABLE ${sqlIdentifier(table)} DROP CONSTRAINT ${sqlIdentifier(name)}, ADD CONSTRAINT ${sqlIdentifier(name)} ${definition}`
);
}
}
export async function migrateAssetCatalogText(database: Sequelize) {
await upgradeAssetTextConstraint(
database,
"asset_definitions",
"asset_definition_kind",
ASSET_DEFINITION_CHECK
);
}

View File

@ -60,6 +60,46 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
return this.database.transaction((transaction) => this.load(scope, transaction, false));
}
private async definitionsInUse(ids: readonly string[], transaction: Transaction) {
for (const id of ids) {
const references = await this.database.query<{ id: string }>(
"SELECT id FROM assets WHERE templateId=:id UNION ALL SELECT id FROM asset_revisions WHERE templateId=:id UNION ALL SELECT id FROM asset_relationships WHERE typeId=:id UNION ALL SELECT assetId AS id FROM asset_specification_fields WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_characteristics WHERE fieldId=:id UNION ALL SELECT assetId AS id FROM asset_revision_characteristics WHERE fieldId=:id LIMIT 1",
{ replacements: { id }, transaction, type: QueryTypes.SELECT }
);
if (references.length) {
return true;
}
}
return false;
}
private async removeDefinitions(ids: readonly string[], transaction: Transaction) {
for (const id of ids) {
await this.database.query("DELETE FROM asset_availability WHERE definitionId=:id", {
replacements: { id },
transaction,
});
await this.database.query("DELETE FROM asset_field_choices WHERE fieldId=:id", {
replacements: { id },
transaction,
});
await this.database.query("DELETE FROM asset_template_fields WHERE templateId=:id", {
replacements: { id },
transaction,
});
await this.database.query("DELETE FROM asset_template_sections WHERE templateId=:id", {
replacements: { id },
transaction,
});
await this.database.query("DELETE FROM asset_definitions WHERE id=:id", {
replacements: { id },
transaction,
});
}
}
update(scope: Maybe<UniqueID>, action: Parameters<IAssetCatalogRepository["update"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(scope, transaction, true);
@ -73,6 +113,15 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
return changed;
}
const rows = this.mapper.toPersistence(changed.data);
const removedDefinitions = loaded.data.definitions.filter(
(item) => !changed.data.definitions.some((candidate) => candidate.id.equals(item.id))
);
const removedIds = removedDefinitions.map((item) => item.id.toPrimitive());
if (await this.definitionsInUse(removedIds, transaction)) {
return Result.fail(new AssetCatalogError("DEFINITION_IN_USE"));
}
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
for (const row of rows.definitions) {
@ -102,6 +151,7 @@ export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository
for (const row of rows.availability) {
await AvailabilityModel.upsert(row, { transaction });
}
await this.removeDefinitions(removedIds, transaction);
return changed;
});

View File

@ -24,7 +24,10 @@ import {
} from "./infrastructure/persistence/asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
import { AssetDashboardReader } from "./infrastructure/persistence/asset-dashboard-reader";
import { enforceAssetRelationalConstraints } from "./infrastructure/persistence/asset-relational-constraints";
import {
enforceAssetRelationalConstraints,
migrateAssetCatalogText,
} from "./infrastructure/persistence/asset-relational-constraints";
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
@ -37,6 +40,7 @@ import { migrateWorkstations } from "./infrastructure/persistence/workstation.mo
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
await migrateAssetCatalog(database);
await migrateAssetCatalogText(database);
await CatalogModel.findOrCreate({
where: { catalogKey: "global" },
defaults: { id: UniqueID.generateNewID().toPrimitive(), catalogKey: "global" },

View File

@ -230,6 +230,11 @@
"retry": "Retry",
"save": "Save",
"saving": "Saving…",
"delete": "Delete",
"deleting": "Deleting…",
"deleteDefinitionConfirm": "Delete “{{name}}”? This action cannot be undone.",
"deleteDefinitionGuidance": "Only unused custom global definitions can be deleted. Built-in definitions and those retained by assets, history, relationships or templates cannot be deleted.",
"deleteError": "The definition could not be deleted.",
"cancel": "Cancel",
"selectField": "Add a field to the template",
"choose": "Select a field",
@ -268,6 +273,8 @@
"FIELD_TYPE_FIXED": "The data type cannot change and existing list values cannot be removed.",
"BUILTIN_PROTECTED": "Built-in relationship types cannot be edited.",
"DEFINITION_NOT_FOUND": "This definition is unavailable in this scope.",
"DEFINITION_PROTECTED": "Only custom global definitions can be deleted.",
"DEFINITION_IN_USE": "This definition is used by assets, history, relationships or templates and cannot be deleted.",
"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.",
@ -328,5 +335,6 @@
"camera": "Camera",
"box": "Other asset"
}
}
},
"multiline": "Long text"
}

View File

@ -230,6 +230,11 @@
"retry": "Reintentar",
"save": "Guardar",
"saving": "Guardando…",
"delete": "Eliminar",
"deleting": "Eliminando…",
"deleteDefinitionConfirm": "¿Quieres eliminar «{{name}}»? Esta acción no se puede deshacer.",
"deleteDefinitionGuidance": "Solo se pueden eliminar definiciones globales personalizadas que no estén en uso. Las de serie y las que se conservan en activos, historial, relaciones o plantillas no se eliminan.",
"deleteError": "No se ha podido eliminar la definición.",
"cancel": "Cancelar",
"selectField": "Añadir un campo a la plantilla",
"choose": "Selecciona un campo",
@ -268,6 +273,8 @@
"FIELD_TYPE_FIXED": "El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.",
"BUILTIN_PROTECTED": "Los tipos de relación de serie no se pueden modificar.",
"DEFINITION_NOT_FOUND": "La definición no está disponible en este ámbito.",
"DEFINITION_PROTECTED": "Solo se pueden eliminar definiciones globales personalizadas.",
"DEFINITION_IN_USE": "La definición está en uso en activos, historial, relaciones o plantillas y no se puede eliminar.",
"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.",
@ -328,5 +335,6 @@
"camera": "Cámara",
"box": "Otro activo"
}
}
},
"multiline": "Texto largo"
}

View File

@ -25,6 +25,7 @@ import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list";
import { AssetForm } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
const companyId = UniqueID.generateNewID().toPrimitive();
const lookups = {
@ -293,3 +294,29 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
assert.ok(html.includes("ABC123"));
});
test("texto largo: área editable con ayuda accesible", async () => {
const markup = await render(
"es",
<AssetCharacteristicControl
disabled={false}
field={{
fieldId: UniqueID.generateNewID().toPrimitive(),
name: "Puertos físicos",
type: "multiline",
unit: "",
choices: [],
required: false,
}}
helpText="Puertos y velocidades admitidas"
onChange={() => undefined}
prefix="ports"
value={"24 RJ45\n4 SFP+"}
/>
);
assert.match(markup, /<textarea/);
assert.match(markup, /aria-describedby=/);
assert.match(markup, /24 RJ45\n4 SFP\+/);
assert.match(markup, /Puertos y velocidades admitidas/);
});

View File

@ -2,6 +2,7 @@ import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
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 { useTranslation } from "react-i18next";
import type { AssetCharacteristic, AssetInput } from "../../../../common/inventory";
@ -26,6 +27,7 @@ export function AssetCharacteristicControl({
const { t } = useTranslation("assets");
const id = `${prefix}-${field.fieldId}`;
const describedBy = fieldHelpId(id, helpText);
const displayValue = value === undefined ? "" : String(value);
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
return (
@ -37,11 +39,7 @@ export function AssetCharacteristicControl({
disabled={disabled}
id={id}
label={label}
onValueChange={(choice) =>
onChange(
choice === "" ? undefined : field.type === "boolean" ? choice === "true" : choice
)
}
onValueChange={(choice) => onChange(choiceValue(field.type, choice))}
options={
field.type === "boolean"
? [
@ -52,7 +50,18 @@ export function AssetCharacteristicControl({
}
placeholder={t("noValue")}
required={field.required}
value={value === undefined ? "" : String(value)}
value={displayValue}
/>
) : field.type === "multiline" ? (
<Textarea
aria-describedby={describedBy}
disabled={disabled}
id={id}
maxLength={10000}
onChange={(event) => onChange(event.currentTarget.value || undefined)}
required={field.required}
rows={3}
value={displayValue}
/>
) : (
<Input
@ -77,7 +86,7 @@ export function AssetCharacteristicControl({
required={field.required}
step={field.type === "number" ? "any" : undefined}
type={inputType(field.type)}
value={value === undefined ? "" : String(value)}
value={displayValue}
/>
)}
<FieldHelp id={describedBy} text={helpText} />
@ -95,3 +104,11 @@ function inputType(type: AssetCharacteristic["type"]) {
return "text";
}
function choiceValue(type: AssetCharacteristic["type"], choice: string) {
if (choice === "") {
return undefined;
}
return type === "boolean" ? choice === "true" : choice;
}

View File

@ -0,0 +1 @@
export { DeleteCatalogDefinitionDialog } from "./ui/delete-catalog-definition-dialog";

View File

@ -0,0 +1,50 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { Button } from "@repo/shadcn-ui/components/button";
import { DialogFooter } from "@repo/shadcn-ui/components/dialog";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { CatalogApiError } from "../../shared/api";
export function DeleteCatalogDefinitionDialog({
item,
pending,
error,
onClose,
onDelete,
}: {
item: CatalogItemDTO;
pending: boolean;
error: Error | null;
onClose: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation("assets");
const code = error instanceof CatalogApiError ? error.code : "INTERNAL_ERROR";
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!(open || pending)) {
onClose();
}
}}
open
title={t("delete")}
>
<p>{t("deleteDefinitionConfirm", { name: item.values.name })}</p>
<p className="text-sm text-muted-foreground">{t("deleteDefinitionGuidance")}</p>
{error && <p role="alert">{t(`errors.${code}`, { defaultValue: t("deleteError") })}</p>}
<DialogFooter className="border-t pt-4">
<Button disabled={pending} onClick={onClose} type="button" variant="outline">
{t("cancel")}
</Button>
<Button disabled={pending} onClick={onDelete} type="button" variant="destructive">
{t(pending ? "deleting" : "delete")}
</Button>
</DialogFooter>
</FormDialog>
);
}

View File

@ -36,6 +36,7 @@ export function useAssetCatalogPageController({ scope }: AssetCatalogPageProps)
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>();
const [deletingId, setDeletingId] = useState<string>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
@ -49,7 +50,16 @@ export function useAssetCatalogPageController({ scope }: AssetCatalogPageProps)
},
});
const items = query.data?.items ?? [];
const removeMutation = useMutation({
mutationFn: (id: string) => catalogApi.remove(scope, id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
setDeletingId(undefined);
},
});
const view = items.find((item) => item.id === viewId);
const deleting = items.find((item) => item.id === deletingId);
const visible = items.filter(
(item) =>
item.values.kind === tab &&
@ -64,8 +74,11 @@ export function useAssetCatalogPageController({ scope }: AssetCatalogPageProps)
setViewId,
editor,
setEditor,
deleting,
setDeletingId,
query,
mutation,
removeMutation,
items,
view,
visible,

View File

@ -17,6 +17,7 @@ export function CatalogCell({
onCopy,
onView,
onStatus,
onDelete,
}: {
column: "name" | "origin" | "details" | "state" | "actions";
item: CatalogItemDTO;
@ -27,6 +28,7 @@ export function CatalogCell({
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
onDelete: () => void;
}) {
const { t } = useTranslation("assets");
const editable =
@ -63,9 +65,11 @@ export function CatalogCell({
<>
<EntityRowActions label={t("actions")}>
<CatalogRowActions
deletable={scope === "global" && !item.builtin && item.companyId === null}
editable={editable}
item={item}
onCopy={onCopy}
onDelete={onDelete}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}

View File

@ -2,6 +2,7 @@ import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-li
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import { DeleteCatalogDefinitionDialog } from "../../delete";
import { CatalogDefinitionDialog } from "../../detail";
import { CatalogEditorDialog, CatalogError } from "../../shared";
import { useAssetCatalogPageController } from "../controllers";
@ -21,9 +22,12 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
setEditor,
query,
mutation,
removeMutation,
items,
view,
visible,
deleting,
setDeletingId,
} = useAssetCatalogPageController(props);
const { t, i18n } = useTranslation("assets");
@ -82,6 +86,7 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onDelete={() => setDeletingId(item.id)}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setViewId(item.id)}
@ -100,6 +105,7 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onDelete={() => setDeletingId(item.id)}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setViewId(item.id)}
@ -117,6 +123,7 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onDelete={() => setDeletingId(item.id)}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setViewId(item.id)}
@ -135,6 +142,7 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onDelete={() => setDeletingId(item.id)}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setViewId(item.id)}
@ -153,6 +161,7 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
item={item}
items={items}
onCopy={() => setEditor({ kind: "template", copy: item })}
onDelete={() => setDeletingId(item.id)}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setViewId(item.id)}
@ -183,6 +192,15 @@ export function AssetCatalogPage(props: AssetCatalogPageProps) {
scope={scope}
/>
)}
{deleting && (
<DeleteCatalogDefinitionDialog
error={removeMutation.error}
item={deleting}
onClose={() => setDeletingId(undefined)}
onDelete={() => removeMutation.mutate(deleting.id)}
pending={removeMutation.isPending}
/>
)}
</div>
);
}

View File

@ -6,19 +6,23 @@ import type { CatalogItemDTO } from "../../../../common";
export function CatalogRowActions({
item,
editable,
deletable,
pending,
onEdit,
onCopy,
onView,
onStatus,
onDelete,
}: {
item: CatalogItemDTO;
editable: boolean;
deletable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
onDelete: () => void;
}) {
const { t } = useTranslation("assets");
@ -35,6 +39,11 @@ export function CatalogRowActions({
{t("reset")}
</EntityAction>
)}
{deletable && (
<EntityAction disabled={pending} onClick={onDelete}>
{t("delete")}
</EntityAction>
)}
</div>
);
}

View File

@ -42,4 +42,7 @@ export const catalogApi = {
async status(scope: string, id: string, active: boolean | null) {
return CatalogResponseSchema.parse(await request(`${scope}/${id}/status`, { active }));
},
async remove(scope: string, id: string) {
return CatalogResponseSchema.parse(await request(`${scope}/${id}/delete`, {}));
},
};

View File

@ -92,17 +92,13 @@ export class CompanyInputMapper {
site(dto: SiteRequestDTO) {
const name = Name.create(dto.name, 100);
const address = optional(dto.address ?? null, (value) => SiteAddress.create(value));
const phone = optional(dto.phone ?? null, (value) => PhoneNumber.create(value));
if (!name.isSuccess) {
if (!(name.isSuccess && address.isSuccess && phone.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
if (dto.address === null || dto.address === undefined || !dto.address.trim()) {
return Result.ok({ name: name.data, address: Maybe.none<SiteAddress>() });
}
const address = SiteAddress.create(dto.address);
return address.isSuccess
? Result.ok({ name: name.data, address: Maybe.some(address.data) })
: Result.fail(new CompanyError("INVALID_REQUEST"));
return Result.ok({ name: name.data, address: address.data, phone: phone.data });
}
}

View File

@ -60,6 +60,7 @@ export function buildCompanyResponse(company: Company): CompanyDTO {
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
phone: site.phone.getOrUndefined()?.toPrimitive() ?? null,
})),
};
}

View File

@ -88,16 +88,21 @@ function inputContact() {
return props.data;
}
test("la dirección es optativa, admite varias líneas y se puede borrar", () => {
test("la dirección y el teléfono son optativos y se pueden borrar", () => {
const company = companyFixture();
const empty = input.site({ name: "Central", address: null });
const full = input.site({ name: "Central", address: "Calle Mayor, 10\n28001 Madrid" });
const empty = input.site({ name: "Central", address: null, phone: null });
const full = input.site({
name: "Central",
address: "Calle Mayor, 10\n28001 Madrid",
phone: "+34 900 123 456",
});
assert.ok(empty.isSuccess && full.isSuccess);
const site = Site.create({ ...full.data, companyId: company.id });
assert.ok(site.isSuccess);
assert.equal(site.data.id.toPrimitive().charAt(14), "7");
assert.equal(site.data.phone.getOrUndefined()?.toPrimitive(), "+34 900 123 456");
const added = company.addSite(site.data);
assert.ok(added.isSuccess);
@ -105,6 +110,7 @@ test("la dirección es optativa, admite varias líneas y se puede borrar", () =>
assert.ok(cleared.isSuccess);
assert.equal(cleared.data.sites[0]?.address.getOrUndefined(), undefined);
assert.equal(cleared.data.sites[0]?.phone.getOrUndefined(), undefined);
assert.ok(cleared.data.sites[0]?.id.equals(site.data.id));
});
test("nombres, email, teléfono y longitud de dirección se validan en los Value Objects", () => {
@ -126,4 +132,5 @@ test("nombres, email, teléfono y longitud de dirección se validan en los Value
);
assert.equal(input.site({ name: "Central", address: "x".repeat(10001) }).isSuccess, false);
assert.equal(input.site({ name: "Central", address: "x".repeat(10000) }).isSuccess, true);
assert.equal(input.site({ name: "Central", address: null, phone: "abc" }).isSuccess, false);
});

View File

@ -4,9 +4,15 @@ import type { Maybe } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import { CompanyError } from "./company-error";
import type { PhoneNumber } from "./value-objects/phone-number.vo";
import type { SiteAddress } from "./value-objects/site-address.vo";
export type SiteProps = Readonly<{ companyId: UniqueID; name: Name; address: Maybe<SiteAddress> }>;
export type SiteProps = Readonly<{
companyId: UniqueID;
name: Name;
address: Maybe<SiteAddress>;
phone: Maybe<PhoneNumber>;
}>;
export class Site extends DomainEntity<SiteProps> {
static create(props: SiteProps): Result<Site, CompanyError> {
@ -33,6 +39,10 @@ export class Site extends DomainEntity<SiteProps> {
return this.props.address;
}
get phone() {
return this.props.phone;
}
update(values: Omit<SiteProps, "companyId">): Result<Site, CompanyError> {
if (values.name.toPrimitive().length > 100) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
@ -40,8 +50,11 @@ export class Site extends DomainEntity<SiteProps> {
const current = this.address.getOrUndefined();
const next = values.address.getOrUndefined();
const sameAddress = current ? current.equals(next) : next === undefined;
const currentPhone = this.phone.getOrUndefined();
const nextPhone = values.phone.getOrUndefined();
const samePhone = currentPhone ? currentPhone.equals(nextPhone) : nextPhone === undefined;
return this.name.equals(values.name) && sameAddress
return this.name.equals(values.name) && sameAddress && samePhone
? Result.ok(this)
: Result.ok(new Site({ ...values, companyId: this.companyId }, this.id));
}

View File

@ -174,6 +174,7 @@ export class CompanyPersistenceMapper {
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
phone: site.phone.getOrUndefined()?.toPrimitive() ?? null,
})),
departments: company.departments.map((item) => ({
id: item.id.toPrimitive(),

View File

@ -39,6 +39,7 @@ export class SiteModel extends Model<
declare companyId: string;
declare name: string;
declare address: string | null;
declare phone: string | null;
}
export function initializeCompanyModels(database: Sequelize): void {
@ -70,7 +71,11 @@ export function initializeCompanyModels(database: Sequelize): void {
{ sequelize: database, tableName: "company_contacts", timestamps: false }
);
SiteModel.init(
{ ...childFields, address: { type: DataTypes.TEXT, allowNull: true } },
{
...childFields,
address: { type: DataTypes.TEXT, allowNull: true },
phone: { type: DataTypes.STRING(50), allowNull: true },
},
{ sequelize: database, tableName: "company_sites", timestamps: false }
);
}

View File

@ -36,10 +36,20 @@ export async function migrateCompanies(database: Sequelize): Promise<void> {
active: { type: DataTypes.BOOLEAN, allowNull: false },
});
}
if (!tables.includes("company_sites")) {
if (tables.includes("company_sites")) {
const columns = await query.describeTable("company_sites");
if (!columns.phone) {
await query.addColumn("company_sites", "phone", {
type: DataTypes.STRING(50),
allowNull: true,
});
}
} else {
await query.createTable("company_sites", {
...childFields,
address: { type: DataTypes.TEXT, allowNull: true },
phone: { type: DataTypes.STRING(50), allowNull: true },
});
}
}

View File

@ -200,7 +200,7 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
if (record.sites.length) {
await SiteModel.bulkCreate(record.sites, {
transaction,
updateOnDuplicate: ["name", "address"],
updateOnDuplicate: ["name", "address", "phone"],
});
}
if (record.departments.length) {

View File

@ -12,6 +12,7 @@ export type ContactRequestDTO = z.infer<typeof ContactRequestSchema>;
export const SiteRequestSchema = SiteFieldsSchema.extend({
address: SiteFieldsSchema.shape.address.optional(),
phone: SiteFieldsSchema.shape.phone.optional(),
});
export type SiteRequestDTO = z.infer<typeof SiteRequestSchema>;

View File

@ -25,6 +25,7 @@ export const ContactFieldsSchema = PersonFieldsSchema.omit({ isContact: true });
export const SiteFieldsSchema = z.strictObject({
name: RequiredTextSchema,
address: z.string().max(10000).nullable(),
phone: z.string().max(50).nullable(),
});
export const CompanySummarySchema = CompanyFieldsSchema.extend({

View File

@ -1022,8 +1022,8 @@
},
"site": {
"description": "A site represents a company location.",
"examples": "Head office; Warehouse; address: 12 Main Street, London.",
"guidance": "The address is optional and supports multiple lines."
"examples": "Head office; Warehouse; address: 12 Main Street, London; phone: +44 20 7946 0958.",
"guidance": "The address and phone are optional; the address supports multiple lines."
},
"assignment": {
"description": "An assignment records a person's department membership, position and period.",

View File

@ -1022,8 +1022,8 @@
},
"site": {
"description": "Una sede representa una ubicación de la compañía.",
"examples": "Oficina central; Almacén; dirección: Calle Mayor 12, 28013 Madrid.",
"guidance": "La dirección es opcional y admite varias líneas."
"examples": "Oficina central; Almacén; dirección: Calle Mayor 12, 28013 Madrid; teléfono: +34 900 123 456.",
"guidance": "La dirección y el teléfono son opcionales; la dirección admite varias líneas."
},
"assignment": {
"description": "Una asignación registra la pertenencia de una persona a un departamento, su puesto y su periodo.",

View File

@ -190,16 +190,21 @@ test("la ficha presenta datos de la compañía, contactos y acceso a sedes", asy
assert.ok(markup.includes("Editar compañía"));
assert.equal(markup.includes("<form"), false);
});
test("el formulario de sede ofrece dirección multilínea optativa", async () => {
test("el formulario de sede ofrece dirección y teléfono opcionales", async () => {
const markup = await renderPage("es", <SiteForm companyId={companyId} />, () => undefined);
assert.ok(markup.includes("Dirección (opcional)"));
assert.ok(markup.includes("Teléfono (opcional)"));
assert.match(markup, /<textarea[^>]*rows="6"/);
assert.match(markup, /<textarea[^>]*name="address"/);
const textarea = markup.match(/<textarea[^>]*>/)?.[0];
assert.ok(textarea);
assert.equal(textarea.includes("required"), false);
const phone = markup.match(/<input[^>]*name="phone"[^>]*>/)?.[0];
assert.ok(phone);
assert.equal(phone.includes("required"), false);
});
test("el formulario de persona distingue contactos de cuentas de acceso y permite datos opcionales", async () => {

View File

@ -46,6 +46,8 @@ test("sin logotipo aparecen iniciales y las listas consultan la misma caché de
);
assert.ok(initials.includes("initials-avatar"));
assert.ok(initials.includes("[container-type:inline-size]"));
assert.ok(initials.includes("text-[clamp(0.75rem,45cqi,2rem)]"));
assert.equal(initials.includes("<img"), false);
client.clear();
});

View File

@ -38,7 +38,7 @@ export function CompanyAvatar({
{...props}
aria-label={name}
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full",
"inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full [container-type:inline-size]",
className
)}
data-slot="company-avatar"
@ -57,7 +57,11 @@ export function CompanyAvatar({
width={128}
/>
) : (
<InitialsAvatar aria-hidden="true" className="size-full" name={name} />
<InitialsAvatar
aria-hidden="true"
className="size-full text-[clamp(0.75rem,45cqi,2rem)]"
name={name}
/>
)}
</span>
);

View File

@ -101,14 +101,19 @@ test("los adapters mantienen la ausencia semántica sin modificar los campos del
assert.equal(request.phone, null);
assert.equal(request.active, false);
assert.equal(person.email, " ana@example.test ");
assert.deepEqual(buildSiteRequest({ name: "Office", address: " " }), {
assert.deepEqual(buildSiteRequest({ name: "Office", address: " ", phone: " " }), {
name: "Office",
address: null,
phone: null,
});
assert.equal(
buildSiteRequest({ name: "Office", address: " Street\n Floor " }).address,
" Street\n Floor "
);
const site = buildSiteRequest({
name: "Office",
address: " Street\n Floor ",
phone: " +34 900 123 456 ",
});
assert.equal(site.address, " Street\n Floor ");
assert.equal(site.phone, " +34 900 123 456 ");
});
test("las instrucciones de proceso conservan el orden y excluyen líneas vacías", () => {

View File

@ -41,6 +41,12 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
),
},
{
id: "phone",
label: t("phoneOptional"),
value: (site) => site.phone,
render: (site) => site.phone ?? "—",
},
{
id: "actions",
label: t("actions"),

View File

@ -1,5 +1,9 @@
import type { SiteRequestDTO } from "../../../../common";
export function buildSiteRequest(values: SiteRequestDTO): SiteRequestDTO {
return { ...values, address: values.address?.trim() ? values.address : null };
return {
...values,
address: values.address?.trim() ? values.address : null,
phone: values.phone?.trim() ? values.phone : null,
};
}

View File

@ -13,7 +13,11 @@ export function useSiteFormController({ site, companyId, onSaved }: SiteFormProp
const client = useQueryClient();
const form = useForm<SiteRequestDTO>({
resolver: zodResolver(SiteRequestSchema),
defaultValues: { name: site?.name ?? "", address: site?.address ?? "" },
defaultValues: {
name: site?.name ?? "",
address: site?.address ?? "",
phone: site?.phone ?? "",
},
});
const mutation = useMutation({
mutationFn: (values: SiteRequestDTO) => saveSite(companyId, site?.id, buildSiteRequest(values)),

View File

@ -36,6 +36,10 @@ export function SiteForm(props: SiteFormProps) {
/>
<p className="text-xs text-muted-foreground">{t("addressHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
</div>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}

View File

@ -4,32 +4,124 @@ import type { ResourceCatalog } from "./resource-catalog.aggregate";
import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo";
const FIELDS: readonly [string, string, ResourceFieldType, (readonly string[])?][] = [
["email", "Dirección de correo", "text"],
["provider", "Proveedor", "text"],
["context", "Contexto", "text"],
["unc", "Ruta UNC", "text"],
["server", "Servidor (referencia documental)", "text"],
["description", "Descripción", "text"],
["url", "URL", "text"],
["holder", "Titular", "text"],
["issuer", "Emisor", "text"],
["expires", "Caducidad", "date"],
["fingerprint", "Huella digital", "text"],
["purpose", "Finalidad", "text"],
["reference", "Referencia", "text"],
["software_product", "Producto de software", "text"],
["software_edition", "Edición o plan", "text"],
["license_model", "Modalidad de licencia", "choice", ["Suscripción", "Perpetua", "OEM", "Otra"]],
const FIELDS: readonly [string, string, ResourceFieldType, string, (readonly string[])?][] = [
[
"email",
"Dirección de correo",
"text",
"Indica la dirección completa del buzón. Ejemplo: soporte@empresa.es.",
],
[
"provider",
"Proveedor",
"text",
"Indica el proveedor o fabricante del servicio. Ejemplo: Microsoft 365.",
],
[
"context",
"Contexto",
"text",
"Aclara el uso, área o persona a la que está destinado. Ejemplo: facturación.",
],
["unc", "Ruta UNC", "text", "Usa la ruta de red completa. Ejemplo: \\servidor\\contabilidad."],
[
"server",
"Servidor (referencia documental)",
"text",
"Anota el servidor que publica el recurso, si se conoce. Ejemplo: FS-01.",
],
[
"description",
"Descripción",
"text",
"Añade los detalles necesarios para identificar o utilizar el recurso. Ejemplo: acceso solo para administración.",
],
[
"url",
"URL",
"text",
"Incluye la dirección web de acceso o de documentación. Ejemplo: https://portal.empresa.es.",
],
[
"holder",
"Titular",
"text",
"Indica la persona u organización titular del certificado. Ejemplo: Empresa S. L.",
],
[
"issuer",
"Emisor",
"text",
"Indica la entidad que emitió el certificado. Ejemplo: Let's Encrypt.",
],
[
"expires",
"Caducidad",
"date",
"Indica la fecha en la que deja de ser válido. Ejemplo: 2027-06-30.",
],
[
"fingerprint",
"Huella digital",
"text",
"Copia la huella o identificador único del certificado. Ejemplo: SHA-256 A1:B2:C3:…",
],
[
"purpose",
"Finalidad",
"text",
"Explica qué servicio, proceso o equipo utiliza este recurso. Ejemplo: firma de facturas.",
],
[
"reference",
"Referencia",
"text",
"Registra el identificador, número de contrato o referencia del proveedor. Ejemplo: contrato MS-2026-014.",
],
[
"software_product",
"Producto de software",
"text",
"Indica el nombre comercial del producto licenciado. Ejemplo: Microsoft 365 Business Premium.",
],
[
"software_edition",
"Edición o plan",
"text",
"Indica la edición, nivel o plan contratado. Ejemplo: Enterprise E3.",
],
[
"license_model",
"Modalidad de licencia",
"choice",
"Selecciona cómo se adquiere o mantiene la licencia. Ejemplo: Suscripción.",
["Suscripción", "Perpetua", "OEM", "Otra"],
],
[
"license_metric",
"Criterio de licenciamiento",
"choice",
"Selecciona la unidad con la que se calcula la cobertura contratada. Ejemplo: Por usuario.",
["Por usuario", "Por dispositivo", "Por servidor", "Concurrente", "Corporativa", "Otro"],
],
["license_quantity", "Cantidad contratada", "number"],
["renews", "Fecha de renovación", "date"],
["auto_renew", "Renovación automática", "boolean"],
[
"license_quantity",
"Cantidad contratada",
"number",
"Indica el número de licencias o unidades cubiertas. Ejemplo: 25.",
],
[
"renews",
"Fecha de renovación",
"date",
"Indica la próxima fecha prevista de renovación. Ejemplo: 2027-01-01.",
],
[
"auto_renew",
"Renovación automática",
"boolean",
"Marca si la renovación se realiza automáticamente. Ejemplo: Sí si el proveedor carga el siguiente periodo.",
],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
@ -82,11 +174,11 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
current = result.data;
};
for (const [key, name, type, choices] of FIELDS) {
for (const [key, name, type, description, choices] of FIELDS) {
add(`field:${key}`, {
kind: "field",
name,
description: "",
description,
type,
unit: "",
choices: [...(choices ?? [])],

View File

@ -144,7 +144,21 @@ export class ResourceCatalog extends AggregateRoot<CatalogProps> {
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
if (previous) {
return Result.ok(this);
const enriched = previous.initializeDescription(values);
return enriched === previous
? Result.ok(this)
: Result.ok(
new ResourceCatalog(
{
...this.props,
definitions: this.definitions.map((item) =>
item.id.equals(previous.id) ? enriched : item
),
},
this.id
)
);
}
return this.replace(

View File

@ -7,6 +7,7 @@ import { Maybe } from "@repo/rdx-utils";
import { ResourceCatalog } from "./resource-catalog.aggregate";
import { seedResourceCatalog } from "./resource-catalog-seed";
import type { ResourceDefinitionSpec } from "./resource-definition";
import { ResourceDefinition } from "./resource-definition.entity";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo";
function values(input: ResourceDefinitionSpec) {
@ -32,6 +33,53 @@ test("catálogo de recursos: serie idempotente, UUID v7, características opcion
assert.equal(catalog.definitions.length, 27);
assert.equal(seedResourceCatalog(catalog), catalog);
assert.ok(catalog.definitions.every((item) => item.id.toPrimitive()[14] === "7"));
assert.ok(
catalog.definitions
.filter((item) => item.values.data.kind === "field")
.every((item) => item.values.data.description.includes("Ejemplo:"))
);
const previousDescription = ResourceDefinitionValues.create({
kind: "field",
name: "Dirección de correo",
description: "Indica la dirección completa del buzón.",
type: "text",
unit: "",
choices: [],
});
assert.ok(previousDescription.isSuccess);
const email = catalog.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:email"
);
assert.ok(email);
const catalogWithoutHelp = ResourceCatalog.rehydrate(
{
definitions: catalog.definitions.map((item) =>
item.id.equals(email.id)
? ResourceDefinition.rehydrate(
{
companyId: item.companyId,
stockKey: item.stockKey,
values: previousDescription.data,
active: item.active,
bindings: item.bindings,
},
item.id
)
: item
),
availability: catalog.availability,
},
catalog.id
);
const enriched = seedResourceCatalog(catalogWithoutHelp);
assert.ok(
enriched.definitions
.find((item) => item.id.equals(email.id))
?.values.data.description.includes("Ejemplo:")
);
const template = catalog.definitions.find((item) => item.values.data.kind === "template");
assert.ok(template && template.values.data.kind === "template");

View File

@ -145,6 +145,29 @@ export class ResourceDefinition extends DomainEntity<DefinitionProps> {
return new ResourceDefinition({ ...this.props, values: values.data }, this.id);
}
/** Completa la ayuda de un campo de serie solo cuando todavía no fue definida. */
initializeDescription(source: ResourceDefinitionValues) {
const previous = this.values.data;
const next = source.data;
if (
!this.stockKey.getOrUndefined() ||
previous.kind !== "field" ||
next.kind !== "field" ||
!next.description ||
(previous.description && !next.description.startsWith(`${previous.description} Ejemplo:`))
) {
return this;
}
const values = ResourceDefinitionValues.create({ ...previous, description: next.description });
if (!values.isSuccess) {
throw new Error("Texto de ayuda inicial de recurso inconsistente.");
}
return new ResourceDefinition({ ...this.props, values: values.data }, this.id);
}
setActive(active: boolean) {
return active === this.active
? this

View File

@ -63,7 +63,7 @@ export function EntityStatus({
aria-hidden="true"
className={
active
? "size-2 shrink-0 rounded-full bg-primary"
? "size-2 shrink-0 rounded-full bg-emerald-500"
: "size-2 shrink-0 rounded-full bg-muted-foreground"
}
/>

View File

@ -27,6 +27,8 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
}) {
const container = useRef<HTMLDivElement>(null);
const styles = fieldSectionStyles(layout, values);
const contentClass =
layout === "rows" && sections.length > 0 ? `${styles.content} px-5` : styles.content;
useEffect(() => {
if (hasErrors) {
@ -40,7 +42,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values ? (
<dl className="@container">{fields.map(renderItem)}</dl>
) : (
<div className={styles.content}>{fields.map(renderItem)}</div>
<div className={contentClass}>{fields.map(renderItem)}</div>
);
if (!sections.length) {
@ -140,7 +142,7 @@ const FIELD_SECTION_LAYOUTS = {
wrapper: "grid items-start gap-4 @min-[900px]:grid-cols-2",
},
rows: {
content: "divide-y divide-border/50 pl-5",
content: "divide-y divide-border/50",
wrapper: "grid items-start gap-x-8 gap-y-3 @min-[900px]:grid-cols-2",
},
form: {

View File

@ -3,7 +3,12 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { EntityAction, EntityRowActions, EntityTable } from "./components/entity-list";
import {
EntityAction,
EntityRowActions,
EntityStatus,
EntityTable,
} from "./components/entity-list";
import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list";
const items = Array.from({ length: 21 }, (_, index) => ({
@ -114,6 +119,14 @@ test("acciones: disparador de solo icono con etiqueta accesible y menú contextu
assert.equal(html.includes(">Acciones de Ana"), false);
});
test("el estado activo se distingue en verde del inactivo", () => {
const active = renderToStaticMarkup(<EntityStatus active>Activa</EntityStatus>);
const inactive = renderToStaticMarkup(<EntityStatus active={false}>Inactiva</EntityStatus>);
assert.ok(active.includes("bg-emerald-500"));
assert.ok(inactive.includes("bg-muted-foreground"));
});
test("acciones: siempre última, compacta y anclada a la derecha, aunque se declare primero", () => {
const html = renderToStaticMarkup(
<EntityTable