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);
|
assert.equal(data.code, code);
|
||||||
}
|
}
|
||||||
const initial = await read(global);
|
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.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(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { verifyAssets } from "./assets.integration";
|
|||||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||||
import { verifyInventory } from "./inventory.integration";
|
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 () => {
|
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
|
||||||
const environment = readDatabaseEnvironment(process.env);
|
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 once(server, "listening");
|
||||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||||
await database.query("DELETE FROM http_sessions");
|
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 asset_revisions");
|
||||||
await database.query("DELETE FROM assets");
|
await database.query("DELETE FROM assets");
|
||||||
await database.query("DELETE FROM asset_availability");
|
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,
|
secondCompany.id,
|
||||||
database
|
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 }>(
|
const countAfterAccess = await database.query<{ total: number }>(
|
||||||
"SELECT COUNT(*) AS total FROM users",
|
"SELECT COUNT(*) AS total FROM users",
|
||||||
{ type: QueryTypes.SELECT }
|
{ 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("/access/global"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
|
||||||
assert.equal((await operator.session()).user, null);
|
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("/users"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||||
await assert.rejects(
|
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(companyKeys.detail(workspaceCompany.id), { company: workspaceCompany });
|
||||||
client.setQueryData(inventoryKeys.company(workspaceCompany.id), {
|
client.setQueryData(inventoryKeys.company(workspaceCompany.id), {
|
||||||
assets: workspaceAssets,
|
assets: workspaceAssets,
|
||||||
|
relationships: [],
|
||||||
|
workstations: [],
|
||||||
});
|
});
|
||||||
client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog);
|
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).
|
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.
|
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.
|
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
|
## 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.
|
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
|
## 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.
|
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.
|
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 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
|
## Siguientes entregas: orden actualizado
|
||||||
|
|
||||||
1. Relaciones entre activos y conjuntos de puesto de trabajo.
|
1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
|
||||||
2. 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. 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.
|
||||||
4. 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.
|
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).
|
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
|
## Tickets e intervenciones: pendiente
|
||||||
|
|
||||||
@ -66,4 +65,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
|
|||||||
|
|
||||||
## Decisiones pendientes
|
## 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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@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 { Asset } from "../domain/asset.aggregate";
|
||||||
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
|
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
|
||||||
import { AssetCatalogError } from "../domain/asset-definition";
|
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 { AssetValues } from "../domain/value-objects/asset-values";
|
||||||
|
import type { Workstation } from "../domain/workstation.aggregate";
|
||||||
|
|
||||||
export type InventoryContext = Readonly<{
|
export type InventoryContext = Readonly<{
|
||||||
|
workstations: readonly Workstation[];
|
||||||
catalog: AssetCatalog;
|
catalog: AssetCatalog;
|
||||||
assets: readonly Asset[];
|
assets: readonly Asset[];
|
||||||
|
relationships: readonly AssetRelationship[];
|
||||||
sites: readonly UniqueID[];
|
sites: readonly UniqueID[];
|
||||||
people: readonly { id: UniqueID; active: boolean }[];
|
people: readonly { id: UniqueID; active: boolean }[];
|
||||||
actorNames: ReadonlyMap<string, string>;
|
actorNames: ReadonlyMap<string, string>;
|
||||||
}>;
|
}>;
|
||||||
export interface IAssetRepository {
|
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>>;
|
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(
|
save(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
|
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 });
|
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;
|
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", () => {
|
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
|
||||||
const catalog = seedAssetCatalog(AssetCatalog.create());
|
const catalog = seedAssetCatalog(AssetCatalog.create());
|
||||||
assert.equal(catalog.definitions.length, 42);
|
assert.equal(catalog.definitions.length, 47);
|
||||||
assert.equal(seedAssetCatalog(catalog), catalog);
|
assert.equal(seedAssetCatalog(catalog), catalog);
|
||||||
const ids = [
|
const ids = [
|
||||||
catalog.id,
|
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, first), false);
|
||||||
assert.equal(catalog.active(stock, second), true);
|
assert.equal(catalog.active(stock, second), true);
|
||||||
assert.equal(stock.active, true);
|
assert.equal(stock.active, true);
|
||||||
assert.equal(catalog.definitions.length, 42);
|
assert.equal(catalog.definitions.length, 47);
|
||||||
const overrideId = catalog.availability[0]?.id;
|
const overrideId = catalog.availability[0]?.id;
|
||||||
assert.ok(overrideId);
|
assert.ok(overrideId);
|
||||||
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
|
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"));
|
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)) {
|
if (this.values.equals(values)) {
|
||||||
return Result.ok(this);
|
return Result.ok(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,13 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
|
|||||||
description: z.string().max(1000),
|
description: z.string().max(1000),
|
||||||
fields: z.array(TemplateFieldSchema).min(1).max(100),
|
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 AssetDefinitionSpec = z.infer<typeof AssetDefinitionSchema>;
|
||||||
export type AssetFieldType = z.infer<typeof FieldTypeSchema>;
|
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);
|
Object.freeze(values);
|
||||||
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
|
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
|
||||||
}
|
}
|
||||||
|
if (data.kind === "relation") {
|
||||||
|
return AssetDefinitionValues.relation(data, name.data);
|
||||||
|
}
|
||||||
const ids: UniqueID[] = [];
|
const ids: UniqueID[] = [];
|
||||||
for (const field of data.fields) {
|
for (const field of data.fields) {
|
||||||
const id = UniqueID.create(field.fieldId);
|
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 {
|
equals(other: AssetDefinitionValues): boolean {
|
||||||
const a = this.data;
|
const a = this.data;
|
||||||
const b = other.data;
|
const b = other.data;
|
||||||
@ -82,6 +103,9 @@ export class AssetDefinitionValues {
|
|||||||
a.choices.every((choice, index) => choice === b.choices[index])
|
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 (
|
return (
|
||||||
a.kind === "template" &&
|
a.kind === "template" &&
|
||||||
b.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,
|
SaveAssetRequestSchema,
|
||||||
} from "../../../common/inventory";
|
} from "../../../common/inventory";
|
||||||
import type { AssetUseCases } from "../../application/asset.use-cases";
|
import type { AssetUseCases } from "../../application/asset.use-cases";
|
||||||
|
import { buildRelationshipResponse } from "../../application/asset-relationship-response.builder";
|
||||||
import { buildAssetResponse } from "../../application/asset-response.builder";
|
import { buildAssetResponse } from "../../application/asset-response.builder";
|
||||||
|
import { buildWorkstationResponse } from "../../application/workstation-response.builder";
|
||||||
|
|
||||||
function fail(response: Response, code: string) {
|
function fail(response: Response, code: string) {
|
||||||
response
|
response
|
||||||
@ -35,6 +37,12 @@ export function registerAssetRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.json({
|
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) =>
|
assets: result.data.assets.map((asset) =>
|
||||||
buildAssetResponse(asset, result.data.actorNames)
|
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 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 type { Sequelize, Transaction } from "sequelize";
|
||||||
import { QueryTypes } from "sequelize";
|
import { QueryTypes } from "sequelize";
|
||||||
|
|
||||||
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
|
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
|
||||||
import { AssetCatalogError } from "../../domain/asset-definition";
|
import { AssetCatalogError } from "../../domain/asset-definition";
|
||||||
|
import type { Workstation } from "../../domain/workstation.aggregate";
|
||||||
|
|
||||||
import { AssetModel, AssetRevisionModel } from "./asset.model";
|
import { AssetModel, AssetRevisionModel } from "./asset.model";
|
||||||
import {
|
import {
|
||||||
@ -15,6 +16,15 @@ import {
|
|||||||
} from "./asset-catalog.model";
|
} from "./asset-catalog.model";
|
||||||
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
|
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
|
||||||
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-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 {
|
export class SequelizeAssetRepository implements IAssetRepository {
|
||||||
private readonly mapper = new AssetPersistenceMapper();
|
private readonly mapper = new AssetPersistenceMapper();
|
||||||
constructor(private readonly database: Sequelize) {}
|
constructor(private readonly database: Sequelize) {}
|
||||||
@ -106,7 +116,34 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
return asset;
|
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({
|
return Result.ok({
|
||||||
|
workstations,
|
||||||
|
relationships,
|
||||||
catalog,
|
catalog,
|
||||||
assets,
|
assets,
|
||||||
sites: sites.map((site) => inventoryIdentity(site.id)),
|
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])),
|
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) {
|
get(companyId: UniqueID) {
|
||||||
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
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 { AssetUseCases } from "./application/asset.use-cases";
|
||||||
import { AssetCatalogUseCases } from "./application/asset-catalog.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 { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
||||||
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
||||||
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.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 { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||||
import {
|
import {
|
||||||
CatalogModel,
|
CatalogModel,
|
||||||
migrateAssetCatalog,
|
migrateAssetCatalog,
|
||||||
} from "./infrastructure/persistence/asset-catalog.model";
|
} from "./infrastructure/persistence/asset-catalog.model";
|
||||||
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
|
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 { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.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) {
|
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
|
||||||
await migrateAssetCatalog(database);
|
await migrateAssetCatalog(database);
|
||||||
await CatalogModel.findOrCreate({
|
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.");
|
throw new Error("No se pudo inicializar el catálogo de activos.");
|
||||||
}
|
}
|
||||||
await migrateInventory(database);
|
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);
|
const useCases = new AssetCatalogUseCases(repository);
|
||||||
return {
|
return {
|
||||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||||
registerAssetRoutes(app, inventory, 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 type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
||||||
|
|
||||||
export * from "./inventory";
|
export * from "./inventory";
|
||||||
|
export * from "./relationships";
|
||||||
|
export * from "./workstations";
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import {
|
|||||||
AssetValueSchema,
|
AssetValueSchema,
|
||||||
} from "../api/domain/asset-values";
|
} from "../api/domain/asset-values";
|
||||||
|
|
||||||
|
import { RelationshipSchema } from "./relationships";
|
||||||
|
import { WorkstationSchema } from "./workstations";
|
||||||
|
|
||||||
export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values";
|
export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values";
|
||||||
|
|
||||||
export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema };
|
export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema };
|
||||||
@ -27,7 +30,11 @@ export const AssetSchema = z.strictObject({
|
|||||||
values: AssetInputSchema,
|
values: AssetInputSchema,
|
||||||
history: z.array(AssetRevisionSchema).min(1),
|
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 const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
|
||||||
export type AssetDTO = z.infer<typeof AssetSchema>;
|
export type AssetDTO = z.infer<typeof AssetSchema>;
|
||||||
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { AssetDTO } from "../common/inventory";
|
import type { AssetDTO } from "../common/inventory";
|
||||||
|
|
||||||
import type { InventoryLookups } from "./asset-form";
|
import type { InventoryLookups } from "./asset-form";
|
||||||
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
export function AssetDetail({
|
export function AssetDetail({
|
||||||
asset,
|
asset,
|
||||||
lookups,
|
lookups,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
asset: AssetDTO;
|
asset: AssetDTO;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
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 }[]) =>
|
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
|
||||||
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
|
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
|
||||||
const display = (value: string | number | boolean | undefined) =>
|
const display = (value: string | number | boolean | undefined) =>
|
||||||
@ -41,6 +53,10 @@ export function AssetDetail({
|
|||||||
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
|
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
|
||||||
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
|
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
|
||||||
</div>
|
</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) => (
|
{asset.specification.fields.map((field) => (
|
||||||
<div key={field.fieldId}>
|
<div key={field.fieldId}>
|
||||||
<dt className="text-sm text-muted-foreground">
|
<dt className="text-sm text-muted-foreground">
|
||||||
@ -56,6 +72,7 @@ export function AssetDetail({
|
|||||||
{asset.values.notes && (
|
{asset.values.notes && (
|
||||||
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
|
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
|
||||||
)}
|
)}
|
||||||
|
{children}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export function AssetForm({
|
|||||||
items,
|
items,
|
||||||
lookups,
|
lookups,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
allowQuickCreate = true,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
@ -46,6 +47,7 @@ export function AssetForm({
|
|||||||
items: readonly CatalogItemDTO[];
|
items: readonly CatalogItemDTO[];
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
onSaved: (id: string) => void;
|
onSaved: (id: string) => void;
|
||||||
|
allowQuickCreate?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
@ -127,7 +129,7 @@ export function AssetForm({
|
|||||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
action={
|
action={
|
||||||
asset
|
asset || !allowQuickCreate
|
||||||
? undefined
|
? undefined
|
||||||
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
|
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
|
||||||
}
|
}
|
||||||
@ -170,15 +172,19 @@ export function AssetForm({
|
|||||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
{...selectProps}
|
{...selectProps}
|
||||||
action={{
|
action={
|
||||||
label: t("newSite"),
|
allowQuickCreate
|
||||||
onClick: () =>
|
? {
|
||||||
lookups.onCreateSite((id) => {
|
label: t("newSite"),
|
||||||
if (mounted.current) {
|
onClick: () =>
|
||||||
form.setValue("siteId", id);
|
lookups.onCreateSite((id) => {
|
||||||
|
if (mounted.current) {
|
||||||
|
form.setValue("siteId", id);
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}),
|
: undefined
|
||||||
}}
|
}
|
||||||
id={`${prefix}-site`}
|
id={`${prefix}-site`}
|
||||||
label={t("site")}
|
label={t("site")}
|
||||||
onValueChange={(id) => form.setValue("siteId", id || null)}
|
onValueChange={(id) => form.setValue("siteId", id || null)}
|
||||||
@ -190,15 +196,19 @@ export function AssetForm({
|
|||||||
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
|
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
{...selectProps}
|
{...selectProps}
|
||||||
action={{
|
action={
|
||||||
label: t("newPerson"),
|
allowQuickCreate
|
||||||
onClick: () =>
|
? {
|
||||||
lookups.onCreatePerson((id) => {
|
label: t("newPerson"),
|
||||||
if (mounted.current) {
|
onClick: () =>
|
||||||
form.setValue("personId", id);
|
lookups.onCreatePerson((id) => {
|
||||||
|
if (mounted.current) {
|
||||||
|
form.setValue("personId", id);
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}),
|
: undefined
|
||||||
}}
|
}
|
||||||
id={`${prefix}-person`}
|
id={`${prefix}-person`}
|
||||||
label={t("person")}
|
label={t("person")}
|
||||||
onValueChange={(id) => form.setValue("personId", id || null)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
) : values.kind === "relation" ? (
|
||||||
|
<p>
|
||||||
|
{t("inverseName")}: {values.inverseName} ·{" "}
|
||||||
|
{t(values.symmetric ? "symmetric" : "directional")}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y rounded-md border">
|
<ul className="divide-y rounded-md border">
|
||||||
{values.fields.map((binding) => {
|
{values.fields.map((binding) => {
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { useTranslation } from "react-i18next";
|
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");
|
const { t } = useTranslation("assets");
|
||||||
return (
|
return (
|
||||||
<ContextHelp
|
<ContextHelp
|
||||||
|
|||||||
@ -20,14 +20,19 @@ import type { CatalogItemDTO } from "../common";
|
|||||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
|
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
|
||||||
import { CatalogError, CatalogFieldForm } from "./catalog-form";
|
import { CatalogError, CatalogFieldForm } from "./catalog-form";
|
||||||
|
import { CatalogRelationForm } from "./catalog-relation-form";
|
||||||
import { CatalogTemplateForm } from "./catalog-template-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 }) {
|
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const searchId = useId();
|
const searchId = useId();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template">("template");
|
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [view, setView] = useState<CatalogItemDTO>();
|
const [view, setView] = useState<CatalogItemDTO>();
|
||||||
const [editor, setEditor] = useState<Editor>();
|
const [editor, setEditor] = useState<Editor>();
|
||||||
@ -61,11 +66,11 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
|
||||||
{t(tab === "field" ? "newField" : "newTemplate")}
|
{t({ field: "newField", template: "newTemplate", relation: "newRelation" }[tab])}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label={t("title")} className="flex gap-2">
|
<nav aria-label={t("title")} className="flex gap-2">
|
||||||
{(["template", "field"] as const).map((kind) => (
|
{(["template", "field", "relation"] as const).map((kind) => (
|
||||||
<Button
|
<Button
|
||||||
aria-pressed={tab === kind}
|
aria-pressed={tab === kind}
|
||||||
key={kind}
|
key={kind}
|
||||||
@ -75,7 +80,7 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
}}
|
}}
|
||||||
variant={tab === kind ? "secondary" : "outline"}
|
variant={tab === kind ? "secondary" : "outline"}
|
||||||
>
|
>
|
||||||
{t(kind === "field" ? "fields" : "templates")}
|
{t({ field: "fields", template: "templates", relation: "relations" }[kind])}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@ -109,7 +114,9 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("name")}</TableHead>
|
<TableHead>{t("name")}</TableHead>
|
||||||
<TableHead>{t("origin")}</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("active")}</TableHead>
|
||||||
<TableHead>{t("actions")}</TableHead>
|
<TableHead>{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -156,6 +163,13 @@ export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToke
|
|||||||
onSaved={() => setEditor(undefined)}
|
onSaved={() => setEditor(undefined)}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
/>
|
/>
|
||||||
|
) : editor.kind === "relation" ? (
|
||||||
|
<CatalogRelationForm
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
item={editor.item}
|
||||||
|
onSaved={() => setEditor(undefined)}
|
||||||
|
scope={scope}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CatalogTemplateForm
|
<CatalogTemplateForm
|
||||||
copy={editor.copy}
|
copy={editor.copy}
|
||||||
@ -216,7 +230,9 @@ function CatalogRow({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
{values.kind === "field"
|
{values.kind === "field"
|
||||||
? t(values.type)
|
? t(values.type)
|
||||||
: t("fieldCount", { count: values.fields.length })}
|
: values.kind === "relation"
|
||||||
|
? values.inverseName
|
||||||
|
: t("fieldCount", { count: values.fields.length })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{t(item.effectiveActive ? "active" : "inactive")}
|
{t(item.effectiveActive ? "active" : "inactive")}
|
||||||
@ -245,6 +261,9 @@ function editorTitle(editor: Editor) {
|
|||||||
if (editor.kind === "field") {
|
if (editor.kind === "field") {
|
||||||
return editor.item ? "editField" : "newField";
|
return editor.item ? "editField" : "newField";
|
||||||
}
|
}
|
||||||
|
if (editor.kind === "relation") {
|
||||||
|
return editor.item ? "editRelation" : "newRelation";
|
||||||
|
}
|
||||||
return editor.item ? "editTemplate" : "newTemplate";
|
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 { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
|
||||||
|
import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
|
||||||
|
import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
|
||||||
export const assetsSpanish = {
|
export const assetsSpanish = {
|
||||||
...inventorySpanish,
|
...inventorySpanish,
|
||||||
|
...workstationsSpanish,
|
||||||
|
...relationshipsSpanish,
|
||||||
yes: "Sí",
|
yes: "Sí",
|
||||||
no: "No",
|
no: "No",
|
||||||
title: "Catálogo de activos",
|
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.",
|
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.",
|
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
|
||||||
fields: "Campos",
|
fields: "Campos",
|
||||||
@ -84,6 +88,7 @@ export const assetsSpanish = {
|
|||||||
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
|
"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.",
|
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
|
||||||
errors: {
|
errors: {
|
||||||
|
RELATION_DIRECTION_FIXED: "La dirección de una relación no se puede cambiar.",
|
||||||
INVALID_REQUEST: "Revisa los datos del formulario.",
|
INVALID_REQUEST: "Revisa los datos del formulario.",
|
||||||
INVALID_CHOICES:
|
INVALID_CHOICES:
|
||||||
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
|
"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 = {
|
export const assetsEnglish = {
|
||||||
...inventoryEnglish,
|
...inventoryEnglish,
|
||||||
|
...workstationsEnglish,
|
||||||
|
...relationshipsEnglish,
|
||||||
yes: "Yes",
|
yes: "Yes",
|
||||||
no: "No",
|
no: "No",
|
||||||
title: "Asset catalog",
|
title: "Asset catalog",
|
||||||
companyTitle: "Asset templates",
|
companyTitle: "Asset catalog",
|
||||||
introduction: "Define the characteristics and templates used when registering assets.",
|
introduction: "Define the characteristics and templates used when registering assets.",
|
||||||
companyIntroduction: "Use global definitions and add those needed by this company.",
|
companyIntroduction: "Use global definitions and add those needed by this company.",
|
||||||
fields: "Fields",
|
fields: "Fields",
|
||||||
@ -183,6 +190,7 @@ export const assetsEnglish = {
|
|||||||
"This template contains inactive fields. You can activate them in the catalog before using it.",
|
"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.",
|
quickNotice: "The field remains saved if you later cancel the template.",
|
||||||
errors: {
|
errors: {
|
||||||
|
RELATION_DIRECTION_FIXED: "Relationship direction cannot be changed.",
|
||||||
INVALID_REQUEST: "Check the form data.",
|
INVALID_REQUEST: "Check the form data.",
|
||||||
INVALID_CHOICES:
|
INVALID_CHOICES:
|
||||||
"A list needs unique non-empty values. Other data types do not accept list values.",
|
"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 { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
export { inventoryKeys } from "./inventory-api";
|
export { inventoryKeys } from "./inventory-api";
|
||||||
export { AssetInventoryPage } from "./inventory-page";
|
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 type { AssetInput } from "../common/inventory";
|
||||||
import { AssetResponseSchema, InventoryResponseSchema } 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 const inventoryKeys = { company: (id: string) => ["inventory", id] };
|
||||||
export class InventoryApiError extends Error {
|
export class InventoryApiError extends Error {
|
||||||
constructor(public readonly code: string) {
|
constructor(public readonly code: string) {
|
||||||
@ -26,6 +28,21 @@ async function request(path: string, body?: unknown, csrfToken?: string) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
export const inventoryApi = {
|
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) {
|
async get(companyId: string) {
|
||||||
return InventoryResponseSchema.parse(await request(companyId));
|
return InventoryResponseSchema.parse(await request(companyId));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export const inventorySpanish = {
|
|||||||
inventory: "Activos",
|
inventory: "Activos",
|
||||||
chooseValue: "Selecciona una opción",
|
chooseValue: "Selecciona una opción",
|
||||||
relatedNotice:
|
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",
|
newAsset: "Nuevo activo",
|
||||||
editAsset: "Editar activo",
|
editAsset: "Editar activo",
|
||||||
viewAsset: "Ficha e historial",
|
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.",
|
"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.",
|
saveAssetError: "No se pudo guardar el activo.",
|
||||||
inventoryErrors: {
|
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_REQUEST: "Revisa los datos del formulario.",
|
||||||
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
|
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
|
||||||
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
|
||||||
@ -61,7 +69,7 @@ export const inventoryEnglish = {
|
|||||||
inventory: "Assets",
|
inventory: "Assets",
|
||||||
chooseValue: "Choose an option",
|
chooseValue: "Choose an option",
|
||||||
relatedNotice:
|
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",
|
newAsset: "New asset",
|
||||||
editAsset: "Edit asset",
|
editAsset: "Edit asset",
|
||||||
viewAsset: "Details and history",
|
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.",
|
"The asset belongs to this company. Site and person are optional and must belong to it.",
|
||||||
saveAssetError: "The asset could not be saved.",
|
saveAssetError: "The asset could not be saved.",
|
||||||
inventoryErrors: {
|
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_REQUEST: "Check the form data.",
|
||||||
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
|
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
|
||||||
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",
|
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { AssetDTO } from "../common/inventory";
|
|||||||
import { AssetDetail } from "./asset-detail";
|
import { AssetDetail } from "./asset-detail";
|
||||||
import type { InventoryLookups } from "./asset-form";
|
import type { InventoryLookups } from "./asset-form";
|
||||||
import { AssetForm, InventoryFeedback } from "./asset-form";
|
import { AssetForm, InventoryFeedback } from "./asset-form";
|
||||||
|
import { AssetRelationships } from "./asset-relationships";
|
||||||
import { catalogApi, catalogKeys } from "./catalog-api";
|
import { catalogApi, catalogKeys } from "./catalog-api";
|
||||||
import { CatalogError } from "./catalog-form";
|
import { CatalogError } from "./catalog-form";
|
||||||
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
import { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
@ -159,6 +160,8 @@ export function AssetInventoryPage({
|
|||||||
<AssetSelectionPanel
|
<AssetSelectionPanel
|
||||||
asset={selected}
|
asset={selected}
|
||||||
canEdit={catalog.isSuccess}
|
canEdit={catalog.isSuccess}
|
||||||
|
companyId={companyId}
|
||||||
|
csrfToken={csrfToken}
|
||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
onEdit={setEditor}
|
onEdit={setEditor}
|
||||||
@ -204,7 +207,9 @@ export function AssetInventoryPage({
|
|||||||
title={viewed.values.name}
|
title={viewed.values.name}
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<AssetDetail asset={viewed} lookups={lookups} />
|
<AssetDetail asset={viewed} lookups={lookups}>
|
||||||
|
<AssetRelationships assetId={viewed.id} companyId={companyId} csrfToken={csrfToken} />
|
||||||
|
</AssetDetail>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -216,6 +221,8 @@ function selectedRowClass(id: string, selectedId: string | undefined, detailOpen
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssetSelectionPanel({
|
function AssetSelectionPanel({
|
||||||
|
companyId,
|
||||||
|
csrfToken,
|
||||||
asset,
|
asset,
|
||||||
open,
|
open,
|
||||||
lookups,
|
lookups,
|
||||||
@ -224,6 +231,8 @@ function AssetSelectionPanel({
|
|||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
csrfToken: string;
|
||||||
asset: AssetDTO | undefined;
|
asset: AssetDTO | undefined;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
lookups: InventoryLookups;
|
lookups: InventoryLookups;
|
||||||
@ -244,7 +253,9 @@ function AssetSelectionPanel({
|
|||||||
×
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => onView(asset.id)} variant="outline">
|
<Button onClick={() => onView(asset.id)} variant="outline">
|
||||||
{t("viewAsset")}
|
{t("viewAsset")}
|
||||||
|
|||||||
@ -12,13 +12,18 @@ import { I18nextProvider } from "react-i18next";
|
|||||||
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
|
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
|
||||||
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
|
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
|
||||||
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
|
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
|
||||||
|
import type { RelationshipDTO } from "../common";
|
||||||
|
|
||||||
import { AssetForm } from "./asset-form";
|
import { AssetForm } from "./asset-form";
|
||||||
|
import { AssetRelationships } from "./asset-relationships";
|
||||||
import { catalogKeys } from "./catalog-api";
|
import { catalogKeys } from "./catalog-api";
|
||||||
|
import { CatalogRelationForm } from "./catalog-relation-form";
|
||||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||||
import { assetsEnglish, assetsSpanish } from "./i18n";
|
import { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
import { inventoryKeys } from "./inventory-api";
|
import { inventoryKeys } from "./inventory-api";
|
||||||
import { AssetInventoryPage } from "./inventory-page";
|
import { AssetInventoryPage } from "./inventory-page";
|
||||||
|
import { WorkstationForm } from "./workstation-form";
|
||||||
|
import { WorkstationsPage } from "./workstations-page";
|
||||||
|
|
||||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||||
const lookups = {
|
const lookups = {
|
||||||
@ -27,14 +32,18 @@ const lookups = {
|
|||||||
onCreateSite: () => undefined,
|
onCreateSite: () => undefined,
|
||||||
onCreatePerson: () => undefined,
|
onCreatePerson: () => undefined,
|
||||||
};
|
};
|
||||||
async function render(language: string, node: ReactNode) {
|
async function render(language: string, node: ReactNode, relationships: RelationshipDTO[] = []) {
|
||||||
const i18n = createInstance();
|
const i18n = createInstance();
|
||||||
await i18n.init({
|
await i18n.init({
|
||||||
lng: language,
|
lng: language,
|
||||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||||
});
|
});
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
client.setQueryData(inventoryKeys.company(companyId), { assets: [] });
|
client.setQueryData(inventoryKeys.company(companyId), {
|
||||||
|
assets: [],
|
||||||
|
relationships,
|
||||||
|
workstations: [],
|
||||||
|
});
|
||||||
client.setQueryData(
|
client.setQueryData(
|
||||||
catalogKeys.scope(companyId),
|
catalogKeys.scope(companyId),
|
||||||
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
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);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
type CompanyTab = "people" | "departments" | "sites" | "access" | "assets" | "inventory";
|
type CompanyTab =
|
||||||
|
| "people"
|
||||||
|
| "departments"
|
||||||
|
| "sites"
|
||||||
|
| "access"
|
||||||
|
| "assets"
|
||||||
|
| "inventory"
|
||||||
|
| "workstations";
|
||||||
|
|
||||||
export function CompanyDetail({
|
export function CompanyDetail({
|
||||||
id,
|
id,
|
||||||
@ -120,7 +127,7 @@ export function CompanyDetail({
|
|||||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [tab, setTab] = useState<
|
const [tab, setTab] = useState<
|
||||||
"people" | "departments" | "sites" | "access" | "assets" | "inventory"
|
"people" | "departments" | "sites" | "access" | "assets" | "inventory" | "workstations"
|
||||||
>(embedded ? "inventory" : "people");
|
>(embedded ? "inventory" : "people");
|
||||||
const company = query.data?.company;
|
const company = query.data?.company;
|
||||||
const layout = embedded
|
const layout = embedded
|
||||||
@ -189,6 +196,7 @@ export function CompanyDetail({
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
|
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
[
|
[
|
||||||
"departments",
|
"departments",
|
||||||
@ -242,6 +250,8 @@ function CompanySection({
|
|||||||
return (
|
return (
|
||||||
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
||||||
);
|
);
|
||||||
|
case "workstations":
|
||||||
|
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
||||||
case "inventory":
|
case "inventory":
|
||||||
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
||||||
case "assets":
|
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 { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -10,9 +10,11 @@ import { SiteForm } from "./site-form";
|
|||||||
export function CompanyInventory({
|
export function CompanyInventory({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
|
workstations = false,
|
||||||
}: {
|
}: {
|
||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
|
workstations?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const { t: assetTranslation } = useTranslation("assets");
|
const { t: assetTranslation } = useTranslation("assets");
|
||||||
@ -20,9 +22,10 @@ export function CompanyInventory({
|
|||||||
kind: "site" | "person";
|
kind: "site" | "person";
|
||||||
onCreated: (id: string) => void;
|
onCreated: (id: string) => void;
|
||||||
}>();
|
}>();
|
||||||
|
const Page = workstations ? WorkstationsPage : AssetInventoryPage;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AssetInventoryPage
|
<Page
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
lookups={{
|
lookups={{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user