Puestos de trabajo
This commit is contained in:
parent
2a39be97f4
commit
867d5ec9c1
@ -30,7 +30,7 @@ export async function verifyAssets(
|
||||
assert.equal(data.code, code);
|
||||
}
|
||||
const initial = await read(global);
|
||||
assert.equal(initial.items.length, 42);
|
||||
assert.equal(initial.items.length, 47);
|
||||
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12);
|
||||
assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
|
||||
assert.ok(
|
||||
|
||||
@ -23,6 +23,8 @@ import { verifyAssets } from "./assets.integration";
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
import { verifyInventory } from "./inventory.integration";
|
||||
import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyWorkstations } from "./workstations.integration";
|
||||
|
||||
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
|
||||
const environment = readDatabaseEnvironment(process.env);
|
||||
@ -35,6 +37,10 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await once(server, "listening");
|
||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||
await database.query("DELETE FROM http_sessions");
|
||||
await database.query("DELETE FROM workstation_assets");
|
||||
await database.query("DELETE FROM workstation_revisions");
|
||||
await database.query("DELETE FROM workstations");
|
||||
await database.query("DELETE FROM asset_relationships");
|
||||
await database.query("DELETE FROM asset_revisions");
|
||||
await database.query("DELETE FROM assets");
|
||||
await database.query("DELETE FROM asset_availability");
|
||||
@ -693,6 +699,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyRelationships(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyWorkstations(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
const countAfterAccess = await database.query<{ total: number }>(
|
||||
"SELECT COUNT(*) AS total FROM users",
|
||||
{ type: QueryTypes.SELECT }
|
||||
@ -702,6 +720,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
|
||||
assert.equal((await operator.session()).user, null);
|
||||
await expectError(
|
||||
await operator.request(`/assets/${customer.id}/relationships`, {}),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/assets/${customer.id}/workstations`, {}),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||
await assert.rejects(
|
||||
|
||||
155
apps/server/src/relationships.integration.ts
Normal file
155
apps/server/src/relationships.integration.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
AssetResponseSchema,
|
||||
CatalogResponseSchema,
|
||||
InventoryResponseSchema,
|
||||
RelationshipResponseSchema,
|
||||
SaveDefinitionResponseSchema,
|
||||
} from "@metis/assets/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyRelationships(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const path = `/assets/${companyId}`;
|
||||
const catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/asset-catalog/${companyId}`)).json()
|
||||
);
|
||||
const type = catalog.items.find(
|
||||
(item) => item.values.kind === "relation" && item.values.symmetric
|
||||
);
|
||||
assert.ok(type);
|
||||
const template = catalog.items.find(
|
||||
(item) => item.values.kind === "template" && item.values.name === "Monitor"
|
||||
);
|
||||
assert.ok(template);
|
||||
const own = InventoryResponseSchema.parse(await (await request(path)).json()).assets;
|
||||
const first = own[0];
|
||||
const second = own[1];
|
||||
assert.ok(first && second);
|
||||
const foreignResponse = await request(`/assets/${otherId}`, {
|
||||
values: {
|
||||
name: "MON-Foreign",
|
||||
templateId: template.id,
|
||||
status: "available",
|
||||
siteId: null,
|
||||
personId: null,
|
||||
characteristics: {},
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
assert.equal(foreignResponse.status, 201);
|
||||
const foreign = AssetResponseSchema.parse(await foreignResponse.json()).asset;
|
||||
const input = { sourceId: first.id, targetId: second.id, typeId: type.id, notes: "USB" };
|
||||
const linkPath = `${path}/relationships`;
|
||||
const save = async (values: unknown) => {
|
||||
const response = await request(linkPath, { values });
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
return RelationshipResponseSchema.parse(await response.json()).relationship;
|
||||
};
|
||||
const error = async (values: unknown, code: string, status = 409) => {
|
||||
const response = await request(linkPath, { values });
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
};
|
||||
await error({ ...input, targetId: first.id }, "SELF_RELATIONSHIP");
|
||||
await error({ ...input, targetId: foreign.id }, "ASSET_NOT_FOUND", 404);
|
||||
await error({ ...input, typeId: template.id }, "RELATION_TYPE_UNAVAILABLE");
|
||||
await error({ ...input, createdBy: first.id }, "INVALID_REQUEST", 400);
|
||||
assert.equal((await request(linkPath, { values: input }, "https://other.example")).status, 403);
|
||||
assert.equal((await request(linkPath, { values: input }, undefined, "a".repeat(64))).status, 403);
|
||||
const [created, retry] = await Promise.all([save(input), save(input)]);
|
||||
assert.equal(created.id, retry.id);
|
||||
assert.equal(created.id.charAt(14), "7");
|
||||
assert.ok(created.createdByName);
|
||||
assert.equal((await save({ ...input, sourceId: second.id, targetId: first.id })).id, created.id);
|
||||
await error({ ...input, notes: "Otro cable" }, "RELATIONSHIP_EXISTS");
|
||||
await assert.rejects(
|
||||
database.query("UPDATE asset_relationships SET targetId=:target WHERE id=:id", {
|
||||
replacements: { target: foreign.id, id: created.id },
|
||||
})
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: false })).status,
|
||||
200
|
||||
);
|
||||
await error(input, "RELATION_TYPE_UNAVAILABLE");
|
||||
const endPath = `${path}/relationships/${created.id}/end`;
|
||||
const ending = await request(endPath, {});
|
||||
assert.equal(ending.status, 200);
|
||||
const ended = RelationshipResponseSchema.parse(await ending.json()).relationship;
|
||||
assert.ok(ended.endedAt);
|
||||
assert.ok(ended.endedByName);
|
||||
assert.deepEqual(
|
||||
RelationshipResponseSchema.parse(await (await request(endPath, {})).json()).relationship,
|
||||
ended
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/assets/${otherId}/relationships/${created.id}/end`, {})).status,
|
||||
404
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/asset-catalog/${companyId}/${type.id}/status`, { active: null })).status,
|
||||
200
|
||||
);
|
||||
const replacement = await save(input);
|
||||
assert.notEqual(replacement.id, created.id);
|
||||
await verifyDirectional(request, companyId, otherId, first.id, second.id);
|
||||
const stored = InventoryResponseSchema.parse(await (await request(path)).json()).relationships;
|
||||
assert.deepEqual(
|
||||
stored.find((item) => item.id === ended.id),
|
||||
ended
|
||||
);
|
||||
assert.ok(
|
||||
!InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).relationships
|
||||
.length
|
||||
);
|
||||
}
|
||||
async function verifyDirectional(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
sourceId: string,
|
||||
targetId: string
|
||||
) {
|
||||
const definition = {
|
||||
kind: "relation",
|
||||
name: "Replica a",
|
||||
inverseName: "Recibe réplica de",
|
||||
description: "",
|
||||
symmetric: false,
|
||||
};
|
||||
const response = await request(`/asset-catalog/${companyId}`, { values: definition });
|
||||
assert.equal(response.status, 201);
|
||||
const typeId = SaveDefinitionResponseSchema.parse(await response.json()).savedId;
|
||||
const path = `/assets/${companyId}/relationships`;
|
||||
const values = { sourceId, targetId, typeId, notes: "" };
|
||||
const added = await request(path, { values });
|
||||
assert.equal(added.status, 201);
|
||||
const link = RelationshipResponseSchema.parse(await added.json()).relationship;
|
||||
const reverse = await request(path, {
|
||||
values: { ...values, sourceId: targetId, targetId: sourceId },
|
||||
});
|
||||
assert.equal(reverse.status, 201);
|
||||
assert.notEqual(RelationshipResponseSchema.parse(await reverse.json()).relationship.id, link.id);
|
||||
const edited = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
|
||||
values: { ...definition, name: "Replica hacia" },
|
||||
});
|
||||
assert.equal(edited.status, 200);
|
||||
const conversion = await request(`/asset-catalog/${companyId}/${typeId}/update`, {
|
||||
values: { ...definition, symmetric: true },
|
||||
});
|
||||
assert.equal(conversion.status, 409);
|
||||
const stored = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
).relationships.find((item) => item.id === link.id);
|
||||
assert.equal(stored?.values.name, "Replica a");
|
||||
const other = await request(`/assets/${otherId}/relationships`, { values });
|
||||
assert.equal(other.status, 404);
|
||||
}
|
||||
136
apps/server/src/workstations.integration.ts
Normal file
136
apps/server/src/workstations.integration.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { InventoryResponseSchema, WorkstationResponseSchema } from "@metis/assets/common";
|
||||
import { CompanyResponseSchema } from "@metis/companies/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyWorkstations(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const path = `/assets/${companyId}/workstations`;
|
||||
const own = CompanyResponseSchema.parse(
|
||||
await (await request(`/companies/${companyId}`)).json()
|
||||
).company;
|
||||
const other = CompanyResponseSchema.parse(
|
||||
await (await request(`/companies/${otherId}`)).json()
|
||||
).company;
|
||||
const inventory = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
);
|
||||
const foreign = InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json())
|
||||
.assets[0];
|
||||
const first = inventory.assets[0];
|
||||
const second = inventory.assets[1];
|
||||
assert.ok(first && second && foreign);
|
||||
const personFields = {
|
||||
name: "Persona puesto",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
};
|
||||
const personResponse = await request(`/companies/${companyId}/people`, personFields);
|
||||
assert.equal(personResponse.status, 201);
|
||||
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
|
||||
assert.ok(person);
|
||||
const input = {
|
||||
name: "Puesto Ana",
|
||||
active: true,
|
||||
siteId: own.sites[0]?.id ?? null,
|
||||
personId: person.id,
|
||||
assetIds: [first.id, second.id],
|
||||
};
|
||||
const save = async (values: unknown, id?: string) => {
|
||||
const response = await request(id ? `${path}/${id}/update` : path, { values });
|
||||
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
|
||||
return WorkstationResponseSchema.parse(await response.json()).workstation;
|
||||
};
|
||||
const error = async (values: unknown, code: string, id?: string, status = 409) => {
|
||||
const response = await request(id ? `${path}/${id}/update` : path, { values });
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
};
|
||||
await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404);
|
||||
await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404);
|
||||
await error({ ...input, personId: other.people[0]?.id }, "PERSON_UNAVAILABLE");
|
||||
await error({ ...input, assetIds: [first.id, first.id] }, "INVALID_MEMBERS");
|
||||
await error({ ...input, actorId: first.id }, "INVALID_REQUEST", undefined, 400);
|
||||
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
|
||||
assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403);
|
||||
const [a, b] = await Promise.all([
|
||||
request(path, { values: input }),
|
||||
request(path, { values: { ...input, name: "Puesto conflictivo" } }),
|
||||
]);
|
||||
assert.deepEqual([a.status, b.status].sort(), [201, 409]);
|
||||
const success = a.status === 201 ? a : b;
|
||||
const created = WorkstationResponseSchema.parse(await success.json()).workstation;
|
||||
assert.equal(created.id.charAt(14), "7");
|
||||
assert.equal(created.history[0]?.id.charAt(14), "7");
|
||||
assert.ok(created.history[0]?.actorName);
|
||||
const unchanged = await save(
|
||||
{ ...created.values, assetIds: [...created.values.assetIds].reverse() },
|
||||
created.id
|
||||
);
|
||||
assert.deepEqual(unchanged, created);
|
||||
const empty = await save({ ...input, name: "Puesto vacío", siteId: null, assetIds: [] });
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/companies/${companyId}/people/${person.id}/update`, {
|
||||
...personFields,
|
||||
active: false,
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
await error({ ...empty.values, name: "Nueva asignación inactiva" }, "PERSON_UNAVAILABLE");
|
||||
assert.deepEqual(await save(created.values, created.id), created);
|
||||
await error({ ...empty.values, assetIds: [first.id] }, "ASSET_IN_WORKSTATION", empty.id);
|
||||
await error({ ...empty.values, assetIds: [created.id] }, "ASSET_NOT_FOUND", empty.id, 404);
|
||||
const inactive = await save({ ...created.values, active: false }, created.id);
|
||||
assert.equal(inactive.history.length, 2);
|
||||
const receiver = await save({ ...empty.values, assetIds: [first.id] }, empty.id);
|
||||
await error({ ...created.values, active: true }, "ASSET_IN_WORKSTATION", created.id);
|
||||
const removed = await save({ ...receiver.values, assetIds: [] }, receiver.id);
|
||||
assert.equal(removed.history.length, 3);
|
||||
const restored = await save(created.values, created.id);
|
||||
assert.equal(restored.history.length, 3);
|
||||
assert.deepEqual(restored.history[1]?.values.assetIds, created.values.assetIds);
|
||||
const foreignUpdate = await request(`/assets/${otherId}/workstations/${created.id}/update`, {
|
||||
values: created.values,
|
||||
});
|
||||
assert.equal(foreignUpdate.status, 404);
|
||||
await assert.rejects(
|
||||
database.query("UPDATE workstation_assets SET assetId=:asset WHERE workstationId=:id", {
|
||||
replacements: { asset: foreign.id, id: created.id },
|
||||
})
|
||||
);
|
||||
const corruption = { ...restored.values, assetIds: [foreign.id] };
|
||||
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
|
||||
replacements: { values: JSON.stringify(corruption), id: restored.id },
|
||||
});
|
||||
try {
|
||||
assert.equal((await request(`/assets/${companyId}`)).status, 500);
|
||||
} finally {
|
||||
await database.query("UPDATE workstations SET `values`=:values WHERE id=:id", {
|
||||
replacements: { values: JSON.stringify(restored.values), id: restored.id },
|
||||
});
|
||||
}
|
||||
const current = InventoryResponseSchema.parse(
|
||||
await (await request(`/assets/${companyId}`)).json()
|
||||
);
|
||||
assert.deepEqual(
|
||||
current.workstations.find((item) => item.id === restored.id),
|
||||
restored
|
||||
);
|
||||
assert.equal(
|
||||
InventoryResponseSchema.parse(await (await request(`/assets/${otherId}`)).json()).workstations
|
||||
.length,
|
||||
0
|
||||
);
|
||||
}
|
||||
@ -102,6 +102,8 @@ test("el espacio mantiene compañía, secciones y ficha del primer activo sin fo
|
||||
client.setQueryData(companyKeys.detail(workspaceCompany.id), { company: workspaceCompany });
|
||||
client.setQueryData(inventoryKeys.company(workspaceCompany.id), {
|
||||
assets: workspaceAssets,
|
||||
relationships: [],
|
||||
workstations: [],
|
||||
});
|
||||
client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog);
|
||||
}
|
||||
|
||||
@ -49,6 +49,10 @@ AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateFiel
|
||||
|
||||
Asset es la raíz de cada activo real y AssetRevision una entidad con UUID propio, instantánea de valores, actor y fecha. AssetValues y AssetSpecification son Value Objects inmutables. La especificación capturada conserva los campos y reglas de la ficha aunque cambie el catálogo. Consultar [Inventario de activos](../inventario-de-activos.md).
|
||||
|
||||
AssetRelationship es la raíz de un vínculo entre dos activos de una compañía, con UUID propio y auditoría de creación y retirada. AssetRelationshipValues reconstruye extremos, tipo y nombres como Value Objects y conserva una instantánea. Consultar [Relaciones entre activos](../relaciones-entre-activos.md).
|
||||
|
||||
Workstation es la raíz de cada puesto y WorkstationRevision una entidad con UUID propio para cada instantánea. WorkstationValues encapsula el nombre, estado, referencias opcionales y composición. Las referencias técnicas de reserva se derivan del agregado. Consultar [Puestos de trabajo](../puestos-de-trabajo.md).
|
||||
|
||||
Los módulos de documentos y tickets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado.
|
||||
|
||||
Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización.
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
## Uso
|
||||
|
||||
El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Plantillas de activos: combina las globales con sus adiciones propias.
|
||||
El Catálogo de activos de la navegación principal contiene las definiciones globales. La ficha de cada compañía incorpora Catálogo de activos: combina las globales con sus adiciones propias.
|
||||
|
||||
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.
|
||||
|
||||
@ -15,7 +15,7 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d
|
||||
|
||||
## Datos de serie
|
||||
|
||||
Se incluyen 30 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 30 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.
|
||||
|
||||
@ -41,6 +41,6 @@ Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_avai
|
||||
|
||||
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.
|
||||
|
||||
## Siguiente entrega
|
||||
## Inventario y relaciones
|
||||
|
||||
Registrar activos reales utilizando estas plantillas, con compañía, sede, asignación a personas y movimientos. Las relaciones y conjuntos de puesto de trabajo se incorporarán después. Este bloque no registra todavía inventario, configuración por dispositivo ni relaciones entre activos.
|
||||
El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. El mismo catálogo incorpora los [tipos de relación](relaciones-entre-activos.md). Los [puestos de trabajo](puestos-de-trabajo.md) agrupan los activos registrados.
|
||||
|
||||
@ -42,4 +42,4 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y
|
||||
|
||||
Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial.
|
||||
|
||||
Las relaciones entre activos y los conjuntos de puesto de trabajo se incorporarán en el siguiente bloque. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan.
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan.
|
||||
|
||||
@ -41,10 +41,9 @@ Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
1. Relaciones entre activos y conjuntos de puesto de trabajo.
|
||||
2. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
|
||||
3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
|
||||
2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||
|
||||
@ -52,7 +51,7 @@ Este orden se concreta antes de cada entrega. Las licencias y su relación con c
|
||||
|
||||
Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro estados, características tipadas y revisiones con actor y fecha. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md).
|
||||
|
||||
Los conjuntos de puesto de trabajo y las relaciones entre activos siguen pendientes.
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, reserva exclusiva de activos en puestos vigentes e historial.
|
||||
|
||||
## Tickets e intervenciones: pendiente
|
||||
|
||||
@ -66,4 +65,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
|
||||
|
||||
## Decisiones pendientes
|
||||
|
||||
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; las relaciones entre activos y los tickets siguen pendientes.
|
||||
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets siguen pendientes.
|
||||
|
||||
33
docs/puestos-de-trabajo.md
Normal file
33
docs/puestos-de-trabajo.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Puestos de trabajo
|
||||
|
||||
## Uso
|
||||
|
||||
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
|
||||
|
||||
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas, sedes y personas existentes para evitar un tercer modal.
|
||||
|
||||
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra su puesto vigente.
|
||||
|
||||
## Reglas
|
||||
|
||||
Un activo solo puede pertenecer a un puesto activo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos para un puesto activo. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden ocupar el mismo activo.
|
||||
|
||||
Desactivar un puesto conserva su composición y libera sus reservas. Un activo puede figurar en composiciones de puestos inactivos. Reactivar comprueba de nuevo la disponibilidad: si un activo está ocupado, hay que retirarlo de uno de los puestos antes de guardar. Retirar un activo del puesto no elimina el activo ni sus relaciones.
|
||||
|
||||
Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe estar activa para una nueva asignación. Si se desactiva después, se conserva la asignación anterior para revisión y puede retirarse. La persona no recibe acceso a Metis Desk.
|
||||
|
||||
La asignación del puesto no cambia automáticamente sede, persona o estado de cada activo; son registros independientes. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
|
||||
|
||||
No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.
|
||||
|
||||
## Arquitectura y API
|
||||
|
||||
Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito.
|
||||
|
||||
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos, claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
|
||||
|
||||
El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas.
|
||||
|
||||
GET /api/assets/:companyId incluye workstations junto a assets y relationships. POST /api/assets/:companyId/workstations y POST /api/assets/:companyId/workstations/:id/update reciben values. Las escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión y no se admite en el cuerpo.
|
||||
|
||||
Las pruebas verifican identidad, composición opcional, límites, igualdad, historial, ámbito, ausencia de anidación, desactivación y reactivación, personas inactivas, concurrencia y persistencia. La comprobación de navegador incluye altas rápidas, cancelación, composición, activos ocupados e historial.
|
||||
29
docs/relaciones-entre-activos.md
Normal file
29
docs/relaciones-entre-activos.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Relaciones entre activos
|
||||
|
||||
## Uso y catálogo
|
||||
|
||||
Catálogo de activos incorpora la pestaña Relaciones, tanto global como por compañía. Las definiciones de serie son Conectado a, Alojado en / Aloja, Depende de / Es necesario para, Alimentado por / Alimenta e Instalado en / Tiene instalado. Se pueden añadir tipos globales o exclusivos de una compañía. Las definiciones de serie se pueden activar o desactivar, pero no modificar ni borrar.
|
||||
|
||||
La disponibilidad global actúa como valor predeterminado; una compañía puede personalizarla y volver a usar la configuración global. Desactivar un tipo impide crear vínculos nuevos, pero conserva los existentes y permite retirarlos. Una definición con dirección tiene un nombre por sentido; una simétrica usa el mismo nombre en ambos. Esta elección queda fija tras el alta.
|
||||
|
||||
En la ficha lateral o Ficha e historial, Relacionar activo abre un modal para seleccionar el tipo, otro activo y notas opcionales. El «+» junto al tipo permite crearlo para esa compañía y lo selecciona sin perder el resto del formulario. La nueva definición se conserva aunque después se cancele el vínculo. La ayuda queda inmediatamente antes de Guardar y Cancelar.
|
||||
|
||||
Las fichas de ambos extremos muestran el vínculo con su nombre correspondiente. Por ejemplo, la máquina virtual muestra Alojado en · Servidor y el servidor muestra Aloja · Máquina virtual. Los nombres se capturan al crear cada vínculo; renombrar un tipo propio no reescribe los vínculos anteriores.
|
||||
|
||||
## Reglas e historial
|
||||
|
||||
Ambos activos pertenecen a la misma compañía y deben existir. Un activo no puede relacionarse consigo mismo. Una relación vigente no puede duplicarse con el mismo tipo y extremos: en las simétricas, invertir los extremos tampoco crea otro vínculo. Repetir exactamente el alta devuelve la relación existente; cambiar las notas de un duplicado produce un error. En las relaciones con dirección, el sentido inverso es un vínculo distinto.
|
||||
|
||||
Retirar relación requiere una confirmación en un modal. Conserva UUID, extremos, notas, tipo y autor y fecha de creación, añadiendo autor y fecha de retirada. Repetir la retirada no altera los datos. Las relaciones retiradas se consultan en un apartado desplegable. Se puede volver a crear un vínculo retirado, con un nuevo UUID.
|
||||
|
||||
Los activos retirados conservan sus relaciones para revisión; retirar el activo no desconecta automáticamente otros equipos. No se incluyen restricciones por plantilla, detección de ciclos ni conjuntos anidados. Los [puestos de trabajo](puestos-de-trabajo.md) agrupan activos sin crear relaciones automáticamente.
|
||||
|
||||
## Arquitectura y API
|
||||
|
||||
AssetRelationship es un agregado con UniqueID UUID v7. AssetRelationshipValues es un Value Object inmutable que reconstruye los extremos y el tipo como UniqueID, y los nombres como Name. AssetCatalog conserva los tipos como AssetDefinition de clase relation.
|
||||
|
||||
Infrastructure reconstruye y valida valores, auditoría, pertenencia y referencias. asset_relationships contiene claves foráneas a compañía, activos, tipo y usuarios. Las claves compuestas impiden extremos de otra compañía; la clave vigente única evita duplicados incluso ante concurrencia. Las escrituras transaccionales bloquean catálogo y compañía en el orden compartido con el inventario.
|
||||
|
||||
GET /api/assets/:companyId devuelve assets y relationships, incluidas las retiradas. POST /api/assets/:companyId/relationships recibe values con sourceId, targetId, typeId y notes. POST /api/assets/:companyId/relationships/:id/end recibe un cuerpo vacío. Ambas escrituras requieren sesión, origen válido y CSRF; el actor procede de la sesión.
|
||||
|
||||
Las pruebas cubren UUID, dirección, simetría, autovínculos, duplicados concurrentes, ámbito, disponibilidad, protección de serie, dirección fija, instantáneas, auditoría e idempotencia. La comprobación en navegador verifica alta, alta rápida, cancelación y retirada.
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import type { RelationshipDTO } from "../../common/relationships";
|
||||
import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
|
||||
export function buildRelationshipResponse(
|
||||
item: AssetRelationship,
|
||||
actors: ReadonlyMap<string, string>
|
||||
): RelationshipDTO {
|
||||
const endedBy = item.endedBy.getOrUndefined()?.toPrimitive() ?? null;
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
createdBy: item.createdBy.toPrimitive(),
|
||||
createdByName: actors.get(item.createdBy.toPrimitive()) ?? "",
|
||||
createdAt: item.createdAt,
|
||||
endedBy,
|
||||
endedByName: endedBy ? (actors.get(endedBy) ?? "") : null,
|
||||
endedAt: item.endedAt.getOrUndefined() ?? null,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { RelationshipInputDTO } from "../../common/relationships";
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import { AssetRelationship } from "../domain/asset-relationship.aggregate";
|
||||
import { AssetRelationshipValues } from "../domain/value-objects/asset-relationship-values";
|
||||
|
||||
import type { IAssetRepository, InventoryContext } from "./asset.repository";
|
||||
|
||||
export class AssetRelationshipUseCases {
|
||||
constructor(private readonly repository: IAssetRepository) {}
|
||||
save(companyId: string, input: RelationshipInputDTO, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
if (!(company.isSuccess && actor.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.saveRelationship(company.data, (context) => {
|
||||
const mapped = mapRelationshipInput(input, company.data, context);
|
||||
if (!mapped.isSuccess) {
|
||||
return Result.fail(mapped.error);
|
||||
}
|
||||
const previous = context.relationships.find(
|
||||
(item) => item.active && item.values.key() === mapped.data.key()
|
||||
);
|
||||
if (previous) {
|
||||
return previous.values.notes === mapped.data.notes
|
||||
? Result.ok(previous)
|
||||
: Result.fail(new AssetCatalogError("RELATIONSHIP_EXISTS"));
|
||||
}
|
||||
return AssetRelationship.create(
|
||||
company.data,
|
||||
mapped.data,
|
||||
actor.data,
|
||||
new Date().toISOString()
|
||||
);
|
||||
});
|
||||
}
|
||||
end(companyId: string, id: string, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const identity = UniqueID.create(id);
|
||||
const actor = UniqueID.create(actorId);
|
||||
if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.saveRelationship(company.data, (context) => {
|
||||
const item = context.relationships.find((entry) => entry.id.equals(identity.data));
|
||||
return item
|
||||
? item.end(actor.data, new Date().toISOString())
|
||||
: Result.fail(new AssetCatalogError("RELATIONSHIP_NOT_FOUND"));
|
||||
});
|
||||
}
|
||||
}
|
||||
export function mapRelationshipInput(
|
||||
input: RelationshipInputDTO,
|
||||
company: UniqueID,
|
||||
context: InventoryContext
|
||||
) {
|
||||
if (
|
||||
![input.sourceId, input.targetId].every((id) =>
|
||||
context.assets.some((asset) => asset.id.toPrimitive() === id)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
const scope = Maybe.some(company);
|
||||
const type = context.catalog
|
||||
.visible(scope)
|
||||
.find((item) => item.id.toPrimitive() === input.typeId);
|
||||
if (!(type && type.values.data.kind === "relation" && context.catalog.active(type, scope))) {
|
||||
return Result.fail(new AssetCatalogError("RELATION_TYPE_UNAVAILABLE"));
|
||||
}
|
||||
return AssetRelationshipValues.create({
|
||||
...input,
|
||||
name: type.values.data.name,
|
||||
inverseName: type.values.data.inverseName,
|
||||
symmetric: type.values.data.symmetric,
|
||||
});
|
||||
}
|
||||
@ -4,17 +4,36 @@ import { Result } from "@repo/rdx-utils";
|
||||
import type { Asset } from "../domain/asset.aggregate";
|
||||
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
|
||||
import type { AssetValues } from "../domain/value-objects/asset-values";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
|
||||
export type InventoryContext = Readonly<{
|
||||
workstations: readonly Workstation[];
|
||||
catalog: AssetCatalog;
|
||||
assets: readonly Asset[];
|
||||
relationships: readonly AssetRelationship[];
|
||||
sites: readonly UniqueID[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
actorNames: ReadonlyMap<string, string>;
|
||||
}>;
|
||||
export interface IAssetRepository {
|
||||
saveWorkstation(
|
||||
companyId: UniqueID,
|
||||
action: (context: InventoryContext) => Result<Workstation, AssetCatalogError>
|
||||
): Promise<
|
||||
Result<{ workstation: Workstation; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>
|
||||
>;
|
||||
get(companyId: UniqueID): Promise<Result<InventoryContext, AssetCatalogError>>;
|
||||
saveRelationship(
|
||||
companyId: UniqueID,
|
||||
action: (context: InventoryContext) => Result<AssetRelationship, AssetCatalogError>
|
||||
): Promise<
|
||||
Result<
|
||||
{ relationship: AssetRelationship; actorNames: ReadonlyMap<string, string> },
|
||||
AssetCatalogError
|
||||
>
|
||||
>;
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import { WorkstationValues } from "../domain/value-objects/workstation-values";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
import type { WorkstationInput } from "../domain/workstation-values";
|
||||
|
||||
import type { InventoryContext } from "./asset.repository";
|
||||
export function mapWorkstationInput(
|
||||
input: WorkstationInput,
|
||||
context: InventoryContext,
|
||||
previous?: Workstation
|
||||
) {
|
||||
const mapped = WorkstationValues.create(input);
|
||||
if (!mapped.isSuccess) {
|
||||
return mapped;
|
||||
}
|
||||
const values = mapped.data.toPrimitive();
|
||||
if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) {
|
||||
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
|
||||
}
|
||||
const person = context.people.find((item) => item.id.toPrimitive() === values.personId);
|
||||
if (
|
||||
values.personId &&
|
||||
(!person || (!person.active && previous?.values.toPrimitive().personId !== values.personId))
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
|
||||
}
|
||||
for (const id of values.assetIds) {
|
||||
const asset = context.assets.find((item) => item.id.toPrimitive() === id);
|
||||
if (!asset) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
if (
|
||||
values.active &&
|
||||
context.workstations.some(
|
||||
(item) =>
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||
item.values.active &&
|
||||
item.values.assetIds.some((assetId) => assetId.toPrimitive() === id)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_IN_WORKSTATION"));
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import type { WorkstationDTO } from "../../common/workstations";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
export function buildWorkstationResponse(
|
||||
item: Workstation,
|
||||
names: ReadonlyMap<string, string>
|
||||
): WorkstationDTO {
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
history: item.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName: names.get(revision.actorId.toPrimitive()) ?? "",
|
||||
recordedAt: revision.recordedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
36
modules/assets/src/api/application/workstation.use-cases.ts
Normal file
36
modules/assets/src/api/application/workstation.use-cases.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import { Workstation } from "../domain/workstation.aggregate";
|
||||
import type { WorkstationInput } from "../domain/workstation-values";
|
||||
|
||||
import type { IAssetRepository } from "./asset.repository";
|
||||
import { mapWorkstationInput } from "./workstation-input.mapper";
|
||||
export class WorkstationUseCases {
|
||||
constructor(private readonly repository: IAssetRepository) {}
|
||||
save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
const identity = id ? UniqueID.create(id) : undefined;
|
||||
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.saveWorkstation(company.data, (context) => {
|
||||
const previous = identity?.isSuccess
|
||||
? context.workstations.find((item) => item.id.equals(identity.data))
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND"));
|
||||
}
|
||||
const values = mapWorkstationInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: Workstation.create(company.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -100,5 +100,14 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
|
||||
});
|
||||
add(`template:${key}`, { kind: "template", name, description: "", fields: references });
|
||||
}
|
||||
for (const [key, name, inverseName, symmetric] of [
|
||||
["connected", "Conectado a", "Conectado a", true],
|
||||
["hosted", "Alojado en", "Aloja", false],
|
||||
["depends", "Depende de", "Es necesario para", false],
|
||||
["powered", "Alimentado por", "Alimenta", false],
|
||||
["installed", "Instalado en", "Tiene instalado", false],
|
||||
] as const) {
|
||||
add(`relation:${key}`, { kind: "relation", name, inverseName, symmetric, description: "" });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@ -24,7 +24,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, 42);
|
||||
assert.equal(catalog.definitions.length, 47);
|
||||
assert.equal(seedAssetCatalog(catalog), catalog);
|
||||
const ids = [
|
||||
catalog.id,
|
||||
@ -58,7 +58,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, 42);
|
||||
assert.equal(catalog.definitions.length, 47);
|
||||
const overrideId = catalog.availability[0]?.id;
|
||||
assert.ok(overrideId);
|
||||
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
|
||||
|
||||
@ -76,6 +76,13 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("FIELD_TYPE_FIXED"));
|
||||
}
|
||||
if (
|
||||
previous.kind === "relation" &&
|
||||
next.kind === "relation" &&
|
||||
previous.symmetric !== next.symmetric
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("RELATION_DIRECTION_FIXED"));
|
||||
}
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
|
||||
@ -18,6 +18,13 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
|
||||
description: z.string().max(1000),
|
||||
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("relation"),
|
||||
name: title,
|
||||
inverseName: title,
|
||||
description: z.string().max(1000),
|
||||
symmetric: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
export type AssetDefinitionSpec = z.infer<typeof AssetDefinitionSchema>;
|
||||
export type AssetFieldType = z.infer<typeof FieldTypeSchema>;
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetCatalogError } from "./asset-definition";
|
||||
import type { AssetRelationshipValues } from "./value-objects/asset-relationship-values";
|
||||
|
||||
type RelationshipProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: AssetRelationshipValues;
|
||||
createdBy: UniqueID;
|
||||
createdAt: string;
|
||||
endedBy: Maybe<UniqueID>;
|
||||
endedAt: Maybe<string>;
|
||||
}>;
|
||||
export class AssetRelationship extends AggregateRoot<RelationshipProps> {
|
||||
private constructor(props: RelationshipProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: AssetRelationshipValues,
|
||||
actor: UniqueID,
|
||||
now: string
|
||||
): Result<AssetRelationship, AssetCatalogError> {
|
||||
if (!z.iso.datetime().safeParse(now).success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new AssetRelationship({
|
||||
companyId,
|
||||
values,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
endedBy: Maybe.none(),
|
||||
endedAt: Maybe.none(),
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: RelationshipProps, id: UniqueID) {
|
||||
return new AssetRelationship(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get createdBy() {
|
||||
return this.props.createdBy;
|
||||
}
|
||||
get createdAt() {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
get endedBy() {
|
||||
return this.props.endedBy;
|
||||
}
|
||||
get endedAt() {
|
||||
return this.props.endedAt;
|
||||
}
|
||||
get active() {
|
||||
return !this.endedAt.getOrUndefined();
|
||||
}
|
||||
end(actor: UniqueID, now: string): Result<AssetRelationship, AssetCatalogError> {
|
||||
if (!this.active) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (!z.iso.datetime().safeParse(now).success || now < this.createdAt) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new AssetRelationship(
|
||||
{ ...this.props, endedBy: Maybe.some(actor), endedAt: Maybe.some(now) },
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
124
modules/assets/src/api/domain/asset-relationship.test.ts
Normal file
124
modules/assets/src/api/domain/asset-relationship.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalog } from "./asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "./asset-catalog-seed";
|
||||
import { AssetRelationship } from "./asset-relationship.aggregate";
|
||||
import { AssetDefinitionValues } from "./value-objects/asset-definition-values";
|
||||
import {
|
||||
AssetRelationshipValues,
|
||||
type RelationshipValuesData,
|
||||
} from "./value-objects/asset-relationship-values";
|
||||
|
||||
const sourceId = UniqueID.generateNewID().toPrimitive();
|
||||
const targetId = UniqueID.generateNewID().toPrimitive();
|
||||
const typeId = UniqueID.generateNewID().toPrimitive();
|
||||
function input(symmetric = false): RelationshipValuesData {
|
||||
return {
|
||||
sourceId,
|
||||
targetId,
|
||||
typeId,
|
||||
name: " Alojado en ",
|
||||
inverseName: " Aloja ",
|
||||
symmetric,
|
||||
notes: " rack 1 ",
|
||||
};
|
||||
}
|
||||
function values(data = input()) {
|
||||
const result = AssetRelationshipValues.create(data);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("Las relaciones validan sus extremos, normalizan nombres y distinguen dirección", () => {
|
||||
const first = values();
|
||||
assert.equal(first.toPrimitive().name, "Alojado en");
|
||||
assert.equal(first.notes, "rack 1");
|
||||
assert.notEqual(
|
||||
first.key(),
|
||||
values({ ...input(), sourceId: targetId, targetId: sourceId }).key()
|
||||
);
|
||||
const symmetric = values(input(true));
|
||||
assert.equal(
|
||||
symmetric.key(),
|
||||
values({ ...input(true), sourceId: targetId, targetId: sourceId }).key()
|
||||
);
|
||||
assert.equal(symmetric.toPrimitive().inverseName, "Alojado en");
|
||||
const self = AssetRelationshipValues.create({ ...input(), targetId: sourceId });
|
||||
assert.ok(!self.isSuccess);
|
||||
assert.equal(self.error.code, "SELF_RELATIONSHIP");
|
||||
assert.ok(!AssetRelationshipValues.create({ ...input(), targetId: "bad" }).isSuccess);
|
||||
assert.ok(!AssetRelationshipValues.create({ ...input(), inverseName: " " }).isSuccess);
|
||||
assert.ok(!AssetRelationshipValues.create({ ...input(), notes: "x".repeat(10001) }).isSuccess);
|
||||
assert.ok(Object.isFrozen(first));
|
||||
});
|
||||
test("Retirar conserva UUID, extremos y auditoría; repetir la retirada no modifica el historial", () => {
|
||||
const company = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const otherActor = UniqueID.generateNewID();
|
||||
const created = AssetRelationship.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
|
||||
assert.ok(created.isSuccess);
|
||||
const link = created.data;
|
||||
assert.equal(link.id.toPrimitive().charAt(14), "7");
|
||||
assert.ok(link.active);
|
||||
assert.ok(!link.end(otherActor, "2026-10-03T10:00:00.000Z").isSuccess);
|
||||
const ended = link.end(otherActor, "2026-10-04T11:00:00.000Z");
|
||||
assert.ok(ended.isSuccess);
|
||||
assert.equal(ended.data.active, false);
|
||||
assert.ok(ended.data.id.equals(link.id));
|
||||
assert.equal(ended.data.values, link.values);
|
||||
assert.ok(ended.data.createdBy.equals(actor));
|
||||
assert.ok(ended.data.endedBy.getOrUndefined()?.equals(otherActor));
|
||||
const retry = ended.data.end(actor, "2026-10-04T12:00:00.000Z");
|
||||
assert.ok(retry.isSuccess);
|
||||
assert.equal(retry.data, ended.data);
|
||||
assert.ok(link.active);
|
||||
});
|
||||
test("Tipos de relación de serie protegidos, ámbito de compañía y dirección fija", () => {
|
||||
const scope = Maybe.some(UniqueID.generateNewID());
|
||||
const other = Maybe.some(UniqueID.generateNewID());
|
||||
let catalog = seedAssetCatalog(AssetCatalog.create());
|
||||
const stock = catalog.definitions.find((item) => item.values.data.kind === "relation");
|
||||
assert.ok(stock);
|
||||
const spec = {
|
||||
kind: "relation",
|
||||
name: "Conectado a",
|
||||
inverseName: "Conectado a",
|
||||
description: "",
|
||||
symmetric: true,
|
||||
} as const;
|
||||
const custom = AssetDefinitionValues.create({
|
||||
...spec,
|
||||
name: "Replica a",
|
||||
inverseName: "Replicado desde",
|
||||
symmetric: false,
|
||||
});
|
||||
assert.ok(custom.isSuccess);
|
||||
const protectedChange = catalog.save(Maybe.none(), Maybe.some(stock.id), custom.data);
|
||||
assert.ok(!protectedChange.isSuccess);
|
||||
assert.equal(protectedChange.error.code, "BUILTIN_PROTECTED");
|
||||
const status = catalog.setActive(scope, stock.id, false);
|
||||
assert.ok(status.isSuccess);
|
||||
catalog = status.data;
|
||||
assert.equal(catalog.active(stock, scope), false);
|
||||
assert.equal(catalog.active(stock, other), true);
|
||||
const added = catalog.save(scope, Maybe.none(), custom.data);
|
||||
assert.ok(added.isSuccess);
|
||||
catalog = added.data;
|
||||
const item = catalog.definitions.at(-1);
|
||||
assert.ok(item);
|
||||
assert.equal(
|
||||
catalog.visible(other).some((entry) => entry.id.equals(item.id)),
|
||||
false
|
||||
);
|
||||
const converted = AssetDefinitionValues.create({ ...spec, name: "Replica a" });
|
||||
assert.ok(converted.isSuccess);
|
||||
const rejected = catalog.save(scope, Maybe.some(item.id), converted.data);
|
||||
assert.ok(!rejected.isSuccess);
|
||||
assert.equal(rejected.error.code, "RELATION_DIRECTION_FIXED");
|
||||
const unchanged = catalog.save(scope, Maybe.some(item.id), custom.data);
|
||||
assert.ok(unchanged.isSuccess);
|
||||
assert.equal(unchanged.data, catalog);
|
||||
});
|
||||
@ -41,6 +41,9 @@ export class AssetDefinitionValues {
|
||||
Object.freeze(values);
|
||||
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
|
||||
}
|
||||
if (data.kind === "relation") {
|
||||
return AssetDefinitionValues.relation(data, name.data);
|
||||
}
|
||||
const ids: UniqueID[] = [];
|
||||
for (const field of data.fields) {
|
||||
const id = UniqueID.create(field.fieldId);
|
||||
@ -68,6 +71,24 @@ export class AssetDefinitionValues {
|
||||
)
|
||||
);
|
||||
}
|
||||
private static relation(data: Extract<AssetDefinitionSpec, { kind: "relation" }>, name: Name) {
|
||||
const inverse = Name.create(data.symmetric ? data.name : data.inverseName, 100);
|
||||
if (!inverse.isSuccess) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new AssetDefinitionValues(
|
||||
Object.freeze({
|
||||
...data,
|
||||
name: name.toPrimitive(),
|
||||
inverseName: inverse.data.toPrimitive(),
|
||||
description: data.description.trim(),
|
||||
}),
|
||||
name,
|
||||
Object.freeze([])
|
||||
)
|
||||
);
|
||||
}
|
||||
equals(other: AssetDefinitionValues): boolean {
|
||||
const a = this.data;
|
||||
const b = other.data;
|
||||
@ -82,6 +103,9 @@ export class AssetDefinitionValues {
|
||||
a.choices.every((choice, index) => choice === b.choices[index])
|
||||
);
|
||||
}
|
||||
if (a.kind === "relation" && b.kind === "relation") {
|
||||
return a.inverseName === b.inverseName && a.symmetric === b.symmetric;
|
||||
}
|
||||
return (
|
||||
a.kind === "template" &&
|
||||
b.kind === "template" &&
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetCatalogError } from "../asset-definition";
|
||||
|
||||
export const RelationshipValuesSchema = z.strictObject({
|
||||
sourceId: z.uuid(),
|
||||
targetId: z.uuid(),
|
||||
typeId: z.uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
inverseName: z.string().min(1).max(100),
|
||||
symmetric: z.boolean(),
|
||||
notes: z.string().max(10000),
|
||||
});
|
||||
export type RelationshipValuesData = z.infer<typeof RelationshipValuesSchema>;
|
||||
|
||||
export class AssetRelationshipValues {
|
||||
private constructor(
|
||||
public readonly sourceId: UniqueID,
|
||||
public readonly targetId: UniqueID,
|
||||
public readonly typeId: UniqueID,
|
||||
public readonly name: Name,
|
||||
public readonly inverseName: Name,
|
||||
public readonly symmetric: boolean,
|
||||
public readonly notes: string
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: RelationshipValuesData): Result<AssetRelationshipValues, AssetCatalogError> {
|
||||
const parsed = RelationshipValuesSchema.safeParse(input);
|
||||
const source = UniqueID.create(input.sourceId);
|
||||
const target = UniqueID.create(input.targetId);
|
||||
const type = UniqueID.create(input.typeId);
|
||||
const name = Name.create(input.name, 100);
|
||||
const inverse = Name.create(input.symmetric ? input.name : input.inverseName, 100);
|
||||
if (
|
||||
!(
|
||||
parsed.success &&
|
||||
source.isSuccess &&
|
||||
target.isSuccess &&
|
||||
type.isSuccess &&
|
||||
name.isSuccess &&
|
||||
inverse.isSuccess
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
if (source.data.equals(target.data)) {
|
||||
return Result.fail(new AssetCatalogError("SELF_RELATIONSHIP"));
|
||||
}
|
||||
return Result.ok(
|
||||
new AssetRelationshipValues(
|
||||
source.data,
|
||||
target.data,
|
||||
type.data,
|
||||
name.data,
|
||||
inverse.data,
|
||||
input.symmetric,
|
||||
input.notes.trim()
|
||||
)
|
||||
);
|
||||
}
|
||||
toPrimitive(): RelationshipValuesData {
|
||||
return {
|
||||
sourceId: this.sourceId.toPrimitive(),
|
||||
targetId: this.targetId.toPrimitive(),
|
||||
typeId: this.typeId.toPrimitive(),
|
||||
name: this.name.toPrimitive(),
|
||||
inverseName: this.inverseName.toPrimitive(),
|
||||
symmetric: this.symmetric,
|
||||
notes: this.notes,
|
||||
};
|
||||
}
|
||||
equals(other: AssetRelationshipValues): boolean {
|
||||
return (
|
||||
this.key() === other.key() &&
|
||||
this.name.equals(other.name) &&
|
||||
this.inverseName.equals(other.inverseName) &&
|
||||
this.symmetric === other.symmetric &&
|
||||
this.notes === other.notes
|
||||
);
|
||||
}
|
||||
/** Identifica un vínculo; las relaciones simétricas normalizan el orden de sus extremos. */
|
||||
key(): string {
|
||||
const ids = [this.sourceId.toPrimitive(), this.targetId.toPrimitive()];
|
||||
if (this.symmetric) {
|
||||
ids.sort();
|
||||
}
|
||||
return [this.typeId.toPrimitive(), ...ids].join(":");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../asset-definition";
|
||||
import { type WorkstationInput, WorkstationInputSchema } from "../workstation-values";
|
||||
export class WorkstationValues {
|
||||
private constructor(
|
||||
public readonly name: Name,
|
||||
public readonly active: boolean,
|
||||
public readonly siteId: Maybe<UniqueID>,
|
||||
public readonly personId: Maybe<UniqueID>,
|
||||
public readonly assetIds: readonly UniqueID[]
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: WorkstationInput): Result<WorkstationValues, AssetCatalogError> {
|
||||
const parsed = WorkstationInputSchema.safeParse(input);
|
||||
const name = Name.create(input.name, 100);
|
||||
const site = optionalIdentity(input.siteId);
|
||||
const person = optionalIdentity(input.personId);
|
||||
if (!(parsed.success && name.isSuccess && site.isSuccess && person.isSuccess)) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const ids: UniqueID[] = [];
|
||||
for (const value of parsed.data.assetIds) {
|
||||
const id = UniqueID.create(value);
|
||||
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
|
||||
}
|
||||
ids.push(id.data);
|
||||
}
|
||||
return Result.ok(
|
||||
new WorkstationValues(name.data, input.active, site.data, person.data, Object.freeze(ids))
|
||||
);
|
||||
}
|
||||
toPrimitive(): WorkstationInput {
|
||||
return {
|
||||
name: this.name.toPrimitive(),
|
||||
active: this.active,
|
||||
siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
personId: this.personId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
assetIds: this.assetIds.map((id) => id.toPrimitive()),
|
||||
};
|
||||
}
|
||||
equals(other: WorkstationValues) {
|
||||
const a = this.toPrimitive();
|
||||
const b = other.toPrimitive();
|
||||
return (
|
||||
a.name === b.name &&
|
||||
a.active === b.active &&
|
||||
a.siteId === b.siteId &&
|
||||
a.personId === b.personId &&
|
||||
a.assetIds.length === b.assetIds.length &&
|
||||
a.assetIds.every((id) => b.assetIds.includes(id))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function optionalIdentity(value: string | null): Result<Maybe<UniqueID>, AssetCatalogError> {
|
||||
if (!value) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
const id = UniqueID.create(value);
|
||||
return id.isSuccess
|
||||
? Result.ok(Maybe.some(id.data))
|
||||
: Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
25
modules/assets/src/api/domain/workstation-revision.entity.ts
Normal file
25
modules/assets/src/api/domain/workstation-revision.entity.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { WorkstationValues } from "./value-objects/workstation-values";
|
||||
export type WorkstationRevisionProps = Readonly<{
|
||||
values: WorkstationValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class WorkstationRevision extends DomainEntity<WorkstationRevisionProps> {
|
||||
static create(props: WorkstationRevisionProps) {
|
||||
return new WorkstationRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: WorkstationRevisionProps, id: UniqueID) {
|
||||
return new WorkstationRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
9
modules/assets/src/api/domain/workstation-values.ts
Normal file
9
modules/assets/src/api/domain/workstation-values.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
export const WorkstationInputSchema = z.strictObject({
|
||||
name: z.string().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
siteId: z.uuid().nullable(),
|
||||
personId: z.uuid().nullable(),
|
||||
assetIds: z.array(z.uuid()).max(200),
|
||||
});
|
||||
export type WorkstationInput = z.infer<typeof WorkstationInputSchema>;
|
||||
72
modules/assets/src/api/domain/workstation.aggregate.ts
Normal file
72
modules/assets/src/api/domain/workstation.aggregate.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetCatalogError } from "./asset-definition";
|
||||
import type { WorkstationValues } from "./value-objects/workstation-values";
|
||||
import { WorkstationRevision } from "./workstation-revision.entity";
|
||||
|
||||
type WorkstationProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: WorkstationValues;
|
||||
history: readonly WorkstationRevision[];
|
||||
}>;
|
||||
export class Workstation extends AggregateRoot<WorkstationProps> {
|
||||
private constructor(props: WorkstationProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: WorkstationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<Workstation, AssetCatalogError> {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Workstation({
|
||||
companyId,
|
||||
values,
|
||||
history: [WorkstationRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: WorkstationProps, id: UniqueID) {
|
||||
return new Workstation(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: WorkstationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<Workstation, AssetCatalogError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Workstation(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [...this.history, WorkstationRevision.create({ values, actorId, recordedAt })],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
72
modules/assets/src/api/domain/workstation.test.ts
Normal file
72
modules/assets/src/api/domain/workstation.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import { WorkstationValues } from "./value-objects/workstation-values";
|
||||
import { Workstation } from "./workstation.aggregate";
|
||||
import type { WorkstationInput } from "./workstation-values";
|
||||
|
||||
const assetIds = [UniqueID.generateNewID().toPrimitive(), UniqueID.generateNewID().toPrimitive()];
|
||||
function input(): WorkstationInput {
|
||||
return { name: " Puesto Ana ", active: true, siteId: null, personId: null, assetIds };
|
||||
}
|
||||
function values(data = input()) {
|
||||
const result = WorkstationValues.create(data);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("Puestos: composición opcional, referencias válidas y límites sin duplicados", () => {
|
||||
assert.ok(WorkstationValues.create({ ...input(), assetIds: [] }).isSuccess);
|
||||
assert.equal(values().name.toPrimitive(), "Puesto Ana");
|
||||
assert.ok(Object.isFrozen(values().assetIds));
|
||||
assert.ok(!WorkstationValues.create({ ...input(), name: " " }).isSuccess);
|
||||
assert.ok(!WorkstationValues.create({ ...input(), personId: "invalid" }).isSuccess);
|
||||
const duplicate = WorkstationValues.create({
|
||||
...input(),
|
||||
assetIds: [assetIds[0] ?? "", assetIds[0] ?? ""],
|
||||
});
|
||||
assert.ok(!duplicate.isSuccess);
|
||||
assert.equal(duplicate.error.code, "INVALID_MEMBERS");
|
||||
assert.ok(
|
||||
!WorkstationValues.create({
|
||||
...input(),
|
||||
assetIds: Array.from({ length: 201 }, () => UniqueID.generateNewID().toPrimitive()),
|
||||
}).isSuccess
|
||||
);
|
||||
assert.ok(values().equals(values({ ...input(), assetIds: [...assetIds].reverse() })));
|
||||
});
|
||||
test("Puestos: UUID estable e historial de composición, persona y estado; guardado idéntico idempotente", () => {
|
||||
const company = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const created = Workstation.create(company, values(), actor, "2026-10-04T10:00:00.000Z");
|
||||
assert.ok(created.isSuccess);
|
||||
const item = created.data;
|
||||
assert.equal(item.id.toPrimitive().charAt(14), "7");
|
||||
assert.equal(item.history[0]?.id.toPrimitive().charAt(14), "7");
|
||||
const same = item.update(
|
||||
values({ ...input(), assetIds: [...assetIds].reverse() }),
|
||||
actor,
|
||||
"2026-10-04T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, item);
|
||||
const changed = item.update(
|
||||
values({ ...input(), personId: actor.toPrimitive(), active: false, assetIds: [] }),
|
||||
actor,
|
||||
"2026-10-04T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.ok(changed.data.id.equals(item.id));
|
||||
assert.equal(changed.data.history.length, 2);
|
||||
assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null);
|
||||
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().assetIds, assetIds);
|
||||
assert.ok(!changed.data.update(values(), actor, "2026-10-03T10:00:00.000Z").isSuccess);
|
||||
assert.equal(
|
||||
Workstation.rehydrate(
|
||||
{ companyId: company, values: changed.data.values, history: changed.data.history },
|
||||
changed.data.id
|
||||
).id.toPrimitive(),
|
||||
item.id.toPrimitive()
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory";
|
||||
import { RelationshipRequestSchema } from "../../../common/relationships";
|
||||
import type { AssetRelationshipUseCases } from "../../application/asset-relationship.use-cases";
|
||||
import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
export function registerRelationshipRoutes(
|
||||
app: Application,
|
||||
useCases: AssetRelationshipUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.post("/:companyId/relationships", (request, response, next) => {
|
||||
const params = InventoryParamsSchema.safeParse(request.params);
|
||||
const body = RelationshipRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
body.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.save(params.data.companyId, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(201).json({
|
||||
relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
router.post("/:companyId/relationships/:id/end", (request, response, next) => {
|
||||
const params = AssetParamsSchema.safeParse(request.params);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.body).success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.end(params.data.companyId, params.data.id, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
relationship: buildRelationshipResponse(result.data.relationship, result.data.actorNames),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/assets", authenticate, router);
|
||||
}
|
||||
@ -8,7 +8,9 @@ import {
|
||||
SaveAssetRequestSchema,
|
||||
} from "../../../common/inventory";
|
||||
import type { AssetUseCases } from "../../application/asset.use-cases";
|
||||
import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder";
|
||||
import { buildAssetResponse } from "../../application/asset-response.builder";
|
||||
import { buildWorkstationResponse } from "../../application/workstation-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
@ -35,6 +37,12 @@ export function registerAssetRoutes(
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
workstations: result.data.workstations.map((item) =>
|
||||
buildWorkstationResponse(item, result.data.actorNames)
|
||||
),
|
||||
relationships: result.data.relationships.map((item) =>
|
||||
buildRelationshipResponse(item, result.data.actorNames)
|
||||
),
|
||||
assets: result.data.assets.map((asset) =>
|
||||
buildAssetResponse(asset, result.data.actorNames)
|
||||
),
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetParamsSchema, InventoryParamsSchema } from "../../../common/inventory";
|
||||
import { SaveWorkstationRequestSchema } from "../../../common/workstations";
|
||||
import type { WorkstationUseCases } from "../../application/workstation.use-cases";
|
||||
import { buildWorkstationResponse } from "../../application/workstation-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
export function registerWorkstationRoutes(
|
||||
app: Application,
|
||||
useCases: WorkstationUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
for (const path of ["/:companyId/workstations", "/:companyId/workstations/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (
|
||||
path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema
|
||||
).safeParse(request.params);
|
||||
const body = SaveWorkstationRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
body.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.save(params.data.companyId, id, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({
|
||||
workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/assets", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetRelationship } from "../../domain/asset-relationship.aggregate";
|
||||
import {
|
||||
AssetRelationshipValues,
|
||||
RelationshipValuesSchema,
|
||||
} from "../../domain/value-objects/asset-relationship-values";
|
||||
|
||||
import { inventoryIdentity } from "./asset-persistence.mapper";
|
||||
import type { RelationshipModel } from "./asset-relationship.model";
|
||||
|
||||
export class AssetRelationshipPersistenceMapper {
|
||||
toDomain(row: RelationshipModel) {
|
||||
const values = AssetRelationshipValues.create(RelationshipValuesSchema.parse(row.values));
|
||||
if (
|
||||
!(values.isSuccess && z.iso.datetime().safeParse(row.createdAt).success) ||
|
||||
(row.endedAt !== null &&
|
||||
(!z.iso.datetime().safeParse(row.endedAt).success || row.endedAt < row.createdAt)) ||
|
||||
(row.endedAt === null) !== (row.endedBy === null)
|
||||
) {
|
||||
throw new Error("Relación de activos persistida inconsistente.");
|
||||
}
|
||||
const item = AssetRelationship.rehydrate(
|
||||
{
|
||||
companyId: inventoryIdentity(row.companyId),
|
||||
values: values.data,
|
||||
createdBy: inventoryIdentity(row.createdBy),
|
||||
createdAt: row.createdAt,
|
||||
endedBy: row.endedBy ? Maybe.some(inventoryIdentity(row.endedBy)) : Maybe.none(),
|
||||
endedAt: row.endedAt ? Maybe.some(row.endedAt) : Maybe.none(),
|
||||
},
|
||||
inventoryIdentity(row.id)
|
||||
);
|
||||
if (
|
||||
row.sourceId !== item.values.sourceId.toPrimitive() ||
|
||||
row.targetId !== item.values.targetId.toPrimitive() ||
|
||||
row.typeId !== item.values.typeId.toPrimitive() ||
|
||||
row.activeKey !== (item.active ? `${row.companyId}:${item.values.key()}` : null)
|
||||
) {
|
||||
throw new Error("Referencias de relación persistidas inconsistentes.");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
toPersistence(item: AssetRelationship) {
|
||||
const values = item.values.toPrimitive();
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
sourceId: values.sourceId,
|
||||
targetId: values.targetId,
|
||||
typeId: values.typeId,
|
||||
values,
|
||||
createdBy: item.createdBy.toPrimitive(),
|
||||
createdAt: item.createdAt,
|
||||
endedBy: item.endedBy.getOrUndefined()?.toPrimitive() ?? null,
|
||||
endedAt: item.endedAt.getOrUndefined() ?? null,
|
||||
activeKey: item.active ? `${item.companyId.toPrimitive()}:${item.values.key()}` : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||
import { DataTypes, Model, QueryTypes } from "sequelize";
|
||||
export class RelationshipModel extends Model<
|
||||
InferAttributes<RelationshipModel>,
|
||||
InferCreationAttributes<RelationshipModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare companyId: string;
|
||||
declare sourceId: string;
|
||||
declare targetId: string;
|
||||
declare typeId: string;
|
||||
declare values: unknown;
|
||||
declare createdBy: string;
|
||||
declare createdAt: string;
|
||||
declare endedBy: string | null;
|
||||
declare endedAt: string | null;
|
||||
declare activeKey: string | null;
|
||||
}
|
||||
export async function migrateRelationships(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const fk = (model: string, nullable = false) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: nullable,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
const columns = {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
sourceId: fk("assets"),
|
||||
targetId: fk("assets"),
|
||||
typeId: fk("asset_definitions"),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
createdBy: fk("users"),
|
||||
createdAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||
endedBy: fk("users", true),
|
||||
endedAt: { type: DataTypes.STRING(30), allowNull: true },
|
||||
activeKey: { type: DataTypes.STRING(150), allowNull: true, unique: true },
|
||||
};
|
||||
if (!tables.some((table) => table.name === "asset_relationships")) {
|
||||
await query.createTable("asset_relationships", columns);
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((index) => index.name === "assets_company_identity")) {
|
||||
await query.addIndex("assets", ["companyId", "id"], {
|
||||
unique: true,
|
||||
name: "assets_company_identity",
|
||||
});
|
||||
}
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='asset_relationships'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
// Las FK compuestas impiden vínculos cruzados también ante escrituras externas al dominio.
|
||||
for (const [column, name] of [
|
||||
["sourceId", "relationship_source_company"],
|
||||
["targetId", "relationship_target_company"],
|
||||
] as const) {
|
||||
if (!constraints.some((constraint) => constraint.name === name)) {
|
||||
await database.query(
|
||||
`ALTER TABLE asset_relationships ADD CONSTRAINT \`${name}\` FOREIGN KEY (companyId, \`${column}\`) REFERENCES assets(companyId,id)`
|
||||
);
|
||||
}
|
||||
}
|
||||
RelationshipModel.init(columns, {
|
||||
sequelize: database,
|
||||
tableName: "asset_relationships",
|
||||
timestamps: false,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Transaction } from "sequelize";
|
||||
|
||||
import type { Asset } from "../../domain/asset.aggregate";
|
||||
|
||||
import {
|
||||
WorkstationAssetModel,
|
||||
WorkstationModel,
|
||||
WorkstationRevisionModel,
|
||||
} from "./workstation.model";
|
||||
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
||||
export async function loadWorkstations(
|
||||
companyId: UniqueID,
|
||||
transaction: Transaction,
|
||||
assets: readonly Asset[],
|
||||
sites: readonly { id: string }[],
|
||||
people: readonly { id: string }[]
|
||||
) {
|
||||
const rows = await WorkstationModel.findAll({
|
||||
where: { companyId: companyId.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
const revisions = rows.length
|
||||
? await WorkstationRevisionModel.findAll({
|
||||
where: { workstationId: rows.map((item) => item.id) },
|
||||
transaction,
|
||||
})
|
||||
: [];
|
||||
const references = await WorkstationAssetModel.findAll({
|
||||
where: { companyId: companyId.toPrimitive() },
|
||||
transaction,
|
||||
});
|
||||
return rows.map((row) => {
|
||||
const item = new WorkstationPersistenceMapper().toDomain(
|
||||
row,
|
||||
revisions.filter((revision) => revision.workstationId === row.id),
|
||||
references.filter((reference) => reference.workstationId === row.id)
|
||||
);
|
||||
for (const revision of item.history) {
|
||||
const values = revision.values.toPrimitive();
|
||||
if (
|
||||
(values.siteId && !sites.some((site) => site.id === values.siteId)) ||
|
||||
(values.personId && !people.some((person) => person.id === values.personId)) ||
|
||||
values.assetIds.some((id) => !assets.some((asset) => asset.id.toPrimitive() === id))
|
||||
) {
|
||||
throw new Error("Puesto de trabajo fuera de su compañía.");
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
|
||||
import { AssetCatalogError } from "../../domain/asset-definition";
|
||||
import type { Workstation } from "../../domain/workstation.aggregate";
|
||||
|
||||
import { AssetModel, AssetRevisionModel } from "./asset.model";
|
||||
import {
|
||||
@ -15,6 +16,15 @@ import {
|
||||
} from "./asset-catalog.model";
|
||||
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
|
||||
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
|
||||
import { RelationshipModel } from "./asset-relationship.model";
|
||||
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
||||
import { loadWorkstations } from "./load-workstations";
|
||||
import {
|
||||
WorkstationAssetModel,
|
||||
WorkstationModel,
|
||||
WorkstationRevisionModel,
|
||||
} from "./workstation.model";
|
||||
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
||||
export class SequelizeAssetRepository implements IAssetRepository {
|
||||
private readonly mapper = new AssetPersistenceMapper();
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
@ -106,7 +116,34 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
const relationships = (
|
||||
await RelationshipModel.findAll({
|
||||
where: { companyId: companyId.toPrimitive() },
|
||||
transaction,
|
||||
})
|
||||
).map((row) => new AssetRelationshipPersistenceMapper().toDomain(row));
|
||||
for (const relationship of relationships) {
|
||||
const values = relationship.values;
|
||||
const definition = catalog
|
||||
.visible(Maybe.some(companyId))
|
||||
.find((item) => item.id.equals(values.typeId));
|
||||
if (
|
||||
!(
|
||||
definition &&
|
||||
definition.values.data.kind === "relation" &&
|
||||
definition.values.data.symmetric === values.symmetric &&
|
||||
[values.sourceId, values.targetId].every((id) =>
|
||||
assets.some((asset) => asset.id.equals(id))
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error("Relación de activos fuera de su compañía.");
|
||||
}
|
||||
}
|
||||
const workstations = await loadWorkstations(companyId, transaction, assets, sites, people);
|
||||
return Result.ok({
|
||||
workstations,
|
||||
relationships,
|
||||
catalog,
|
||||
assets,
|
||||
sites: sites.map((site) => inventoryIdentity(site.id)),
|
||||
@ -117,6 +154,63 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||
});
|
||||
}
|
||||
saveWorkstation(companyId: UniqueID, action: Parameters<IAssetRepository["saveWorkstation"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(companyId, transaction, true);
|
||||
if (!loaded.isSuccess) {
|
||||
return Result.fail(loaded.error);
|
||||
}
|
||||
const changed = action(loaded.data);
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
const previous = loaded.data.workstations.find((item) => item.id.equals(changed.data.id));
|
||||
if (previous !== changed.data) {
|
||||
await this.persistWorkstation(changed.data, previous, transaction);
|
||||
}
|
||||
return Result.ok({ workstation: changed.data, actorNames: loaded.data.actorNames });
|
||||
});
|
||||
}
|
||||
private async persistWorkstation(
|
||||
item: Workstation,
|
||||
previous: Workstation | undefined,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const rows = new WorkstationPersistenceMapper().toPersistence(item);
|
||||
await WorkstationModel.upsert(rows.workstation, { transaction });
|
||||
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()));
|
||||
for (const revision of rows.revisions) {
|
||||
if (!existing.has(revision.id)) {
|
||||
await WorkstationRevisionModel.create(revision, { transaction });
|
||||
}
|
||||
}
|
||||
for (const reference of rows.references) {
|
||||
await WorkstationAssetModel.upsert(reference, { transaction });
|
||||
}
|
||||
}
|
||||
saveRelationship(
|
||||
companyId: UniqueID,
|
||||
action: Parameters<IAssetRepository["saveRelationship"]>[1]
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(companyId, transaction, true);
|
||||
if (!loaded.isSuccess) {
|
||||
return Result.fail(loaded.error);
|
||||
}
|
||||
const changed = action(loaded.data);
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
const previous = loaded.data.relationships.find((item) => item.id.equals(changed.data.id));
|
||||
if (previous !== changed.data) {
|
||||
await RelationshipModel.upsert(
|
||||
new AssetRelationshipPersistenceMapper().toPersistence(changed.data),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
return Result.ok({ relationship: changed.data, actorNames: loaded.data.actorNames });
|
||||
});
|
||||
}
|
||||
get(companyId: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
||||
}
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { WorkstationValues } from "../../domain/value-objects/workstation-values";
|
||||
import { Workstation } from "../../domain/workstation.aggregate";
|
||||
import { WorkstationRevision } from "../../domain/workstation-revision.entity";
|
||||
import { WorkstationInputSchema } from "../../domain/workstation-values";
|
||||
|
||||
import { inventoryIdentity } from "./asset-persistence.mapper";
|
||||
import type {
|
||||
WorkstationAssetModel,
|
||||
WorkstationModel,
|
||||
WorkstationRevisionModel,
|
||||
} from "./workstation.model";
|
||||
|
||||
function valuesOf(raw: unknown, siteId: string | null, personId: string | null) {
|
||||
const values = WorkstationValues.create(WorkstationInputSchema.parse(raw));
|
||||
if (
|
||||
!values.isSuccess ||
|
||||
values.data.toPrimitive().siteId !== siteId ||
|
||||
values.data.toPrimitive().personId !== personId
|
||||
) {
|
||||
throw new Error("Valores de puesto persistidos inconsistentes.");
|
||||
}
|
||||
return values.data;
|
||||
}
|
||||
export class WorkstationPersistenceMapper {
|
||||
toDomain(
|
||||
row: WorkstationModel,
|
||||
revisions: readonly WorkstationRevisionModel[],
|
||||
references: readonly WorkstationAssetModel[]
|
||||
) {
|
||||
const values = valuesOf(row.values, row.siteId, row.personId);
|
||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||
const history = ordered.map((revision, position) => {
|
||||
if (
|
||||
revision.position !== position ||
|
||||
!z.iso.datetime().safeParse(revision.recordedAt).success ||
|
||||
revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "")
|
||||
) {
|
||||
throw new Error("Historial de puesto inconsistente.");
|
||||
}
|
||||
return WorkstationRevision.rehydrate(
|
||||
{
|
||||
values: valuesOf(revision.values, revision.siteId, revision.personId),
|
||||
actorId: inventoryIdentity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
inventoryIdentity(revision.id)
|
||||
);
|
||||
});
|
||||
if (!(history.length && history.at(-1)?.values.equals(values))) {
|
||||
throw new Error("Última revisión de puesto inconsistente.");
|
||||
}
|
||||
const expected = new Set(history.flatMap((item) => item.values.toPrimitive().assetIds));
|
||||
if (
|
||||
references.length !== expected.size ||
|
||||
references.some(
|
||||
(item) =>
|
||||
item.companyId !== row.companyId ||
|
||||
!expected.has(item.assetId) ||
|
||||
item.activeKey !==
|
||||
(values.active && values.assetIds.some((id) => id.toPrimitive() === item.assetId)
|
||||
? `${row.companyId}:${item.assetId}`
|
||||
: null)
|
||||
)
|
||||
) {
|
||||
throw new Error("Referencias de puesto inconsistentes.");
|
||||
}
|
||||
return Workstation.rehydrate(
|
||||
{ companyId: inventoryIdentity(row.companyId), values, history },
|
||||
inventoryIdentity(row.id)
|
||||
);
|
||||
}
|
||||
toPersistence(item: Workstation) {
|
||||
const values = item.values.toPrimitive();
|
||||
const companyId = item.companyId.toPrimitive();
|
||||
const id = item.id.toPrimitive();
|
||||
return {
|
||||
workstation: { id, companyId, values, siteId: values.siteId, personId: values.personId },
|
||||
revisions: item.history.map((revision, position) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
workstationId: id,
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
values: revision.values.toPrimitive(),
|
||||
position,
|
||||
siteId: revision.values.toPrimitive().siteId,
|
||||
personId: revision.values.toPrimitive().personId,
|
||||
})),
|
||||
references: [
|
||||
...new Set(item.history.flatMap((revision) => revision.values.toPrimitive().assetIds)),
|
||||
].map((assetId) => ({
|
||||
workstationId: id,
|
||||
companyId,
|
||||
assetId,
|
||||
activeKey:
|
||||
values.active && values.assetIds.includes(assetId) ? `${companyId}:${assetId}` : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
|
||||
import { DataTypes, Model, QueryTypes } from "sequelize";
|
||||
export class WorkstationModel extends Model<
|
||||
InferAttributes<WorkstationModel>,
|
||||
InferCreationAttributes<WorkstationModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare companyId: string;
|
||||
declare siteId: string | null;
|
||||
declare personId: string | null;
|
||||
declare values: unknown;
|
||||
}
|
||||
export class WorkstationRevisionModel extends Model<
|
||||
InferAttributes<WorkstationRevisionModel>,
|
||||
InferCreationAttributes<WorkstationRevisionModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare workstationId: string;
|
||||
declare actorId: string;
|
||||
declare recordedAt: string;
|
||||
declare values: unknown;
|
||||
declare position: number;
|
||||
declare siteId: string | null;
|
||||
declare personId: string | null;
|
||||
}
|
||||
/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */
|
||||
export class WorkstationAssetModel extends Model<
|
||||
InferAttributes<WorkstationAssetModel>,
|
||||
InferCreationAttributes<WorkstationAssetModel>
|
||||
> {
|
||||
declare workstationId: string;
|
||||
declare companyId: string;
|
||||
declare assetId: string;
|
||||
declare activeKey: string | null;
|
||||
}
|
||||
export async function migrateWorkstations(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const fk = (model: string, nullable = false) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: nullable,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
const root = {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
siteId: fk("company_sites", true),
|
||||
personId: fk("company_contacts", true),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
};
|
||||
const revision = {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
workstationId: fk("workstations"),
|
||||
actorId: fk("users"),
|
||||
siteId: fk("company_sites", true),
|
||||
personId: fk("company_contacts", true),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
};
|
||||
const reference = {
|
||||
workstationId: { ...fk("workstations"), primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
assetId: { ...fk("assets"), primaryKey: true },
|
||||
activeKey: { type: DataTypes.STRING(80), unique: true, allowNull: true },
|
||||
};
|
||||
for (const [table, columns] of [
|
||||
["workstations", root],
|
||||
["workstation_revisions", revision],
|
||||
["workstation_assets", reference],
|
||||
] as const) {
|
||||
if (!tables.some((item) => item.name === table)) {
|
||||
await query.createTable(table, columns);
|
||||
}
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstations'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "workstations_company_identity")) {
|
||||
await query.addIndex("workstations", ["companyId", "id"], {
|
||||
name: "workstations_company_identity",
|
||||
unique: true,
|
||||
});
|
||||
}
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='workstation_assets'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!constraints.some((item) => item.name === "workstation_asset_company")) {
|
||||
await database.query(
|
||||
"ALTER TABLE workstation_assets ADD CONSTRAINT workstation_asset_company FOREIGN KEY (companyId, assetId) REFERENCES assets(companyId,id)"
|
||||
);
|
||||
}
|
||||
if (!constraints.some((item) => item.name === "workstation_owner_company")) {
|
||||
await database.query(
|
||||
"ALTER TABLE workstation_assets ADD CONSTRAINT workstation_owner_company FOREIGN KEY (companyId, workstationId) REFERENCES workstations(companyId,id)"
|
||||
);
|
||||
}
|
||||
WorkstationModel.init(root, {
|
||||
sequelize: database,
|
||||
tableName: "workstations",
|
||||
timestamps: false,
|
||||
});
|
||||
WorkstationRevisionModel.init(revision, {
|
||||
sequelize: database,
|
||||
tableName: "workstation_revisions",
|
||||
timestamps: false,
|
||||
});
|
||||
WorkstationAssetModel.init(reference, {
|
||||
sequelize: database,
|
||||
tableName: "workstation_assets",
|
||||
timestamps: false,
|
||||
});
|
||||
}
|
||||
@ -5,17 +5,23 @@ import type { Sequelize } from "sequelize";
|
||||
|
||||
import { AssetUseCases } from "./application/asset.use-cases";
|
||||
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
|
||||
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
||||
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
||||
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
||||
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
||||
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
|
||||
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
||||
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
||||
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||
import {
|
||||
CatalogModel,
|
||||
migrateAssetCatalog,
|
||||
} from "./infrastructure/persistence/asset-catalog.model";
|
||||
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
||||
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
|
||||
await migrateAssetCatalog(database);
|
||||
await CatalogModel.findOrCreate({
|
||||
@ -33,12 +39,19 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
throw new Error("No se pudo inicializar el catálogo de activos.");
|
||||
}
|
||||
await migrateInventory(database);
|
||||
const inventory = new AssetUseCases(new SequelizeAssetRepository(database));
|
||||
await migrateRelationships(database);
|
||||
await migrateWorkstations(database);
|
||||
const assetRepository = new SequelizeAssetRepository(database);
|
||||
const inventory = new AssetUseCases(assetRepository);
|
||||
const workstations = new WorkstationUseCases(assetRepository);
|
||||
const relationships = new AssetRelationshipUseCases(assetRepository);
|
||||
const useCases = new AssetCatalogUseCases(repository);
|
||||
return {
|
||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||
registerAssetRoutes(app, inventory, authenticate);
|
||||
registerRelationshipRoutes(app, relationships, authenticate);
|
||||
registerWorkstationRoutes(app, workstations, authenticate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -28,3 +28,5 @@ export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
|
||||
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
||||
|
||||
export * from "./inventory";
|
||||
export * from "./relationships";
|
||||
export * from "./workstations";
|
||||
|
||||
@ -7,6 +7,9 @@ import {
|
||||
AssetValueSchema,
|
||||
} from "../api/domain/asset-values";
|
||||
|
||||
import { RelationshipSchema } from "./relationships";
|
||||
import { WorkstationSchema } from "./workstations";
|
||||
|
||||
export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values";
|
||||
|
||||
export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema };
|
||||
@ -27,7 +30,11 @@ export const AssetSchema = z.strictObject({
|
||||
values: AssetInputSchema,
|
||||
history: z.array(AssetRevisionSchema).min(1),
|
||||
});
|
||||
export const InventoryResponseSchema = z.strictObject({ assets: z.array(AssetSchema) });
|
||||
export const InventoryResponseSchema = z.strictObject({
|
||||
workstations: z.array(WorkstationSchema),
|
||||
assets: z.array(AssetSchema),
|
||||
relationships: z.array(RelationshipSchema),
|
||||
});
|
||||
export const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
|
||||
export type AssetDTO = z.infer<typeof AssetSchema>;
|
||||
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;
|
||||
|
||||
25
modules/assets/src/common/relationships.ts
Normal file
25
modules/assets/src/common/relationships.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { RelationshipValuesSchema } from "../api/domain/value-objects/asset-relationship-values";
|
||||
|
||||
export const RelationshipInputSchema = z.strictObject({
|
||||
sourceId: z.uuid(),
|
||||
targetId: z.uuid(),
|
||||
typeId: z.uuid(),
|
||||
notes: z.string().max(10000),
|
||||
});
|
||||
export type RelationshipInputDTO = z.infer<typeof RelationshipInputSchema>;
|
||||
export const RelationshipRequestSchema = z.strictObject({ values: RelationshipInputSchema });
|
||||
export const RelationshipSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: RelationshipValuesSchema,
|
||||
createdBy: z.uuid(),
|
||||
createdByName: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
endedBy: z.uuid().nullable(),
|
||||
endedByName: z.string().nullable(),
|
||||
endedAt: z.iso.datetime().nullable(),
|
||||
});
|
||||
export const RelationshipResponseSchema = z.strictObject({ relationship: RelationshipSchema });
|
||||
export type RelationshipDTO = z.infer<typeof RelationshipSchema>;
|
||||
23
modules/assets/src/common/workstations.ts
Normal file
23
modules/assets/src/common/workstations.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { WorkstationInputSchema } from "../api/domain/workstation-values";
|
||||
|
||||
export type { WorkstationInput } from "../api/domain/workstation-values";
|
||||
|
||||
export { WorkstationInputSchema };
|
||||
export const SaveWorkstationRequestSchema = z.strictObject({ values: WorkstationInputSchema });
|
||||
export const WorkstationRevisionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: WorkstationInputSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
});
|
||||
export const WorkstationSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: WorkstationInputSchema,
|
||||
history: z.array(WorkstationRevisionSchema).min(1),
|
||||
});
|
||||
export const WorkstationResponseSchema = z.strictObject({ workstation: WorkstationSchema });
|
||||
export type WorkstationDTO = z.infer<typeof WorkstationSchema>;
|
||||
@ -1,18 +1,30 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO } from "../common/inventory";
|
||||
|
||||
import type { InventoryLookups } from "./asset-form";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
export function AssetDetail({
|
||||
asset,
|
||||
lookups,
|
||||
compact = false,
|
||||
children,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
lookups: InventoryLookups;
|
||||
compact?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const inventory = useQuery({
|
||||
queryKey: inventoryKeys.company(asset.companyId),
|
||||
queryFn: () => inventoryApi.get(asset.companyId),
|
||||
});
|
||||
const workstation = inventory.data?.workstations.find(
|
||||
(item) => item.values.active && item.values.assetIds.includes(asset.id)
|
||||
);
|
||||
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
|
||||
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
|
||||
const display = (value: string | number | boolean | undefined) =>
|
||||
@ -41,6 +53,10 @@ export function AssetDetail({
|
||||
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
|
||||
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt>
|
||||
<dd>{workstation?.values.name ?? t("noWorkstation")}</dd>
|
||||
</div>
|
||||
{asset.specification.fields.map((field) => (
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
@ -56,6 +72,7 @@ export function AssetDetail({
|
||||
{asset.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
|
||||
)}
|
||||
{children}
|
||||
{!compact && (
|
||||
<>
|
||||
<h3 className="font-medium">
|
||||
|
||||
@ -39,6 +39,7 @@ export function AssetForm({
|
||||
items,
|
||||
lookups,
|
||||
onSaved,
|
||||
allowQuickCreate = true,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
@ -46,6 +47,7 @@ export function AssetForm({
|
||||
items: readonly CatalogItemDTO[];
|
||||
lookups: InventoryLookups;
|
||||
onSaved: (id: string) => void;
|
||||
allowQuickCreate?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
@ -127,7 +129,7 @@ export function AssetForm({
|
||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
asset
|
||||
asset || !allowQuickCreate
|
||||
? undefined
|
||||
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
|
||||
}
|
||||
@ -170,15 +172,19 @@ export function AssetForm({
|
||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||
<SelectWithAction
|
||||
{...selectProps}
|
||||
action={{
|
||||
label: t("newSite"),
|
||||
onClick: () =>
|
||||
lookups.onCreateSite((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("siteId", id);
|
||||
action={
|
||||
allowQuickCreate
|
||||
? {
|
||||
label: t("newSite"),
|
||||
onClick: () =>
|
||||
lookups.onCreateSite((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("siteId", id);
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}}
|
||||
: undefined
|
||||
}
|
||||
id={`${prefix}-site`}
|
||||
label={t("site")}
|
||||
onValueChange={(id) => form.setValue("siteId", id || null)}
|
||||
@ -190,15 +196,19 @@ export function AssetForm({
|
||||
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
|
||||
<SelectWithAction
|
||||
{...selectProps}
|
||||
action={{
|
||||
label: t("newPerson"),
|
||||
onClick: () =>
|
||||
lookups.onCreatePerson((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("personId", id);
|
||||
action={
|
||||
allowQuickCreate
|
||||
? {
|
||||
label: t("newPerson"),
|
||||
onClick: () =>
|
||||
lookups.onCreatePerson((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("personId", id);
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}}
|
||||
: undefined
|
||||
}
|
||||
id={`${prefix}-person`}
|
||||
label={t("person")}
|
||||
onValueChange={(id) => form.setValue("personId", id || null)}
|
||||
|
||||
268
modules/assets/src/web/asset-relationships.tsx
Normal file
268
modules/assets/src/web/asset-relationships.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { RelationshipDTO, RelationshipInputDTO } from "../common";
|
||||
|
||||
import { InventoryFeedback } from "./asset-form";
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { CatalogRelationForm } from "./catalog-relation-form";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
|
||||
export function AssetRelationships({
|
||||
companyId,
|
||||
csrfToken,
|
||||
assetId,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
assetId: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const client = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [ending, setEnding] = useState<RelationshipDTO>();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (id: string) => inventoryApi.endRelationship(companyId, id, csrfToken),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
setEnding(undefined);
|
||||
},
|
||||
});
|
||||
const links = (query.data?.relationships ?? []).filter(
|
||||
(link) => link.values.sourceId === assetId || link.values.targetId === assetId
|
||||
);
|
||||
const active = links.filter((link) => !link.endedAt);
|
||||
const ended = links.filter((link) => link.endedAt);
|
||||
const nameOf = (id: string) =>
|
||||
query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
|
||||
const dateOf = (date: string) =>
|
||||
new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(date)
|
||||
);
|
||||
const render = (link: RelationshipDTO) => {
|
||||
const source = link.values.sourceId === assetId;
|
||||
return (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={link.id}>
|
||||
<p className="font-medium">
|
||||
{source ? link.values.name : link.values.inverseName} ·{" "}
|
||||
{nameOf(source ? link.values.targetId : link.values.sourceId)}
|
||||
</p>
|
||||
{link.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{link.values.notes}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("relationCreated", { name: link.createdByName, date: dateOf(link.createdAt) })}
|
||||
</p>
|
||||
{link.endedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("relationEnded", { name: link.endedByName, date: dateOf(link.endedAt) })}
|
||||
</p>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutation.reset();
|
||||
setEnding(link);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("endRelationship")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<section aria-label={t("relations")} className="space-y-3">
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-medium">{t("relations")}</h3>
|
||||
<Button onClick={() => setAdding(true)} size="sm" variant="outline">
|
||||
{t("linkAsset")}
|
||||
</Button>
|
||||
</header>
|
||||
{active.length ? (
|
||||
<ul className="space-y-3">{active.map(render)}</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("noRelationships")}</p>
|
||||
)}
|
||||
{!!ended.length && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("endedRelationships")} ({ended.length})
|
||||
</summary>
|
||||
<ul className="mt-3 space-y-3">{ended.map(render)}</ul>
|
||||
</details>
|
||||
)}
|
||||
{adding && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setAdding}
|
||||
open
|
||||
title={t("linkAsset")}
|
||||
>
|
||||
<RelationshipForm
|
||||
assetId={assetId}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={() => setAdding(false)}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{ending && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEnding(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("endRelationship")}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{ending.values.name} · {nameOf(ending.values.sourceId)} →{" "}
|
||||
{nameOf(ending.values.targetId)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("endRelationshipConfirm")}</p>
|
||||
<InventoryFeedback error={mutation.error} />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} onClick={() => mutation.mutate(ending.id)}>
|
||||
{t("endRelationship")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormDialog>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function RelationshipForm({
|
||||
companyId,
|
||||
csrfToken,
|
||||
assetId,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
assetId: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
const client = useQueryClient();
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [quick, setQuick] = useState(false);
|
||||
const catalog = useQuery({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const inventory = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
onSaved();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
|
||||
<SelectWithAction
|
||||
action={{ label: t("newRelation"), onClick: () => setQuick(true) }}
|
||||
disabled={mutation.isPending || catalog.isPending}
|
||||
id={`${id}-type`}
|
||||
label={t("relationshipType")}
|
||||
onValueChange={setTypeId}
|
||||
options={(catalog.data?.items ?? [])
|
||||
.filter((item) => item.values.kind === "relation" && item.effectiveActive)
|
||||
.map((item) => ({ value: item.id, label: item.values.name }))}
|
||||
placeholder={t("chooseRelation")}
|
||||
required
|
||||
value={typeId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
|
||||
<SelectWithAction
|
||||
disabled={mutation.isPending || inventory.isPending}
|
||||
id={`${id}-target`}
|
||||
label={t("targetAsset")}
|
||||
onValueChange={setTargetId}
|
||||
options={(inventory.data?.assets ?? [])
|
||||
.filter((asset) => asset.id !== assetId)
|
||||
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
|
||||
placeholder={t("chooseAsset")}
|
||||
required
|
||||
value={targetId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-notes`}>{t("notes")}</Label>
|
||||
<Textarea
|
||||
id={`${id}-notes`}
|
||||
maxLength={10000}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
value={notes}
|
||||
/>
|
||||
</div>
|
||||
<InventoryFeedback error={mutation.error ?? catalog.error ?? inventory.error} />
|
||||
<ContextHelp
|
||||
description={t("relationshipHelp")}
|
||||
examples={t("relationshipExample")}
|
||||
examplesLabel={t("examplesLabel")}
|
||||
guidance={t("relationshipGuidance")}
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={!(typeId && targetId) || mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setQuick}
|
||||
open
|
||||
title={t("newRelation")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t("quickRelationNotice")}</p>
|
||||
<CatalogRelationForm
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(savedId) => {
|
||||
setTypeId(savedId);
|
||||
setQuick(false);
|
||||
}}
|
||||
scope={companyId}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -53,6 +53,11 @@ export function CatalogDefinitionDialog({
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
) : values.kind === "relation" ? (
|
||||
<p>
|
||||
{t("inverseName")}: {values.inverseName} ·{" "}
|
||||
{t(values.symmetric ? "symmetric" : "directional")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{values.fields.map((binding) => {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) {
|
||||
export function CatalogHelp({
|
||||
kind,
|
||||
scope,
|
||||
}: {
|
||||
kind: "field" | "template" | "relation";
|
||||
scope: string;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
return (
|
||||
<ContextHelp
|
||||
|
||||
@ -20,14 +20,19 @@ import type { CatalogItemDTO } from "../common";
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
|
||||
import { CatalogError, CatalogFieldForm } from "./catalog-form";
|
||||
import { CatalogRelationForm } from "./catalog-relation-form";
|
||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||
|
||||
type Editor = { kind: "field" | "template"; item?: CatalogItemDTO; copy?: CatalogItemDTO };
|
||||
type Editor = {
|
||||
kind: "field" | "template" | "relation";
|
||||
item?: CatalogItemDTO;
|
||||
copy?: CatalogItemDTO;
|
||||
};
|
||||
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||
const { t } = useTranslation("assets");
|
||||
const searchId = useId();
|
||||
const client = useQueryClient();
|
||||
const [tab, setTab] = useState<"field" | "template">("template");
|
||||
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
||||
const [search, setSearch] = useState("");
|
||||
const [view, setView] = useState<CatalogItemDTO>();
|
||||
const [editor, setEditor] = useState<Editor>();
|
||||
@ -61,11 +66,11 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||
{t(tab === "field" ? "newField" : "newTemplate")}
|
||||
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||
</Button>
|
||||
</div>
|
||||
<nav aria-label={t("title")} className="flex gap-2">
|
||||
{(["template", "field"] as const).map((kind) => (
|
||||
{(["template", "field", "relation"] as const).map((kind) => (
|
||||
<Button
|
||||
aria-pressed={tab === kind}
|
||||
key={kind}
|
||||
@ -75,7 +80,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
}}
|
||||
variant={tab === kind ? "secondary" : "outline"}
|
||||
>
|
||||
{t(kind === "field" ? "fields" : "templates")}
|
||||
{t({ field: "fields", template: "templates", relation: "relations" }[kind])}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
@ -109,7 +114,9 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("origin")}</TableHead>
|
||||
<TableHead>{t(tab === "field" ? "type" : "fields")}</TableHead>
|
||||
<TableHead>
|
||||
{t({ field: "type", template: "fields", relation: "inverseName" }[tab])}
|
||||
</TableHead>
|
||||
<TableHead>{t("active")}</TableHead>
|
||||
<TableHead>{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
@ -156,6 +163,13 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
||||
onSaved={() => setEditor(undefined)}
|
||||
scope={scope}
|
||||
/>
|
||||
) : editor.kind === "relation" ? (
|
||||
<CatalogRelationForm
|
||||
csrfToken={csrfToken}
|
||||
item={editor.item}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
scope={scope}
|
||||
/>
|
||||
) : (
|
||||
<CatalogTemplateForm
|
||||
copy={editor.copy}
|
||||
@ -216,7 +230,9 @@ function CatalogRow({
|
||||
<TableCell>
|
||||
{values.kind === "field"
|
||||
? t(values.type)
|
||||
: t("fieldCount", { count: values.fields.length })}
|
||||
: values.kind === "relation"
|
||||
? values.inverseName
|
||||
: t("fieldCount", { count: values.fields.length })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(item.effectiveActive ? "active" : "inactive")}
|
||||
@ -245,6 +261,9 @@ function editorTitle(editor: Editor) {
|
||||
if (editor.kind === "field") {
|
||||
return editor.item ? "editField" : "newField";
|
||||
}
|
||||
if (editor.kind === "relation") {
|
||||
return editor.item ? "editRelation" : "newRelation";
|
||||
}
|
||||
return editor.item ? "editTemplate" : "newTemplate";
|
||||
}
|
||||
|
||||
|
||||
97
modules/assets/src/web/catalog-relation-form.tsx
Normal file
97
modules/assets/src/web/catalog-relation-form.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
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 { useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { AssetDefinitionSchema, type CatalogItemDTO } from "../common";
|
||||
|
||||
import { CatalogError, type CatalogSaved, useCatalogSave } from "./catalog-form";
|
||||
import { CatalogHelp } from "./catalog-help";
|
||||
|
||||
const schema = AssetDefinitionSchema.options[2];
|
||||
export function CatalogRelationForm({
|
||||
scope,
|
||||
csrfToken,
|
||||
item,
|
||||
onSaved,
|
||||
}: {
|
||||
scope: string;
|
||||
csrfToken: string;
|
||||
item?: CatalogItemDTO | undefined;
|
||||
onSaved: CatalogSaved;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
const current = item?.values.kind === "relation" ? item.values : undefined;
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: current ?? {
|
||||
kind: "relation",
|
||||
name: "",
|
||||
inverseName: "",
|
||||
description: "",
|
||||
symmetric: false,
|
||||
},
|
||||
});
|
||||
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
|
||||
const symmetric = form.watch("symmetric");
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
required
|
||||
{...form.register("name")}
|
||||
onChange={(event) => {
|
||||
form.setValue("name", event.target.value);
|
||||
if (symmetric) {
|
||||
form.setValue("inverseName", event.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={symmetric}
|
||||
disabled={!!item}
|
||||
onChange={(event) => {
|
||||
form.setValue("symmetric", event.target.checked);
|
||||
if (event.target.checked) {
|
||||
form.setValue("inverseName", form.getValues("name"));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("symmetric")}
|
||||
</label>
|
||||
{!symmetric && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-inverse`}>{t("inverseName")}</Label>
|
||||
<Input id={`${id}-inverse`} maxLength={100} required {...form.register("inverseName")} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<Textarea id={`${id}-description`} maxLength={1000} {...form.register("description")} />
|
||||
</div>
|
||||
<CatalogError
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<CatalogHelp kind="relation" scope={scope} />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
|
||||
import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
|
||||
import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
|
||||
export const assetsSpanish = {
|
||||
...inventorySpanish,
|
||||
...workstationsSpanish,
|
||||
...relationshipsSpanish,
|
||||
yes: "Sí",
|
||||
no: "No",
|
||||
title: "Catálogo de activos",
|
||||
companyTitle: "Plantillas de activos",
|
||||
companyTitle: "Catálogo de activos",
|
||||
introduction: "Define las características y plantillas que utilizarás al registrar activos.",
|
||||
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
|
||||
fields: "Campos",
|
||||
@ -84,6 +88,7 @@ export const assetsSpanish = {
|
||||
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
|
||||
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
|
||||
errors: {
|
||||
RELATION_DIRECTION_FIXED: "La dirección de una relación no se puede cambiar.",
|
||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
||||
INVALID_CHOICES:
|
||||
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
|
||||
@ -102,10 +107,12 @@ export const assetsSpanish = {
|
||||
};
|
||||
export const assetsEnglish = {
|
||||
...inventoryEnglish,
|
||||
...workstationsEnglish,
|
||||
...relationshipsEnglish,
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
title: "Asset catalog",
|
||||
companyTitle: "Asset templates",
|
||||
companyTitle: "Asset catalog",
|
||||
introduction: "Define the characteristics and templates used when registering assets.",
|
||||
companyIntroduction: "Use global definitions and add those needed by this company.",
|
||||
fields: "Fields",
|
||||
@ -183,6 +190,7 @@ export const assetsEnglish = {
|
||||
"This template contains inactive fields. You can activate them in the catalog before using it.",
|
||||
quickNotice: "The field remains saved if you later cancel the template.",
|
||||
errors: {
|
||||
RELATION_DIRECTION_FIXED: "Relationship direction cannot be changed.",
|
||||
INVALID_REQUEST: "Check the form data.",
|
||||
INVALID_CHOICES:
|
||||
"A list needs unique non-empty values. Other data types do not accept list values.",
|
||||
|
||||
@ -3,3 +3,4 @@ export { AssetCatalogPage } from "./catalog-page";
|
||||
export { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
export { inventoryKeys } from "./inventory-api";
|
||||
export { AssetInventoryPage } from "./inventory-page";
|
||||
export { WorkstationsPage } from "./workstations-page";
|
||||
|
||||
@ -2,6 +2,8 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { AssetInput } from "../common/inventory";
|
||||
import { AssetResponseSchema, InventoryResponseSchema } from "../common/inventory";
|
||||
import { type RelationshipInputDTO, RelationshipResponseSchema } from "../common/relationships";
|
||||
import { type WorkstationInput, WorkstationResponseSchema } from "../common/workstations";
|
||||
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
|
||||
export class InventoryApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
@ -26,6 +28,21 @@ async function request(path: string, body?: unknown, csrfToken?: string) {
|
||||
return data;
|
||||
}
|
||||
export const inventoryApi = {
|
||||
async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) {
|
||||
return WorkstationResponseSchema.parse(
|
||||
await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token)
|
||||
);
|
||||
},
|
||||
async link(companyId: string, values: RelationshipInputDTO, token: string) {
|
||||
return RelationshipResponseSchema.parse(
|
||||
await request(`${companyId}/relationships`, { values }, token)
|
||||
);
|
||||
},
|
||||
async endRelationship(companyId: string, id: string, token: string) {
|
||||
return RelationshipResponseSchema.parse(
|
||||
await request(`${companyId}/relationships/${id}/end`, {}, token)
|
||||
);
|
||||
},
|
||||
async get(companyId: string) {
|
||||
return InventoryResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
|
||||
@ -6,7 +6,7 @@ export const inventorySpanish = {
|
||||
inventory: "Activos",
|
||||
chooseValue: "Selecciona una opción",
|
||||
relatedNotice:
|
||||
"El elemento se guarda por separado y se conserva aunque después canceles el alta del activo.",
|
||||
"El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
|
||||
newAsset: "Nuevo activo",
|
||||
editAsset: "Editar activo",
|
||||
viewAsset: "Ficha e historial",
|
||||
@ -38,6 +38,14 @@ export const inventorySpanish = {
|
||||
"El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
|
||||
saveAssetError: "No se pudo guardar el activo.",
|
||||
inventoryErrors: {
|
||||
SELF_RELATIONSHIP: "Un activo no puede relacionarse consigo mismo.",
|
||||
RELATIONSHIP_EXISTS: "Ya existe esa relación entre estos activos.",
|
||||
RELATIONSHIP_NOT_FOUND: "La relación no existe en esta compañía.",
|
||||
RELATION_TYPE_UNAVAILABLE: "El tipo de relación debe estar activo en esta compañía.",
|
||||
INVALID_MEMBERS: "Selecciona activos distintos y válidos.",
|
||||
WORKSTATION_NOT_FOUND: "El puesto no existe en esta compañía.",
|
||||
ASSET_IN_WORKSTATION:
|
||||
"Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.",
|
||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
||||
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
|
||||
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
||||
@ -61,7 +69,7 @@ export const inventoryEnglish = {
|
||||
inventory: "Assets",
|
||||
chooseValue: "Choose an option",
|
||||
relatedNotice:
|
||||
"This item is saved separately and remains available if you later cancel the asset.",
|
||||
"This item is saved separately and remains available if you later cancel the main form.",
|
||||
newAsset: "New asset",
|
||||
editAsset: "Edit asset",
|
||||
viewAsset: "Details and history",
|
||||
@ -93,6 +101,14 @@ export const inventoryEnglish = {
|
||||
"The asset belongs to this company. Site and person are optional and must belong to it.",
|
||||
saveAssetError: "The asset could not be saved.",
|
||||
inventoryErrors: {
|
||||
SELF_RELATIONSHIP: "An asset cannot link to itself.",
|
||||
RELATIONSHIP_EXISTS: "This relationship already exists between these assets.",
|
||||
RELATIONSHIP_NOT_FOUND: "Relationship not found in this company.",
|
||||
RELATION_TYPE_UNAVAILABLE: "The relationship type must be active for this company.",
|
||||
INVALID_MEMBERS: "Select distinct valid assets.",
|
||||
WORKSTATION_NOT_FOUND: "Workstation not found in this company.",
|
||||
ASSET_IN_WORKSTATION:
|
||||
"An asset belongs to another active workstation. Remove it there before assigning it.",
|
||||
INVALID_REQUEST: "Check the form data.",
|
||||
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
|
||||
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",
|
||||
|
||||
@ -11,6 +11,7 @@ import type { AssetDTO } from "../common/inventory";
|
||||
import { AssetDetail } from "./asset-detail";
|
||||
import type { InventoryLookups } from "./asset-form";
|
||||
import { AssetForm, InventoryFeedback } from "./asset-form";
|
||||
import { AssetRelationships } from "./asset-relationships";
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { CatalogError } from "./catalog-form";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
@ -159,6 +160,8 @@ export function AssetInventoryPage({
|
||||
<AssetSelectionPanel
|
||||
asset={selected}
|
||||
canEdit={catalog.isSuccess}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
lookups={lookups}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onEdit={setEditor}
|
||||
@ -204,7 +207,9 @@ export function AssetInventoryPage({
|
||||
title={viewed.values.name}
|
||||
wide
|
||||
>
|
||||
<AssetDetail asset={viewed} lookups={lookups} />
|
||||
<AssetDetail asset={viewed} lookups={lookups}>
|
||||
<AssetRelationships assetId={viewed.id} companyId={companyId} csrfToken={csrfToken} />
|
||||
</AssetDetail>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
@ -216,6 +221,8 @@ function selectedRowClass(id: string, selectedId: string | undefined, detailOpen
|
||||
}
|
||||
|
||||
function AssetSelectionPanel({
|
||||
companyId,
|
||||
csrfToken,
|
||||
asset,
|
||||
open,
|
||||
lookups,
|
||||
@ -224,6 +231,8 @@ function AssetSelectionPanel({
|
||||
onView,
|
||||
onEdit,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
asset: AssetDTO | undefined;
|
||||
open: boolean;
|
||||
lookups: InventoryLookups;
|
||||
@ -244,7 +253,9 @@ function AssetSelectionPanel({
|
||||
×
|
||||
</Button>
|
||||
</header>
|
||||
<AssetDetail asset={asset} compact lookups={lookups} />
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
|
||||
</AssetDetail>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => onView(asset.id)} variant="outline">
|
||||
{t("viewAsset")}
|
||||
|
||||
@ -12,13 +12,18 @@ import { I18nextProvider } from "react-i18next";
|
||||
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
|
||||
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
|
||||
import type { RelationshipDTO } from "../common";
|
||||
|
||||
import { AssetForm } from "./asset-form";
|
||||
import { AssetRelationships } from "./asset-relationships";
|
||||
import { catalogKeys } from "./catalog-api";
|
||||
import { CatalogRelationForm } from "./catalog-relation-form";
|
||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||
import { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
import { inventoryKeys } from "./inventory-api";
|
||||
import { AssetInventoryPage } from "./inventory-page";
|
||||
import { WorkstationForm } from "./workstation-form";
|
||||
import { WorkstationsPage } from "./workstations-page";
|
||||
|
||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||
const lookups = {
|
||||
@ -27,14 +32,18 @@ const lookups = {
|
||||
onCreateSite: () => undefined,
|
||||
onCreatePerson: () => undefined,
|
||||
};
|
||||
async function render(language: string, node: ReactNode) {
|
||||
async function render(language: string, node: ReactNode, relationships: RelationshipDTO[] = []) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(inventoryKeys.company(companyId), { assets: [] });
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [],
|
||||
relationships,
|
||||
workstations: [],
|
||||
});
|
||||
client.setQueryData(
|
||||
catalogKeys.scope(companyId),
|
||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||
@ -94,3 +103,119 @@ test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una terc
|
||||
);
|
||||
assert.equal(child.includes('aria-label="Nuevo campo"'), false);
|
||||
});
|
||||
|
||||
test("Las fichas leen ambos sentidos y conservan la auditoría de relaciones retiradas", async () => {
|
||||
const sourceId = UniqueID.generateNewID().toPrimitive();
|
||||
const targetId = UniqueID.generateNewID().toPrimitive();
|
||||
const link: RelationshipDTO = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
values: {
|
||||
sourceId,
|
||||
targetId,
|
||||
typeId: UniqueID.generateNewID().toPrimitive(),
|
||||
name: "Alojado en",
|
||||
inverseName: "Aloja",
|
||||
symmetric: false,
|
||||
notes: "Rack 1",
|
||||
},
|
||||
createdBy: UniqueID.generateNewID().toPrimitive(),
|
||||
createdByName: "Técnico",
|
||||
createdAt: "2026-10-04T10:00:00.000Z",
|
||||
endedBy: null,
|
||||
endedByName: null,
|
||||
endedAt: null,
|
||||
};
|
||||
const source = await render(
|
||||
"es",
|
||||
<AssetRelationships assetId={sourceId} companyId={companyId} csrfToken="test" />,
|
||||
[link]
|
||||
);
|
||||
const target = await render(
|
||||
"es",
|
||||
<AssetRelationships assetId={targetId} companyId={companyId} csrfToken="test" />,
|
||||
[link]
|
||||
);
|
||||
assert.ok(source.includes("Alojado en"));
|
||||
assert.ok(target.includes("Aloja"));
|
||||
assert.ok(source.includes("Creada por Técnico"));
|
||||
assert.ok(source.includes("Retirar relación"));
|
||||
const ended = await render(
|
||||
"es",
|
||||
<AssetRelationships assetId={sourceId} companyId={companyId} csrfToken="test" />,
|
||||
[
|
||||
{
|
||||
...link,
|
||||
endedBy: link.createdBy,
|
||||
endedByName: "Técnico",
|
||||
endedAt: "2026-10-04T11:00:00.000Z",
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.ok(ended.includes("Relaciones retiradas"));
|
||||
assert.ok(ended.includes("Retirada por Técnico"));
|
||||
assert.ok(!ended.includes(">Retirar relación<"));
|
||||
});
|
||||
test("El formulario de tipo de relación presenta ayuda justo antes de sus operaciones", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<CatalogRelationForm csrfToken="test" onSaved={() => undefined} scope={companyId} />
|
||||
);
|
||||
assert.ok(html.indexOf('name="description"') < html.indexOf("<aside"));
|
||||
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
language === "es" ? "Nombre visto desde el otro activo" : "Name shown from the other asset"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Puestos de trabajo: listado traducido, alta en cabecera y formulario modal", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<WorkstationsPage companyId={companyId} csrfToken="test" lookups={lookups} />
|
||||
);
|
||||
assert.ok(html.includes(language === "es" ? "Nuevo puesto" : "New workstation"));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
language === "es"
|
||||
? "Esta compañía todavía no tiene puestos de trabajo."
|
||||
: "This company has no workstations yet."
|
||||
)
|
||||
);
|
||||
assert.equal(html.includes("<form"), false);
|
||||
}
|
||||
});
|
||||
test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no ofrece terceras altas", async () => {
|
||||
const html = await render(
|
||||
"es",
|
||||
<WorkstationForm
|
||||
companyId={companyId}
|
||||
csrfToken="test"
|
||||
lookups={lookups}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
for (const label of ["Nueva sede", "Nueva persona", "Nuevo activo"]) {
|
||||
assert.ok(html.includes(`aria-label="${label}"`));
|
||||
}
|
||||
assert.ok(html.indexOf('name="active"') < html.indexOf("<aside"));
|
||||
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
||||
const nested = await render(
|
||||
"es",
|
||||
<AssetForm
|
||||
allowQuickCreate={false}
|
||||
companyId={companyId}
|
||||
csrfToken="test"
|
||||
items={[]}
|
||||
lookups={lookups}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
for (const label of ["Nueva sede", "Nueva persona", "Nueva plantilla"]) {
|
||||
assert.ok(!nested.includes(`aria-label="${label}"`));
|
||||
}
|
||||
});
|
||||
|
||||
64
modules/assets/src/web/relationship-i18n.ts
Normal file
64
modules/assets/src/web/relationship-i18n.ts
Normal file
@ -0,0 +1,64 @@
|
||||
export const relationshipsSpanish = {
|
||||
relations: "Relaciones",
|
||||
newRelation: "Nuevo tipo de relación",
|
||||
editRelation: "Editar tipo de relación",
|
||||
inverseName: "Nombre visto desde el otro activo",
|
||||
symmetric: "Mismo nombre en ambos sentidos",
|
||||
directional: "Relación con dirección",
|
||||
relationDescription:
|
||||
"Un tipo de relación describe cómo se vinculan dos activos de una misma compañía.",
|
||||
relationExamples:
|
||||
"Portátil → Conectado a → Monitor. Máquina virtual → Alojado en → Servidor; desde el servidor se lee «Aloja».",
|
||||
relationGuidance:
|
||||
"Puedes desactivar tipos sin perder los vínculos existentes. La dirección queda fija tras el alta. Las relaciones conservan los nombres que tenían al crearse.",
|
||||
linkAsset: "Relacionar activo",
|
||||
relationshipType: "Tipo de relación",
|
||||
targetAsset: "Otro activo",
|
||||
chooseRelation: "Selecciona una relación",
|
||||
chooseAsset: "Selecciona un activo",
|
||||
noRelationships: "Este activo no tiene relaciones vigentes.",
|
||||
endedRelationships: "Relaciones retiradas",
|
||||
endRelationship: "Retirar relación",
|
||||
endRelationshipConfirm: "Se conservará el historial de esta relación.",
|
||||
relationshipHelp:
|
||||
"Vincula equipos que trabajan juntos o dependen de otros. Solo se muestran activos de esta compañía.",
|
||||
relationshipExample: "Por ejemplo: este portátil está conectado a un monitor externo.",
|
||||
relationshipGuidance:
|
||||
"Selecciona primero el tipo y después el otro activo. Retirar una relación no elimina los activos.",
|
||||
quickRelationNotice:
|
||||
"El tipo se guardará para esta compañía aunque después canceles la relación.",
|
||||
relationCreated: "Creada por {{name}} · {{date}}",
|
||||
relationEnded: "Retirada por {{name}} · {{date}}",
|
||||
};
|
||||
export const relationshipsEnglish = {
|
||||
relations: "Relationships",
|
||||
newRelation: "New relationship type",
|
||||
editRelation: "Edit relationship type",
|
||||
inverseName: "Name shown from the other asset",
|
||||
symmetric: "Same name in both directions",
|
||||
directional: "Directional relationship",
|
||||
relationDescription:
|
||||
"A relationship type describes how two assets in the same company are linked.",
|
||||
relationExamples:
|
||||
"Laptop → Connected to → Monitor. Virtual machine → Hosted on → Server; from the server it reads «Hosts».",
|
||||
relationGuidance:
|
||||
"Disable types without losing existing links. Direction is fixed after creation. Relationships retain the names they had when created.",
|
||||
linkAsset: "Link asset",
|
||||
relationshipType: "Relationship type",
|
||||
targetAsset: "Other asset",
|
||||
chooseRelation: "Select a relationship",
|
||||
chooseAsset: "Select an asset",
|
||||
noRelationships: "This asset has no current relationships.",
|
||||
endedRelationships: "Ended relationships",
|
||||
endRelationship: "End relationship",
|
||||
endRelationshipConfirm: "The relationship history will be retained.",
|
||||
relationshipHelp:
|
||||
"Link devices that work together or depend on others. Only assets in this company are shown.",
|
||||
relationshipExample: "For example: this laptop is connected to an external monitor.",
|
||||
relationshipGuidance:
|
||||
"Select the type and then the other asset. Ending a relationship does not delete assets.",
|
||||
quickRelationNotice:
|
||||
"The type is saved for this company even if you later cancel the relationship.",
|
||||
relationCreated: "Created by {{name}} · {{date}}",
|
||||
relationEnded: "Ended by {{name}} · {{date}}",
|
||||
};
|
||||
254
modules/assets/src/web/workstation-form.tsx
Normal file
254
modules/assets/src/web/workstation-form.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type WorkstationDTO, type WorkstationInput, WorkstationInputSchema } from "../common";
|
||||
|
||||
import { AssetForm, InventoryFeedback, type InventoryLookups } from "./asset-form";
|
||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
export function WorkstationForm({
|
||||
companyId,
|
||||
csrfToken,
|
||||
lookups,
|
||||
workstation,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
lookups: InventoryLookups;
|
||||
workstation?: WorkstationDTO | undefined;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const form = useForm<WorkstationInput>({
|
||||
resolver: zodResolver(WorkstationInputSchema),
|
||||
defaultValues: workstation?.values ?? {
|
||||
name: "",
|
||||
active: true,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
assetIds: [],
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const [quick, setQuick] = useState(false);
|
||||
const inventory = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const catalog = useQuery({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const assets = inventory.data?.assets ?? [];
|
||||
const stations = inventory.data?.workstations ?? [];
|
||||
const occupied = new Set(
|
||||
stations
|
||||
.filter((item) => item.values.active && item.id !== workstation?.id)
|
||||
.flatMap((item) => item.values.assetIds)
|
||||
);
|
||||
const add = (id: string) => {
|
||||
if (id && mounted.current && !form.getValues("assetIds").includes(id)) {
|
||||
form.setValue("assetIds", [...form.getValues("assetIds"), id]);
|
||||
}
|
||||
};
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: WorkstationInput) =>
|
||||
inventoryApi.saveWorkstation(companyId, input, csrfToken, workstation?.id),
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
|
||||
if (mounted.current) {
|
||||
onSaved(response.workstation.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("name")}</Label>
|
||||
<Input id={prefix} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
{t("active")}
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("newSite"),
|
||||
onClick: () =>
|
||||
lookups.onCreateSite((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("siteId", id);
|
||||
}
|
||||
}),
|
||||
}}
|
||||
id={`${prefix}-site`}
|
||||
label={t("site")}
|
||||
onValueChange={(id) => form.setValue("siteId", id || null)}
|
||||
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("noAssignment")}
|
||||
value={values.siteId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("workstationPerson")}</Label>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("newPerson"),
|
||||
onClick: () =>
|
||||
lookups.onCreatePerson((id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("personId", id);
|
||||
}
|
||||
}),
|
||||
}}
|
||||
id={`${prefix}-person`}
|
||||
label={t("workstationPerson")}
|
||||
onValueChange={(id) => form.setValue("personId", id || null)}
|
||||
options={lookups.people
|
||||
.filter((item) => item.active || item.id === workstation?.values.personId)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("noAssignment")}
|
||||
value={values.personId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("newAsset"),
|
||||
onClick: () => setQuick(true),
|
||||
disabled: !catalog.data,
|
||||
}}
|
||||
disabled={inventory.isPending || values.assetIds.length >= 200}
|
||||
id={`${prefix}-assets`}
|
||||
label={t("addWorkstationAsset")}
|
||||
onValueChange={add}
|
||||
options={assets
|
||||
.filter((item) => !values.assetIds.includes(item.id))
|
||||
.map((item) => ({
|
||||
value: item.id,
|
||||
label: occupied.has(item.id)
|
||||
? t("workstationOccupied", { name: item.values.name })
|
||||
: item.values.name,
|
||||
disabled: values.active && occupied.has(item.id),
|
||||
}))}
|
||||
placeholder={t("chooseAsset")}
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
{values.assetIds.length ? (
|
||||
<ul className="space-y-2">
|
||||
{values.assetIds.map((id) => (
|
||||
<li
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
||||
key={id}
|
||||
>
|
||||
<div>
|
||||
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
|
||||
{values.active && occupied.has(id) && (
|
||||
<p className="text-destructive">
|
||||
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t("removeWorkstationAsset", {
|
||||
name: assets.find((item) => item.id === id)?.values.name ?? id,
|
||||
})}
|
||||
onClick={() =>
|
||||
form.setValue(
|
||||
"assetIds",
|
||||
values.assetIds.filter((item) => item !== id)
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("inventoryErrors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<InventoryFeedback error={mutation.error ?? inventory.error} />
|
||||
<ContextHelp
|
||||
description={t("workstationDescription")}
|
||||
examples={t("workstationExamples")}
|
||||
examplesLabel={t("examplesLabel")}
|
||||
guidance={t("workstationGuidance")}
|
||||
scopeNote={t("workstationScope")}
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
!inventory.data ||
|
||||
(values.active && values.assetIds.some((id) => occupied.has(id)))
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && catalog.data && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setQuick}
|
||||
open
|
||||
title={t("newAsset")}
|
||||
wide
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t("relatedNotice")}</p>
|
||||
<AssetForm
|
||||
allowQuickCreate={false}
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
items={catalog.data.items}
|
||||
lookups={lookups}
|
||||
onSaved={(id) => {
|
||||
add(id);
|
||||
setQuick(false);
|
||||
}}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
modules/assets/src/web/workstation-i18n.ts
Normal file
52
modules/assets/src/web/workstation-i18n.ts
Normal file
@ -0,0 +1,52 @@
|
||||
export const workstationsSpanish = {
|
||||
workstations: "Puestos de trabajo",
|
||||
newWorkstation: "Nuevo puesto",
|
||||
editWorkstation: "Editar puesto",
|
||||
workstationPerson: "Persona del puesto",
|
||||
workstationHistory: "Historial del puesto",
|
||||
workstationAssets: "Activos del puesto",
|
||||
noWorkstations: "Esta compañía todavía no tiene puestos de trabajo.",
|
||||
noWorkstationAssets: "Este puesto todavía no tiene activos.",
|
||||
addWorkstationAsset: "Añadir un activo",
|
||||
workstationOccupied: "{{name}} · ocupado en otro puesto",
|
||||
removeWorkstationAsset: "Retirar {{name}} del puesto",
|
||||
workstationCount: "{{count}} activos",
|
||||
workstationCount_one: "{{count}} activo",
|
||||
workstationCount_other: "{{count}} activos",
|
||||
workstationDescription:
|
||||
"Un puesto reúne los activos que una persona utiliza habitualmente. Puede quedar sin persona o sede y puede crearse sin activos.",
|
||||
workstationExamples:
|
||||
"Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
|
||||
workstationGuidance:
|
||||
"Cada activo solo puede estar en un puesto activo. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
|
||||
workstationScope:
|
||||
"Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
|
||||
workstationInAsset: "Puesto de trabajo",
|
||||
noWorkstation: "Sin puesto activo",
|
||||
};
|
||||
export const workstationsEnglish = {
|
||||
workstations: "Workstations",
|
||||
newWorkstation: "New workstation",
|
||||
editWorkstation: "Edit workstation",
|
||||
workstationPerson: "Workstation assignee",
|
||||
workstationHistory: "Workstation history",
|
||||
workstationAssets: "Workstation assets",
|
||||
noWorkstations: "This company has no workstations yet.",
|
||||
noWorkstationAssets: "This workstation has no assets yet.",
|
||||
addWorkstationAsset: "Add an asset",
|
||||
workstationOccupied: "{{name}} · used in another workstation",
|
||||
removeWorkstationAsset: "Remove {{name}} from workstation",
|
||||
workstationCount: "{{count}} assets",
|
||||
workstationCount_one: "{{count}} asset",
|
||||
workstationCount_other: "{{count}} assets",
|
||||
workstationDescription:
|
||||
"A workstation groups assets a person uses regularly. Person and site are optional and it can be created without assets.",
|
||||
workstationExamples:
|
||||
"Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
|
||||
workstationGuidance:
|
||||
"Each asset can belong to one active workstation. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
|
||||
workstationScope:
|
||||
"All elements must belong to this company. Person, site and composition changes retain history.",
|
||||
workstationInAsset: "Workstation",
|
||||
noWorkstation: "No active workstation",
|
||||
};
|
||||
167
modules/assets/src/web/workstations-page.tsx
Normal file
167
modules/assets/src/web/workstations-page.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationDTO } from "../common";
|
||||
|
||||
import { InventoryFeedback, type InventoryLookups } from "./asset-form";
|
||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
import { WorkstationForm } from "./workstation-form";
|
||||
export function WorkstationsPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
lookups,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
lookups: InventoryLookups;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const query = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const stations = query.data?.workstations ?? [];
|
||||
const assets = query.data?.assets ?? [];
|
||||
const edited = stations.find((item) => item.id === editor);
|
||||
const viewed = stations.find((item) => item.id === view);
|
||||
const personName = (id: string | null) =>
|
||||
lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment");
|
||||
const siteName = (id: string | null) =>
|
||||
lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment");
|
||||
const members = (ids: readonly string[]) =>
|
||||
ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") ||
|
||||
t("noWorkstationAssets");
|
||||
const filtered = stations.filter((item) =>
|
||||
[item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some(
|
||||
(value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
);
|
||||
const summary = (item: WorkstationDTO) => (
|
||||
<>
|
||||
<h3 className="font-medium">
|
||||
{item.values.name} · {t(item.values.active ? "active" : "inactive")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{siteName(item.values.siteId)} · {personName(item.values.personId)} ·{" "}
|
||||
{t("workstationCount", { count: item.values.assetIds.length })}
|
||||
</p>
|
||||
<p className="mt-2 text-sm">{members(item.values.assetIds)}</p>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("workstations")}</h2>
|
||||
<Button disabled={!query.data} onClick={() => setEditor("new")}>
|
||||
{t("newWorkstation")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("search")}
|
||||
value={search}
|
||||
/>
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{query.data && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(stations.length ? "noResults" : "noWorkstations")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 py-4" key={item.id}>
|
||||
<div className="min-w-0 break-words">{summary(item)}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("workstationHistory")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(edited ? "editWorkstation" : "newWorkstation")}
|
||||
wide
|
||||
>
|
||||
<WorkstationForm
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
lookups={lookups}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
workstation={edited}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{viewed && (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setView(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("workstationHistory")}
|
||||
wide
|
||||
>
|
||||
<ol className="space-y-4">
|
||||
{[...viewed.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {t("recordedBy", { name: revision.actorName })}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<p>
|
||||
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
|
||||
</p>
|
||||
<p>{members(revision.values.assetIds)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -99,7 +99,14 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type CompanyTab = "people" | "departments" | "sites" | "access" | "assets" | "inventory";
|
||||
type CompanyTab =
|
||||
| "people"
|
||||
| "departments"
|
||||
| "sites"
|
||||
| "access"
|
||||
| "assets"
|
||||
| "inventory"
|
||||
| "workstations";
|
||||
|
||||
export function CompanyDetail({
|
||||
id,
|
||||
@ -120,7 +127,7 @@ export function CompanyDetail({
|
||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tab, setTab] = useState<
|
||||
"people" | "departments" | "sites" | "access" | "assets" | "inventory"
|
||||
"people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations"
|
||||
>(embedded ? "inventory" : "people");
|
||||
const company = query.data?.company;
|
||||
const layout = embedded
|
||||
@ -189,6 +196,7 @@ export function CompanyDetail({
|
||||
{(
|
||||
[
|
||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
[
|
||||
"departments",
|
||||
@ -242,6 +250,8 @@ function CompanySection({
|
||||
return (
|
||||
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
||||
);
|
||||
case "workstations":
|
||||
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
||||
case "inventory":
|
||||
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
||||
case "assets":
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AssetInventoryPage } from "@metis/assets/client";
|
||||
import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -10,9 +10,11 @@ import { SiteForm } from "./site-form";
|
||||
export function CompanyInventory({
|
||||
company,
|
||||
csrfToken,
|
||||
workstations = false,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
workstations?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t: assetTranslation } = useTranslation("assets");
|
||||
@ -20,9 +22,10 @@ export function CompanyInventory({
|
||||
kind: "site" | "person";
|
||||
onCreated: (id: string) => void;
|
||||
}>();
|
||||
const Page = workstations ? WorkstationsPage : AssetInventoryPage;
|
||||
return (
|
||||
<>
|
||||
<AssetInventoryPage
|
||||
<Page
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
lookups={{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user