Repaso a las plantillas de activos y de recursos
This commit is contained in:
parent
832d69c6fa
commit
6d6f807486
7
TODO.md
7
TODO.md
@ -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.
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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>) {
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
1
modules/assets/src/web/catalog/delete/index.ts
Normal file
1
modules/assets/src/web/catalog/delete/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DeleteCatalogDefinitionDialog } from "./ui/delete-catalog-definition-dialog";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`, {}));
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 ?? [])],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user