feat: add catalog template form and inventory management features

- Implemented CatalogTemplateForm for creating and managing asset templates.
- Added inventory management components including AssetInventoryPage and related forms.
- Introduced internationalization support for asset and inventory components in English and Spanish.
- Created tests for catalog and inventory functionalities to ensure proper rendering and behavior.
- Established API interactions for inventory management and asset catalog retrieval.
- Enhanced user experience with form dialogs for quick creation of sites and persons within company inventory.
This commit is contained in:
David Arranz 2026-10-04 18:36:09 +02:00
parent 5adbe81364
commit a232595a71
67 changed files with 5157 additions and 27 deletions

View File

@ -20,7 +20,8 @@
"mariadb": "3.5.4",
"express-session": "1.19.0",
"connect-session-sequelize": "8.0.6",
"@metis/companies": "workspace:*"
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -0,0 +1,211 @@
import assert from "node:assert/strict";
import { CatalogResponseSchema, SaveDefinitionResponseSchema } from "@metis/assets/common";
import { createAssetModule } from "@metis/assets/module";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
export async function verifyAssets(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string,
otherId: string,
database: Sequelize
) {
const global = "/asset-catalog/global";
const company = `/asset-catalog/${companyId}`;
const other = `/asset-catalog/${otherId}`;
async function read(path: string) {
const response = await request(path);
assert.equal(response.status, 200);
return CatalogResponseSchema.parse(await response.json());
}
async function save(path: string, values: unknown) {
const response = await request(path, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text());
return SaveDefinitionResponseSchema.parse(await response.json());
}
async function error(path: string, body: unknown, code: string, status = 409) {
const response = await request(path, body);
assert.equal(response.status, status);
const data = z.object({ code: z.string() }).parse(await response.json());
assert.equal(data.code, code);
}
const initial = await read(global);
assert.equal(initial.items.length, 42);
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.bindings.every((binding) => !binding.required && binding.id.charAt(14) === "7")
)
);
const stock = initial.items.find((item) => item.values.name === "Procesador");
assert.ok(stock);
await error(
`${global}/${stock.id}/update`,
{ values: { ...stock.values, name: "Renombrado" } },
"BUILTIN_PROTECTED"
);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example"))
.status,
403
);
assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, undefined, "a".repeat(64)))
.status,
403
);
assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200);
await createAssetModule(database, (_request, _response, next) => next());
const restarted = await read(global);
assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false);
assert.deepEqual(
restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort()
);
assert.equal((await request(`${global}/${stock.id}/status`, { active: true })).status, 200);
assert.equal((await request(`${company}/${stock.id}/status`, { active: false })).status, 200);
assert.equal(
(await read(company)).items.find((item) => item.id === stock.id)?.effectiveActive,
false
);
assert.equal(
(await read(other)).items.find((item) => item.id === stock.id)?.effectiveActive,
true
);
assert.equal((await read(global)).items.find((item) => item.id === stock.id)?.active, true);
assert.equal((await request(`${company}/${stock.id}/status`, { active: null })).status, 200);
assert.equal((await read(company)).items.find((item) => item.id === stock.id)?.overridden, false);
const localValues = {
kind: "field",
name: "Código interno",
type: "text",
description: "",
unit: "",
choices: [],
};
const local = await save(company, localValues);
const field = local.items.find((item) => item.id === local.savedId);
assert.ok(field);
assert.equal(field.companyId, companyId);
assert.equal(field.builtin, false);
assert.ok(!(await read(other)).items.some((item) => item.id === field.id));
assert.ok(!(await read(global)).items.some((item) => item.id === field.id));
await error(company, { values: { ...localValues, name: " código INTERNO " } }, "NAME_EXISTS");
await error(`${other}/${field.id}/update`, { values: localValues }, "DEFINITION_NOT_FOUND", 404);
await error(
`${company}/${field.id}/update`,
{ values: { ...localValues, type: "number" } },
"FIELD_TYPE_FIXED"
);
const templateValues = {
kind: "template",
name: "Equipo especial",
description: "",
fields: [
{ fieldId: field.id, required: false },
{ fieldId: stock.id, required: false },
],
};
await error(global, { values: templateValues }, "FIELD_UNAVAILABLE");
await error(other, { values: templateValues }, "FIELD_UNAVAILABLE");
const template = await save(company, templateValues);
const saved = template.items.find((item) => item.id === template.savedId);
assert.ok(saved);
const edited = await save(`${company}/${saved.id}/update`, {
...templateValues,
name: "Equipo especial actualizado",
});
assert.equal(edited.savedId, saved.id);
assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings);
await error(
company,
{
values: {
...templateValues,
name: "Duplicado",
fields: [templateValues.fields[0], templateValues.fields[0]],
},
},
"INVALID_FIELDS"
);
const status = await request(`${company}/${field.id}/status`, { active: false });
assert.equal(status.status, 200);
await error(
company,
{ values: { ...templateValues, name: "Campo inactivo" } },
"FIELD_UNAVAILABLE"
);
assert.ok((await read(company)).items.some((item) => item.id === saved.id));
await save(`${company}/${saved.id}/update`, {
...templateValues,
name: "Equipo especial actualizado",
});
const list = {
kind: "field",
name: "Estado batería",
type: "choice",
unit: "",
description: "",
choices: ["Buena", "Sustituir"],
};
const custom = await save(global, list);
await error(
`${global}/${custom.savedId}/update`,
{ values: { ...list, choices: ["Buena"] } },
"FIELD_TYPE_FIXED"
);
await save(`${global}/${custom.savedId}/update`, {
...list,
choices: [...list.choices, "Desconocida"],
});
await error(global, { values: { ...list, name: "Sin valores", choices: [] } }, "INVALID_CHOICES");
const monitor = initial.items.find((item) => item.values.name === "Monitor");
assert.ok(monitor && monitor.values.kind === "template");
const binding = monitor.bindings[0];
assert.ok(binding);
try {
await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", {
replacements: {
id: monitor.id,
values: JSON.stringify({
...monitor.values,
fields: monitor.values.fields.map((entry, index) =>
index === 0 ? { ...entry, fieldId: field.id } : entry
),
}),
},
});
await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", {
replacements: { fieldId: field.id, id: binding.id },
});
const corrupt = await request(global);
assert.equal(corrupt.status, 500);
const hidden = z.object({ code: z.string() }).parse(await corrupt.json());
assert.equal(hidden.code, "INTERNAL_ERROR");
} finally {
await database.query("UPDATE asset_template_fields SET fieldId=:fieldId WHERE id=:id", {
replacements: { fieldId: binding.fieldId, id: binding.id },
});
await database.query("UPDATE asset_definitions SET `values`=:values WHERE id=:id", {
replacements: { values: JSON.stringify(monitor.values), id: monitor.id },
});
}
const concurrent = await Promise.all([
request(company, { values: { ...localValues, name: "Concurrente" } }),
request(company, { values: { ...localValues, name: "Concurrente" } }),
]);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
await error(
`/asset-catalog/${"82be74bb-894f-4cca-ac7b-dedc96c15689"}`,
undefined,
"COMPANY_NOT_FOUND",
404
);
await error(
global,
{ values: { ...localValues, name: "Injection", builtin: true } },
"INVALID_REQUEST",
400
);
}

View File

@ -11,6 +11,7 @@ const DatabaseEnvironmentSchema = z.object({
APP_ORIGIN: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) {
const parsed = DatabaseEnvironmentSchema.safeParse(source);
if (!parsed.success) {
@ -18,12 +19,13 @@ export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) {
}
return parsed.data;
}
export function createDatabase(environment: ReturnType<typeof readDatabaseEnvironment>) {
return new Sequelize(environment.DB_NAME, environment.DB_USER, environment.DB_PASSWORD, {
dialect: "mariadb",
host: environment.DB_HOST,
port: environment.DB_PORT,
logging: false,
logging: true,
pool: { max: 5, min: 0, idle: 10000 },
});
}

View File

@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { once } from "node:events";
import { test } from "node:test";
import { createAssetModule } from "@metis/assets/module";
import {
CompaniesResponseSchema,
CompanyErrorResponseSchema,
@ -18,8 +19,10 @@ import { QueryTypes } from "sequelize";
import { verifyAccess } from "./access.integration";
import { createApp } from "./app";
import { verifyAssets } from "./assets.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
import { verifyInventory } from "./inventory.integration";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
const environment = readDatabaseEnvironment(process.env);
@ -32,6 +35,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
await database.query("DELETE FROM http_sessions");
await database.query("DELETE FROM asset_revisions");
await database.query("DELETE FROM assets");
await database.query("DELETE FROM asset_availability");
await database.query("DELETE FROM asset_template_fields");
await database.query("DELETE FROM asset_definitions");
await database.query("DELETE FROM asset_catalogs");
await database.query("DELETE FROM access_person_references");
await database.query("DELETE FROM access_references");
await database.query("DELETE FROM access_revisions");
@ -44,6 +53,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await database.query("DELETE FROM company_departments");
await database.query("DELETE FROM company_sites");
await database.query("DELETE FROM companies");
await createAssetModule(database, (_request, _response, next) => next());
// Simular la tabla de contactos de la versión anterior solo en la base de pruebas.
const legacyCompanyId = "48f13545-1529-4ba1-a353-a5f844141411";
const legacyPersonId = "4ec8f8c5-3ad9-4e22-abf3-b95370400898";
@ -121,6 +131,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
assert.equal((await admin.session()).setupRequired, true);
await competitor.session();
await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED");
const first = {
name: "Admin test",
email: "ADMIN@example.test",
@ -670,6 +681,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await (await operator.request(`/companies/${secondCompany.id}`)).json()
).company;
await verifyAccess((...args) => operator.request(...args), accessCompany, accessOther);
await verifyAssets(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
await verifyInventory(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT }
@ -677,6 +700,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
assert.equal(Number(countAfterAccess[0]?.total), 2);
assert.equal((await operator.request("/auth/logout", {})).status, 200);
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
await expectError(await operator.request(`/assets/${customer.id}`), 401, "UNAUTHENTICATED");
assert.equal((await operator.session()).user, null);
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");

View File

@ -1,3 +1,4 @@
import { createAssetModule } from "@metis/assets/module";
import { createCompanyModule } from "@metis/companies/module";
import { createIdentityModule, migrateIdentity } from "@metis/identity/module";
import connectSequelize from "connect-session-sequelize";
@ -14,6 +15,7 @@ export async function createIdentityRuntime(
await migrateIdentity(database);
const identity = await createIdentityModule(database, environment.APP_ORIGIN);
const companies = await createCompanyModule(database, identity.authenticate);
const assets = await createAssetModule(database, identity.authenticate);
const SequelizeStore = connectSequelize(session.Store);
const store = new SequelizeStore({
db: database,
@ -37,6 +39,7 @@ export async function createIdentityRuntime(
register: (app: Parameters<typeof identity.register>[0]) => {
identity.register(app);
companies.register(app);
assets.register(app);
},
middleware,
stop: () => store.stopExpiringSessions(),

View File

@ -0,0 +1,237 @@
import assert from "node:assert/strict";
import {
AssetResponseSchema,
CatalogResponseSchema,
InventoryResponseSchema,
SaveDefinitionResponseSchema,
} from "@metis/assets/common";
import { CompanyResponseSchema } from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
export async function verifyInventory(
request: (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>,
companyId: string,
otherId: string,
database: Sequelize
) {
const path = `/assets/${companyId}`;
async function save(url: string, values: unknown) {
const response = await request(url, { values });
assert.ok([200, 201].includes(response.status), await response.clone().text());
return AssetResponseSchema.parse(await response.json()).asset;
}
async function error(url: string, values: unknown, code: string, status = 409) {
const response = await request(url, { values });
assert.equal(response.status, status);
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
async function read(url = path) {
const response = await request(url);
assert.equal(response.status, 200);
return InventoryResponseSchema.parse(await response.json());
}
const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json()
);
const monitor = catalog.items.find((item) => item.values.name === "Monitor");
assert.ok(monitor);
const laptop = catalog.items.find((item) => item.values.name === "Portátil");
assert.ok(laptop);
const inch = catalog.items.find((item) => item.values.name === "Pulgadas");
assert.ok(inch);
async function person(company: string) {
const response = await request(`/companies/${company}/people`, {
name: "Persona inventario",
position: null,
email: null,
phone: null,
active: true,
isContact: false,
});
assert.equal(response.status, 201);
const saved = CompanyResponseSchema.parse(await response.json()).company.people.at(-1);
assert.ok(saved);
return saved;
}
async function site(company: string) {
const response = await request(`/companies/${company}/sites`, {
name: "Sede inventario",
address: null,
});
assert.equal(response.status, 201);
const saved = CompanyResponseSchema.parse(await response.json()).company.sites.at(-1);
assert.ok(saved);
return saved;
}
const ownPerson = await person(companyId);
const foreignPerson = await person(otherId);
const ownSite = await site(companyId);
const foreignSite = await site(otherId);
const input = {
name: "MON-01",
templateId: monitor.id,
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
};
assert.equal((await read()).assets.length, 0);
const created = await save(path, input);
assert.equal(created.history.length, 1);
assert.equal(created.id.charAt(14), "7");
assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName);
assert.ok(created.history[0]?.actorId);
assert.equal((await read(`/assets/${otherId}`)).assets.length, 0);
await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404);
await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404);
await error(path, { ...input, personId: foreignPerson.id }, "PERSON_UNAVAILABLE");
await error(path, { ...input, characteristics: { [inch.id]: "24" } }, "INVALID_CHARACTERISTICS");
await error(
path,
{ ...input, characteristics: { [ownPerson.id]: 24 } },
"INVALID_CHARACTERISTICS"
);
await error(path, { ...input, actorId: ownPerson.id }, "INVALID_REQUEST", 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 assigned = await save(`${path}/${created.id}/update`, {
...input,
siteId: ownSite.id,
personId: ownPerson.id,
status: "in_use",
characteristics: { [inch.id]: 24 },
});
assert.equal(assigned.id, created.id);
assert.equal(assigned.history.length, 2);
assert.equal(assigned.history[0]?.values.personId, null);
const unchanged = await save(`${path}/${created.id}/update`, assigned.values);
assert.deepEqual(unchanged.history, assigned.history);
await error(
`${path}/${created.id}/update`,
{ ...assigned.values, templateId: laptop.id },
"TEMPLATE_FIXED"
);
await error(
`${path}/${created.id}/update`,
{ ...assigned.values, status: "retired" },
"RETIRED_ASSIGNED"
);
const deactivated = await request(`/companies/${companyId}/people/${ownPerson.id}/update`, {
name: ownPerson.name,
position: null,
email: null,
phone: null,
active: false,
isContact: false,
});
assert.equal(deactivated.status, 200);
const kept = await save(`${path}/${created.id}/update`, {
...assigned.values,
notes: "Persona inactiva: revisar entrega.",
});
assert.equal(kept.values.personId, ownPerson.id);
await error(path, { ...input, personId: ownPerson.id }, "PERSON_UNAVAILABLE");
const retired = await save(`${path}/${created.id}/update`, {
...kept.values,
personId: null,
status: "retired",
});
assert.equal(retired.history.length, 4);
assert.equal(retired.history[1]?.values.personId, ownPerson.id);
const templateResponse = await request(`/asset-catalog/${companyId}`, {
values: {
kind: "template",
name: "Monitor obligatorio inventario",
description: "",
fields: [{ fieldId: inch.id, required: true }],
},
});
assert.equal(templateResponse.status, 201);
const templateId = SaveDefinitionResponseSchema.parse(await templateResponse.json()).savedId;
await error(path, { ...input, templateId }, "REQUIRED_CHARACTERISTICS");
await error(`/assets/${otherId}`, { ...input, templateId }, "TEMPLATE_UNAVAILABLE");
const custom = await save(path, {
...input,
templateId,
name: "MON-02",
characteristics: { [inch.id]: 0 },
});
const revisionId = custom.history[0]?.id;
assert.equal(
(
await request(`/asset-catalog/${companyId}/${templateId}/update`, {
values: {
kind: "template",
name: "Plantilla renombrada",
description: "",
fields: [{ fieldId: inch.id, required: false }],
},
})
).status,
200
);
const current = (await read()).assets.find((asset) => asset.id === custom.id);
assert.ok(current);
assert.equal(current.specification.templateName, "Monitor obligatorio inventario");
assert.equal(current.specification.fields[0]?.required, true);
await error(
`${path}/${custom.id}/update`,
{ ...custom.values, characteristics: {} },
"REQUIRED_CHARACTERISTICS"
);
assert.equal(
(await request(`/asset-catalog/${companyId}/${templateId}/status`, { active: false })).status,
200
);
await error(path, { ...input, templateId }, "TEMPLATE_UNAVAILABLE");
const updated = await save(`${path}/${custom.id}/update`, {
...custom.values,
notes: "Plantilla conservada",
});
assert.equal(updated.history[0]?.id, revisionId);
const stored = (await read()).assets.find((asset) => asset.id === updated.id);
assert.deepEqual(stored, updated);
// Corrupción cruzada simulada solo en la base de pruebas y restaurada incluso ante fallo.
const corrupt = { ...updated.values, personId: foreignPerson.id };
const lastRevision = updated.history.at(-1);
assert.ok(lastRevision);
await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", {
replacements: { person: foreignPerson.id, values: JSON.stringify(corrupt), id: updated.id },
});
await database.query(
"UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id",
{
replacements: {
person: foreignPerson.id,
values: JSON.stringify(corrupt),
id: lastRevision.id,
},
}
);
try {
assert.equal((await request(path)).status, 500);
} finally {
await database.query(
"UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id",
{
replacements: {
person: updated.values.personId,
values: JSON.stringify(updated.values),
id: lastRevision.id,
},
}
);
await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", {
replacements: {
person: updated.values.personId,
values: JSON.stringify(updated.values),
id: updated.id,
},
});
}
assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404);
}

View File

@ -20,7 +20,8 @@
"tailwindcss": "4.2.2",
"@repo/shadcn-ui": "workspace:*",
"@metis/identity": "workspace:*",
"@metis/companies": "workspace:*"
"@metis/companies": "workspace:*",
"@metis/assets": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -1,3 +1,4 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { AccessWorkspacePage, CompaniesPage } from "@metis/companies/client";
import { HomePage } from "@metis/core/client";
import { IdentityWorkspace } from "@metis/identity/client";
@ -18,6 +19,7 @@ const LANGUAGE_OPTIONS = [
export function App() {
const { t: companyTranslation } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const { t, i18n } = useTranslation("core");
const [languageError, setLanguageError] = useState(false);
@ -76,6 +78,11 @@ export function App() {
<AccessWorkspacePage csrfToken={session.csrfToken} scope="global" />
),
},
{
id: "asset-catalog",
label: assetTranslation("title"),
render: (session) => <AssetCatalogPage csrfToken={session.csrfToken} scope="global" />,
},
{
id: "companies",
label: companyTranslation("title"),

View File

@ -1,3 +1,4 @@
import { assetsEnglish, assetsSpanish } from "@metis/assets/client";
import {
accessEnglish,
accessSpanish,
@ -21,12 +22,14 @@ export function createAppI18n(language = "es") {
identity: identitySpanish,
companies: companiesSpanish,
access: accessSpanish,
assets: assetsSpanish,
},
en: {
core: coreEnglish,
identity: identityEnglish,
companies: companiesEnglish,
access: accessEnglish,
assets: assetsEnglish,
},
},
interpolation: { escapeValue: false },

View File

@ -45,6 +45,10 @@ Company es la raíz del agregado de compañías clientes. Person, Department, De
AccessWorkspace es una raíz adicional para el catálogo global o la configuración informática de una compañía. Cada espacio conserva su propio UUID. AccessItem y AccessRevision son entidades con UUID propio. AccessValues encapsula la definición tipada e inmutable, con identificadores y fechas reconstruidos como UniqueID y CalendarDate. Las referencias relacionales preservan las dependencias y el historial conserva el actor autenticado. Consultar [Cuentas, grupos y recursos](../cuentas-grupos-y-recursos.md).
Los módulos de documentos, tickets y assets 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.
AssetCatalog es la raíz del catálogo de activos; AssetDefinition, TemplateField y CatalogAvailability son entidades con UUID propio. AssetDefinitionValues reconstruye nombres y referencias como Value Objects. Consultar [Campos y plantillas de activos](../campos-y-plantillas-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).
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.

View File

@ -0,0 +1,46 @@
# Campos y plantillas de activos
## 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.
Las altas y ediciones se abren mediante el botón de cabecera o la acción del listado. Los formularios son modales, con ayuda inferior y Guardar / Cancelar. Ver consulta una definición sin modificarla. Crear copia inicia una nueva plantilla personalizable.
Un campo representa una característica reutilizable: texto, número, fecha, sí/no o lista de valores. Tiene nombre, descripción y unidad opcionales. Una lista necesita valores únicos; el tipo queda fijo tras el alta y sus valores existentes no pueden eliminarse. Se pueden añadir valores. Las credenciales no pertenecen a este catálogo.
Una plantilla reúne campos para un tipo de activo. No representa un activo concreto ni un modelo comercial. Cada campo comienza como opcional; el técnico puede marcarlo obligatorio en plantillas propias. Se pueden añadir y retirar campos. Retirar una asociación no elimina la definición del campo. El orden de incorporación se conserva.
El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta de campo, conserva los datos de la plantilla y añade el nuevo campo como opcional. Cancelar el modal adicional conserva la plantilla. Un campo guardado permanece en el catálogo aunque se cancele después la plantilla.
## 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.
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.
Todas las características de las plantillas de serie son opcionales. Las definiciones de serie no se pueden renombrar ni alterar, tampoco mediante la API. Se pueden activar o desactivar. Para cambiar los campos de una plantilla de serie, crear una copia. No hay borrado de definiciones.
El arranque alimenta únicamente definiciones ausentes mediante claves de serie estables. Reiniciar no duplica registros, regenera UUID ni reactiva definiciones desactivadas. No crea compañías, personas ni activos ficticios.
## Ámbitos y disponibilidad
Una definición global está disponible para todas las compañías. Una adición local solo está visible en su compañía. No se pueden editar definiciones globales desde una compañía; se puede crear una copia de una plantilla.
La disponibilidad global es el valor predeterminado. Cada compañía puede establecer una activación o desactivación propia de una definición global, sin afectar a otras. Usar configuración global elimina el efecto del ajuste local, conservando su UUID.
No se aceptan nombres duplicados del mismo tipo en el catálogo visible, ignorando mayúsculas y espacios exteriores. Una nueva definición global tampoco puede colisionar con una adición local.
Las plantillas globales solo usan campos globales. Las locales pueden usar campos globales o de su propia compañía. Solo se añaden campos activos. Desactivar un campo conserva las asociaciones existentes y muestra un aviso; no desactiva automáticamente plantillas ni borra información.
## Arquitectura
El módulo assets sigue domain, application e infrastructure. AssetCatalog es la raíz de consistencia; AssetDefinition, TemplateField y CatalogAvailability son entidades. Todas tienen UniqueID; las nuevas identidades son UUID v7. AssetDefinitionValues reconstruye Name y referencias UniqueID y compara cambios semánticamente. Los cambios idempotentes conservan instancias e identificadores.
Las tablas asset_catalogs, asset_definitions, asset_template_fields y asset_availability tienen claves primarias y foráneas explícitas. Las migraciones son idempotentes. Un bloqueo de catálogo serializa altas, cambios de nombres, campos asociados y disponibilidad para evitar colisiones concurrentes. El mapper reconstruye y comprueba definiciones y asociaciones persistidas.
Las rutas GET /api/asset-catalog/:scope, POST /api/asset-catalog/:scope, POST /api/asset-catalog/:scope/:id/update y POST /api/asset-catalog/:scope/:id/status requieren sesión. Las escrituras usan la protección de origen y CSRF existente. scope es global o el UUID de una compañía existente. La respuesta solo expone el catálogo visible de ese ámbito.
## Siguiente entrega
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.

View File

@ -85,3 +85,7 @@ La revisión de los formularios extiende este patrón a personas, departamentos,
Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos heredan el tipo del recurso elegido. Al crear un grupo desde una pertenencia, sistema y entorno quedan fijados a los de la cuenta. Las plantillas siguen siendo globales; el modal lo explica y carga únicamente sus opciones globales. Los elementos creados desde un selector están activos para poder seleccionarlos.
Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas.
El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias.
El inventario añade altas rápidas de plantilla, sede y persona en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.

View File

@ -0,0 +1,45 @@
# Inventario de activos
## Uso
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado, sede o persona. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, persona, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla.
Estados: Disponible, En uso, En mantenimiento y Retirado. Estado y asignación se registran de forma explícita: un equipo compartido puede estar en uso sin una persona única. Para retirarlo debe quedar sin persona asignada; puede conservar una sede y todo su historial. No hay borrado de activos ni transferencia entre compañías.
La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk.
## Características y plantillas
El alta utiliza una plantilla activa global o propia de la compañía. Se captura una copia de su nombre, campos, tipos, unidades, valores de lista y obligatoriedad. La plantilla del activo queda fija. Cambiar o desactivar la definición del catálogo no cambia las fichas ni las revisiones existentes. Un activo existente sigue siendo editable aunque su plantilla se desactive.
Esta decisión evita que una edición del catálogo elimine características o convierta datos ya guardados en inválidos. Las nuevas características y opciones incorporadas posteriormente a una plantilla se aplican a los nuevos activos. La adaptación de fichas existentes a futuras versiones queda fuera de esta entrega.
Se validan texto, número, fecha real, sí/no y lista. No se aceptan campos ajenos a la especificación, tipos incorrectos ni opciones de lista desconocidas. Los valores cero y falso se conservan y satisfacen la obligatoriedad; un campo sin completar se omite. No se almacenan contraseñas mediante este catálogo.
## Alta rápida y ayuda
Plantilla, sede y persona reutilizan SelectWithAction. El botón «+» abre un modal adicional; guardar selecciona el elemento creado sin perder los datos del activo y cancelar conserva el formulario principal. La nueva plantilla es exclusiva de esa compañía.
Solo se permite un nivel de alta adicional: al crear una plantilla desde el activo se seleccionan campos existentes, sin abrir un tercer modal de alta de campo. Los elementos guardados permanecen aunque después se cancele el activo. El modal explica este comportamiento. Los estados y las opciones de una característica son valores cerrados para esa ficha.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. Los formularios amplios tienen desplazamiento y conservan navegación por teclado. La fecha se confirma al introducirla y se conserva al actualizar otros campos o cancelar un alta relacionada.
## Historial y arquitectura
Asset es la raíz de consistencia de cada activo. AssetRevision conserva una instantánea completa de cada cambio con UUID propio, actor autenticado y fecha UTC. AssetValues y AssetSpecification son Value Objects inmutables con igualdad semántica. Las referencias se reconstruyen como UniqueID; Name representa los nombres.
El alta crea una primera revisión. Cambiar datos, estado, sede, persona o características añade una revisión, conservando la identidad del activo y las revisiones anteriores. Guardar los mismos valores normalizados no crea otra revisión. La ficha muestra todas las revisiones y sus valores, no solo un contador.
Las migraciones explícitas crean assets y asset_revisions, con claves foráneas para compañía, plantilla, sede, persona y actor; las revisiones también conservan las referencias de sede y persona. Infrastructure reconstruye y comprueba identidades, tipos, historial y pertenencia. No regenera identificadores ni sustituye datos corruptos.
Las escrituras son transaccionales, bloquean catálogo y compañía en un orden fijo y guardan únicamente revisiones nuevas. El repositorio valida la pertenencia de referencias y el ámbito del activo. Las operaciones se mantienen en domain, application e infrastructure; Sequelize queda en Infrastructure.
GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y POST /api/assets/:companyId/:id/update crean y editan. Todas requieren sesión; las escrituras usan origen y CSRF. El actor procede de la sesión, nunca del cuerpo enviado por el navegador.
## Validación y siguientes entregas
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.

View File

@ -35,20 +35,24 @@ Personas sin acceso a Metis Desk, contactos como clasificación, puesto general
Catálogos globales con adiciones por compañía, entornos, cuentas con uso fijo y caducidad opcional, relaciones con personas, plantillas globales de grupos, pertenencias sin anidación, recursos y permisos configurables, necesidades y cobertura calculada. Las autorizaciones y configuraciones se registran por separado, con historial, actor y avisos de revisión. Consultar [Cuentas, grupos y recursos](cuentas-grupos-y-recursos.md).
## Siguientes entregas: orden propuesto
## Campos y plantillas de activos: implementado
1. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
2. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
3. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
4. Modelos de activos, inventario, relaciones y conjuntos de puesto de trabajo.
Catálogo global con 30 campos y 12 plantillas de serie protegidas, cinco tipos de dato, copias personalizables, adiciones por compañía y ajustes locales de disponibilidad. Características opcionales por defecto, modales con ayuda y alta rápida de campos desde las plantillas. Consultar [Campos y plantillas de activos](campos-y-plantillas-de-activos.md).
## Siguientes entregas: orden actualizado
1. Relaciones entre activos y conjuntos de puesto de trabajo.
2. Perfil de configuración por dispositivo: correos con POP/IMAP, carpetas compartidas, impresoras y recursos.
3. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
4. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
## Inventario: pendiente
## Inventario: implementado
Infraestructura por sede: routers, firewalls, switches, puntos de acceso, NAS y servidores.
Puestos de trabajo y asignaciones: equipos, monitores, impresoras y usuarios.
Definir identificación, estado e historial de movimientos.
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.
## Tickets e intervenciones: pendiente
@ -62,4 +66,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
## Decisiones pendientes
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. Inventario y 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; las relaciones entre activos y los tickets siguen pendientes.

View File

@ -0,0 +1,40 @@
{
"name": "@metis/assets",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./common": "./src/common/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"sequelize": "6.37.8",
"zod": "4.3.6",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "25.6.0",
"@types/express": "4.17.21",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"peerDependencies": {
"express": "4.22.1",
"react": "19.2.5",
"react-dom": "19.2.5",
"i18next": "26.0.4",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
}
}

View File

@ -0,0 +1,31 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import type { CatalogResponseDTO } from "../../common";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
export function buildCatalogResponse(
catalog: AssetCatalog,
scope: Maybe<UniqueID>
): CatalogResponseDTO {
return {
items: catalog.visible(scope).map((item) => ({
id: item.id.toPrimitive(),
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
builtin: item.stockKey.getOrUndefined() !== undefined,
active: item.active,
effectiveActive: catalog.active(item, scope),
overridden: catalog.availability.some(
(entry) =>
entry.companyId.equals(scope.getOrUndefined()) &&
entry.definitionId.equals(item.id) &&
entry.active.getOrUndefined() !== undefined
),
values: item.values.data,
bindings: item.bindings.map((binding) => ({
id: binding.id.toPrimitive(),
fieldId: binding.fieldId.toPrimitive(),
required: binding.required,
})),
})),
};
}

View File

@ -0,0 +1,12 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
import type { AssetCatalogError } from "../domain/asset-definition";
export interface IAssetCatalogRepository {
get(scope: Maybe<UniqueID>): Promise<Result<AssetCatalog, AssetCatalogError>>;
update(
scope: Maybe<UniqueID>,
action: (catalog: AssetCatalog) => Result<AssetCatalog, AssetCatalogError>
): Promise<Result<AssetCatalog, AssetCatalogError>>;
}

View File

@ -0,0 +1,55 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { AssetDefinitionSpec } from "../../common";
import { AssetCatalogError } from "../domain/asset-definition";
import { AssetDefinitionValues } from "../domain/value-objects/asset-definition-values";
import type { IAssetCatalogRepository } from "./asset-catalog.repository";
export function catalogScope(value: string) {
if (value === "global") {
return Result.ok(Maybe.none<UniqueID>());
}
const id = UniqueID.create(value);
return id.isSuccess
? Result.ok(Maybe.some(id.data))
: Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
export class AssetCatalogUseCases {
constructor(private readonly repository: IAssetCatalogRepository) {}
async get(scope: string) {
const parsed = catalogScope(scope);
return parsed.isSuccess ? this.repository.get(parsed.data) : parsed;
}
async save(scope: string, identity: string | undefined, input: AssetDefinitionSpec) {
const parent = catalogScope(scope);
const values = AssetDefinitionValues.create(input);
const id = identity ? UniqueID.create(identity) : Result.ok(undefined);
if (!parent.isSuccess) {
return parent;
}
if (!values.isSuccess) {
return values;
}
if (!id.isSuccess) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return this.repository.update(parent.data, (catalog) =>
catalog.save(parent.data, id.data ? Maybe.some(id.data) : Maybe.none(), values.data)
);
}
async setActive(scope: string, identity: string, active: boolean | null) {
const parent = catalogScope(scope);
const id = UniqueID.create(identity);
if (!parent.isSuccess) {
return parent;
}
if (!id.isSuccess) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
return this.repository.update(parent.data, (catalog) =>
catalog.setActive(parent.data, id.data, active)
);
}
}

View File

@ -0,0 +1,60 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Asset } from "../domain/asset.aggregate";
import { AssetCatalogError } from "../domain/asset-definition";
import type { AssetInput } from "../domain/asset-values";
import { AssetSpecification, AssetValues } from "../domain/value-objects/asset-values";
import type { InventoryContext } from "./asset.repository";
export function mapAssetInput(
input: AssetInput,
companyId: UniqueID,
context: InventoryContext,
previous?: Asset
) {
if (previous && previous.values.toPrimitive().templateId !== input.templateId.toLowerCase()) {
return Result.fail(new AssetCatalogError("TEMPLATE_FIXED"));
}
let specification = previous?.specification;
if (!specification) {
const template = context.catalog
.visible(Maybe.some(companyId))
.find((item) => item.id.toPrimitive() === input.templateId.toLowerCase());
if (
!template ||
template.values.data.kind !== "template" ||
!context.catalog.active(template, Maybe.some(companyId))
) {
return Result.fail(new AssetCatalogError("TEMPLATE_UNAVAILABLE"));
}
const fields = template.values.data.fields.map((binding) => {
const field = context.catalog.definitions.find(
(item) => item.id.toPrimitive() === binding.fieldId
);
if (!field || field.values.data.kind !== "field") {
throw new Error("Plantilla persistida inconsistente.");
}
return {
fieldId: field.id.toPrimitive(),
name: field.values.data.name,
type: field.values.data.type,
unit: field.values.data.unit,
choices: [...field.values.data.choices],
required: binding.required,
};
});
const created = AssetSpecification.create({
templateId: template.id.toPrimitive(),
templateName: template.values.data.name,
fields,
});
if (!created.isSuccess) {
return created;
}
specification = created.data;
}
const values = AssetValues.create(input, specification);
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
}

View File

@ -0,0 +1,17 @@
import type { AssetDTO } from "../../common/inventory";
import type { Asset } from "../domain/asset.aggregate";
export function buildAssetResponse(asset: Asset, actors: ReadonlyMap<string, string>): AssetDTO {
return {
id: asset.id.toPrimitive(),
companyId: asset.companyId.toPrimitive(),
specification: asset.specification.toPrimitive(),
values: asset.values.toPrimitive(),
history: asset.history.map((revision) => ({
id: revision.id.toPrimitive(),
values: revision.values.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
actorName: actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
recordedAt: revision.recordedAt,
})),
};
}

View File

@ -0,0 +1,40 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Asset } from "../domain/asset.aggregate";
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
import { AssetCatalogError } from "../domain/asset-definition";
import type { AssetValues } from "../domain/value-objects/asset-values";
export type InventoryContext = Readonly<{
catalog: AssetCatalog;
assets: readonly Asset[];
sites: readonly UniqueID[];
people: readonly { id: UniqueID; active: boolean }[];
actorNames: ReadonlyMap<string, string>;
}>;
export interface IAssetRepository {
get(companyId: UniqueID): Promise<Result<InventoryContext, AssetCatalogError>>;
save(
companyId: UniqueID,
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
}
export function validateAssetReferences(
context: InventoryContext,
values: AssetValues,
previous?: Asset
) {
const next = values.toPrimitive();
const current = previous?.values.toPrimitive();
if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}
if (next.personId) {
const person = context.people.find((person) => person.id.toPrimitive() === next.personId);
if (!person || (!person.active && current?.personId !== next.personId)) {
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
}
}
return Result.ok<true>(true);
}

View File

@ -0,0 +1,54 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Asset } from "../domain/asset.aggregate";
import { AssetCatalogError } from "../domain/asset-definition";
import type { AssetInput } from "../domain/asset-values";
import type { IAssetRepository } from "./asset.repository";
import { validateAssetReferences } from "./asset.repository";
import { mapAssetInput } from "./asset-input.mapper";
export class AssetUseCases {
constructor(private readonly repository: IAssetRepository) {}
get(companyId: string) {
const company = UniqueID.create(companyId);
if (!company.isSuccess) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.get(company.data);
}
save(companyId: string, id: string | undefined, input: AssetInput, 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.save(company.data, (context) => {
const previous = identity?.isSuccess
? context.assets.find((asset) => asset.id.equals(identity.data))
: undefined;
if (id && !previous) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
const mapped = mapAssetInput(input, company.data, context, previous);
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const references = validateAssetReferences(context, mapped.data.values, previous);
if (!references.isSuccess) {
return Result.fail(references.error);
}
const now = new Date().toISOString();
return previous
? previous.update(mapped.data.values, actor.data, now)
: Asset.create(
company.data,
mapped.data.specification,
mapped.data.values,
actor.data,
now
);
});
}
}

View File

@ -0,0 +1,104 @@
import type { AssetCatalog } from "./asset-catalog.aggregate";
import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values";
const FIELDS: readonly [string, string, AssetFieldType, string][] = [
["brand", "Marca", "text", ""],
["model", "Modelo", "text", ""],
["serial", "Número de serie", "text", ""],
["processor", "Procesador", "text", ""],
["memory", "Memoria RAM", "number", "GB"],
["storage", "Almacenamiento", "text", ""],
["os", "Sistema operativo", "text", ""],
["hostname", "Nombre de equipo", "text", ""],
["screen_size", "Pulgadas", "number", "pulgadas"],
["resolution", "Resolución", "text", ""],
["connections", "Conexiones", "text", ""],
["ip", "Dirección IP", "text", ""],
["mac", "Dirección MAC", "text", ""],
["ports", "Número de puertos", "number", ""],
["speed", "Velocidad de red", "text", ""],
["poe", "PoE", "boolean", ""],
["wifi", "Estándar Wi-Fi", "text", ""],
["capacity", "Capacidad útil", "text", ""],
["disks", "Discos", "text", ""],
["raid", "RAID", "text", ""],
["printing", "Tecnología de impresión", "text", ""],
["color", "Impresión en color", "boolean", ""],
["duplex", "Doble cara", "boolean", ""],
["imei", "IMEI", "text", ""],
["phone", "Número de teléfono", "text", ""],
["carrier", "Operador móvil", "text", ""],
["power", "Potencia", "number", "VA"],
["battery", "Autonomía", "number", "minutos"],
["purchased_on", "Fecha de compra", "date", ""],
["warranty_until", "Fin de garantía", "date", ""],
];
const COMMON = ["brand", "model", "serial", "purchased_on", "warranty_until"];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
[
"laptop",
"Portátil",
[
"processor",
"memory",
"storage",
"os",
"hostname",
"screen_size",
"resolution",
"connections",
"ip",
"mac",
],
],
[
"desktop",
"PC de sobremesa",
["processor", "memory", "storage", "os", "hostname", "connections", "ip", "mac"],
],
["monitor", "Monitor", ["screen_size", "resolution", "connections"]],
["printer", "Impresora", ["printing", "color", "duplex", "connections", "ip", "mac"]],
["router", "Router", ["ports", "speed", "wifi", "ip", "mac"]],
["firewall", "Firewall", ["ports", "speed", "ip", "mac"]],
["switch", "Switch", ["ports", "speed", "poe", "ip", "mac"]],
["access_point", "Punto de acceso Wi-Fi", ["wifi", "poe", "ip", "mac"]],
["nas", "NAS", ["processor", "memory", "disks", "capacity", "raid", "os", "ip", "mac"]],
[
"server",
"Servidor",
["processor", "memory", "storage", "disks", "raid", "os", "hostname", "ip", "mac"],
],
["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]],
["ups", "SAI", ["power", "battery", "connections"]],
];
export function seedAssetCatalog(catalog: AssetCatalog) {
let current = catalog;
const add = (key: string, input: AssetDefinitionSpec) => {
const values = AssetDefinitionValues.create(input);
if (!values.isSuccess) {
throw new Error("Definición inicial de activos inválida.");
}
const result = current.seed(key, values.data);
if (!result.isSuccess) {
throw new Error("Catálogo inicial de activos inconsistente.");
}
current = result.data;
};
for (const [key, name, type, unit] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] });
}
for (const [key, name, fields] of TEMPLATES) {
const references = [...COMMON, ...fields].map((field) => {
const id = current.definitions
.find((item) => item.stockKey.getOrUndefined() === `field:${field}`)
?.id.toPrimitive();
if (!id) {
throw new Error("Campo inicial de activos ausente.");
}
return { fieldId: id, required: false };
});
add(`template:${key}`, { kind: "template", name, description: "", fields: references });
}
return current;
}

View File

@ -0,0 +1,181 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { AssetCatalogError } from "./asset-definition";
import { AssetDefinition, CatalogAvailability } from "./asset-definition.entity";
import type { AssetDefinitionValues } from "./value-objects/asset-definition-values";
type CatalogProps = {
definitions: readonly AssetDefinition[];
availability: readonly CatalogAvailability[];
};
export class AssetCatalog extends AggregateRoot<CatalogProps> {
private constructor(props: CatalogProps, id?: UniqueID) {
super(
{
definitions: Object.freeze([...props.definitions]),
availability: Object.freeze([...props.availability]),
},
id
);
}
static create() {
return new AssetCatalog({ definitions: [], availability: [] });
}
static rehydrate(props: CatalogProps, id: UniqueID) {
return new AssetCatalog(props, id);
}
get definitions() {
return this.props.definitions;
}
get availability() {
return this.props.availability;
}
visible(companyId: Maybe<UniqueID>) {
return this.definitions.filter(
(item) =>
!item.companyId.getOrUndefined() ||
item.companyId.getOrUndefined()?.equals(companyId.getOrUndefined())
);
}
active(item: AssetDefinition, companyId: Maybe<UniqueID>) {
const override = this.availability.find(
(entry) =>
entry.companyId.equals(companyId.getOrUndefined()) && entry.definitionId.equals(item.id)
);
return override?.active.getOrUndefined() ?? item.active;
}
save(
companyId: Maybe<UniqueID>,
id: Maybe<UniqueID>,
values: AssetDefinitionValues
): Result<AssetCatalog, AssetCatalogError> {
const previous = id.getOrUndefined()
? this.definitions.find((item) => item.id.equals(id.getOrUndefined()))
: undefined;
if (id.getOrUndefined() && !(previous && sameScope(previous.companyId, companyId))) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
}
const candidate = previous
? previous.update(values)
: Result.ok(AssetDefinition.create(companyId, values, true));
if (!candidate.isSuccess) {
return candidate;
}
return this.replace(candidate.data, companyId, previous);
}
private replace(
candidate: AssetDefinition,
scope: Maybe<UniqueID>,
previous?: AssetDefinition
): Result<AssetCatalog, AssetCatalogError> {
const conflicts = scope.getOrUndefined() ? this.visible(scope) : this.definitions;
if (
conflicts.some(
(item) =>
!item.id.equals(candidate.id) &&
item.values.data.kind === candidate.values.data.kind &&
item.values.data.name.toLocaleLowerCase("es") ===
candidate.values.data.name.toLocaleLowerCase("es")
)
) {
return Result.fail(new AssetCatalogError("NAME_EXISTS"));
}
const invalidField = this.invalidField(candidate, scope, previous);
if (invalidField) {
return Result.fail(new AssetCatalogError("FIELD_UNAVAILABLE"));
}
if (candidate === previous) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
...this.props,
definitions: previous
? this.definitions.map((item) => (item.id.equals(candidate.id) ? candidate : item))
: [...this.definitions, candidate],
},
this.id
)
);
}
private invalidField(
candidate: AssetDefinition,
scope: Maybe<UniqueID>,
previous?: AssetDefinition
) {
return candidate.values.fieldIds.some((id) => {
const field = this.visible(scope).find((item) => item.id.equals(id));
if (!field || field.values.data.kind !== "field") {
return true;
}
return !(
this.active(field, scope) ||
previous?.values.fieldIds.some((existing) => existing.equals(id))
);
});
}
seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> {
if (this.definitions.some((item) => item.stockKey.getOrUndefined() === stockKey)) {
return Result.ok(this);
}
return this.replace(
AssetDefinition.create(Maybe.none(), values, true, Maybe.some(stockKey)),
Maybe.none()
);
}
setActive(
scope: Maybe<UniqueID>,
id: UniqueID,
active: boolean | null
): Result<AssetCatalog, AssetCatalogError> {
const item = this.visible(scope).find((entry) => entry.id.equals(id));
if (!item) {
return Result.fail(new AssetCatalogError("DEFINITION_NOT_FOUND"));
}
const company = scope.getOrUndefined();
if (company && !item.companyId.getOrUndefined()) {
const previous = this.availability.find(
(entry) => entry.companyId.equals(company) && entry.definitionId.equals(id)
);
const value = active === null ? Maybe.none<boolean>() : Maybe.some(active);
const changed = previous
? previous.update(value)
: CatalogAvailability.create(company, id, value);
if (previous === changed || (!previous && active === null)) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
...this.props,
availability: previous
? this.availability.map((entry) => (entry.id.equals(previous.id) ? changed : entry))
: [...this.availability, changed],
},
this.id
)
);
}
if (active === null) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const changed = item.setActive(active);
if (changed === item) {
return Result.ok(this);
}
return Result.ok(
new AssetCatalog(
{
...this.props,
definitions: this.definitions.map((entry) => (entry.id.equals(id) ? changed : entry)),
},
this.id
)
);
}
}
function sameScope(a: Maybe<UniqueID>, b: Maybe<UniqueID>) {
return a.getOrUndefined()?.toPrimitive() === b.getOrUndefined()?.toPrimitive();
}

View File

@ -0,0 +1,137 @@
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 type { AssetDefinitionSpec } from "./asset-definition";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values";
const global = Maybe.none<UniqueID>();
function values(spec: AssetDefinitionSpec) {
const result = AssetDefinitionValues.create(spec);
assert.ok(result.isSuccess);
return result.data;
}
function field(name: string) {
return values({ kind: "field", name, type: "text", unit: "", choices: [], description: "" });
}
function changed(result: ReturnType<AssetCatalog["save"]>) {
assert.ok(result.isSuccess);
return result.data;
}
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
assert.equal(catalog.definitions.length, 42);
assert.equal(seedAssetCatalog(catalog), catalog);
const ids = [
catalog.id,
...catalog.definitions.map((item) => item.id),
...catalog.definitions.flatMap((item) => item.bindings.map((binding) => binding.id)),
].map((id) => id.toPrimitive());
assert.equal(new Set(ids).size, ids.length);
assert.ok(ids.every((id) => id.charAt(14) === "7"));
const templates = catalog.definitions.filter((item) => item.values.data.kind === "template");
assert.equal(templates.length, 12);
assert.ok(templates.every((item) => item.bindings.every((field) => !field.required)));
const rehydrated = AssetCatalog.rehydrate(
{ definitions: catalog.definitions, availability: catalog.availability },
catalog.id
);
assert.ok(rehydrated.id.equals(catalog.id));
assert.ok(rehydrated.definitions[0]?.id.equals(catalog.definitions[0]?.id));
const stock = catalog.definitions[0];
assert.ok(stock);
const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio"));
assert.ok(!protectedResult.isSuccess);
assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED");
});
test("la disponibilidad por compañía conserva el global y los campos desactivados", () => {
let catalog = seedAssetCatalog(AssetCatalog.create());
const first = Maybe.some(UniqueID.generateNewID());
const second = Maybe.some(UniqueID.generateNewID());
const stock = catalog.definitions[0];
assert.ok(stock);
catalog = changed(catalog.setActive(first, stock.id, false));
assert.equal(catalog.active(stock, first), false);
assert.equal(catalog.active(stock, second), true);
assert.equal(stock.active, true);
assert.equal(catalog.definitions.length, 42);
const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId);
assert.equal(changed(catalog.setActive(first, stock.id, false)), catalog);
catalog = changed(catalog.setActive(first, stock.id, null));
assert.equal(catalog.active(stock, first), true);
assert.ok(catalog.availability[0]?.id.equals(overrideId));
catalog = changed(catalog.setActive(global, stock.id, false));
assert.equal(seedAssetCatalog(catalog), catalog);
assert.equal(catalog.active(catalog.definitions[0] ?? stock, first), false);
});
test("las plantillas validan ámbito, campos activos e identidad estable al editar", () => {
let catalog = seedAssetCatalog(AssetCatalog.create());
const company = Maybe.some(UniqueID.generateNewID());
const other = Maybe.some(UniqueID.generateNewID());
catalog = changed(catalog.save(company, Maybe.none(), field("Campo local")));
const local = catalog.definitions.at(-1);
assert.ok(local);
const template = values({
kind: "template",
name: "Plantilla local",
description: "",
fields: [{ fieldId: local.id.toPrimitive(), required: false }],
});
assert.ok(!catalog.save(global, Maybe.none(), template).isSuccess);
assert.ok(!catalog.save(other, Maybe.none(), template).isSuccess);
catalog = changed(catalog.save(company, Maybe.none(), template));
const saved = catalog.definitions.at(-1);
assert.ok(saved);
assert.equal(changed(catalog.save(company, Maybe.some(saved.id), template)), catalog);
const updated = values({ ...template.data, name: "Otra denominación" });
catalog = changed(catalog.save(company, Maybe.some(saved.id), updated));
const edited = catalog.definitions.at(-1);
assert.ok(edited);
assert.ok(edited.id.equals(saved.id));
assert.ok(edited.bindings[0]?.id.equals(saved.bindings[0]?.id));
catalog = changed(catalog.setActive(company, local.id, false));
assert.ok(
!catalog.save(company, Maybe.none(), values({ ...template.data, name: "Nueva" })).isSuccess
);
assert.ok(catalog.save(company, Maybe.some(saved.id), updated).isSuccess);
assert.ok(!catalog.visible(other).some((item) => item.id.equals(local.id)));
});
test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", () => {
const list: AssetDefinitionSpec = {
kind: "field",
name: "Batería",
description: "",
type: "choice",
unit: "",
choices: ["Buena", "Sustituir"],
};
assert.ok(!AssetDefinitionValues.create({ ...list, choices: [] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, choices: ["Buena", " buena "] }).isSuccess);
assert.ok(!AssetDefinitionValues.create({ ...list, type: "text" }).isSuccess);
const frozen = values(list);
assert.ok(Object.isFrozen(frozen.data));
if (frozen.data.kind === "field") {
assert.ok(Object.isFrozen(frozen.data.choices));
}
let catalog = AssetCatalog.create();
catalog = changed(catalog.save(global, Maybe.none(), frozen));
const saved = catalog.definitions[0];
assert.ok(saved);
assert.ok(!catalog.save(global, Maybe.none(), values({ ...list, name: " batería " })).isSuccess);
assert.ok(!catalog.save(global, Maybe.some(saved.id), field("Batería")).isSuccess);
assert.ok(
!catalog.save(global, Maybe.some(saved.id), values({ ...list, choices: ["Buena"] })).isSuccess
);
assert.ok(
catalog.save(
global,
Maybe.some(saved.id),
values({ ...list, choices: [...list.choices, "Desconocida"] })
).isSuccess
);
});

View File

@ -0,0 +1,131 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { AssetCatalogError } from "./asset-definition";
import type { AssetDefinitionValues } from "./value-objects/asset-definition-values";
export class TemplateField extends DomainEntity<{ fieldId: UniqueID; required: boolean }> {
static create(fieldId: UniqueID, required: boolean, id?: UniqueID) {
return new TemplateField({ fieldId, required }, id);
}
static rehydrate(fieldId: UniqueID, required: boolean, id: UniqueID) {
return new TemplateField({ fieldId, required }, id);
}
get fieldId() {
return this.props.fieldId;
}
get required() {
return this.props.required;
}
}
type DefinitionProps = {
companyId: Maybe<UniqueID>;
stockKey: Maybe<string>;
values: AssetDefinitionValues;
active: boolean;
bindings: readonly TemplateField[];
};
export class AssetDefinition extends DomainEntity<DefinitionProps> {
private constructor(props: DefinitionProps, id?: UniqueID) {
super({ ...props, bindings: Object.freeze([...props.bindings]) }, id);
}
static create(
companyId: Maybe<UniqueID>,
values: AssetDefinitionValues,
active: boolean,
stockKey = Maybe.none<string>()
) {
const bindings = values.fieldIds.map((id, index) =>
TemplateField.create(
id,
values.data.kind === "template" && (values.data.fields[index]?.required ?? false)
)
);
return new AssetDefinition({ companyId, stockKey, values, active, bindings });
}
static rehydrate(props: DefinitionProps, id: UniqueID) {
return new AssetDefinition(props, id);
}
get companyId() {
return this.props.companyId;
}
get stockKey() {
return this.props.stockKey;
}
get values() {
return this.props.values;
}
get active() {
return this.props.active;
}
get bindings() {
return this.props.bindings;
}
update(values: AssetDefinitionValues): Result<AssetDefinition, AssetCatalogError> {
if (this.stockKey.getOrUndefined()) {
return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED"));
}
const previous = this.values.data;
const next = values.data;
if (
previous.kind !== next.kind ||
(previous.kind === "field" &&
next.kind === "field" &&
(previous.type !== next.type ||
previous.choices.some((choice) => !next.choices.includes(choice))))
) {
return Result.fail(new AssetCatalogError("FIELD_TYPE_FIXED"));
}
if (this.values.equals(values)) {
return Result.ok(this);
}
const bindings = values.fieldIds.map((fieldId, index) =>
TemplateField.create(
fieldId,
next.kind === "template" && (next.fields[index]?.required ?? false),
this.bindings.find((field) => field.fieldId.equals(fieldId))?.id
)
);
return Result.ok(new AssetDefinition({ ...this.props, values, bindings }, this.id));
}
setActive(active: boolean) {
return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id);
}
}
export class CatalogAvailability extends DomainEntity<{
companyId: UniqueID;
definitionId: UniqueID;
active: Maybe<boolean>;
}> {
static create(
companyId: UniqueID,
definitionId: UniqueID,
active: Maybe<boolean>,
id?: UniqueID
) {
return new CatalogAvailability({ companyId, definitionId, active }, id);
}
static rehydrate(
companyId: UniqueID,
definitionId: UniqueID,
active: Maybe<boolean>,
id: UniqueID
) {
return new CatalogAvailability({ companyId, definitionId, active }, id);
}
get companyId() {
return this.props.companyId;
}
get definitionId() {
return this.props.definitionId;
}
get active() {
return this.props.active;
}
update(active: Maybe<boolean>) {
return active.getOrUndefined() === this.active.getOrUndefined()
? this
: CatalogAvailability.create(this.companyId, this.definitionId, active, this.id);
}
}

View File

@ -0,0 +1,28 @@
import { z } from "zod/v4";
const title = z.string().min(1).max(100);
export const FieldTypeSchema = z.enum(["text", "number", "date", "boolean", "choice"]);
export const TemplateFieldSchema = z.strictObject({ fieldId: z.uuid(), required: z.boolean() });
export const AssetDefinitionSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("field"),
name: title,
description: z.string().max(1000),
type: FieldTypeSchema,
unit: z.string().max(30),
choices: z.array(title).max(100),
}),
z.strictObject({
kind: z.literal("template"),
name: title,
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),
}),
]);
export type AssetDefinitionSpec = z.infer<typeof AssetDefinitionSchema>;
export type AssetFieldType = z.infer<typeof FieldTypeSchema>;
export class AssetCatalogError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { AssetValues } from "./value-objects/asset-values";
export type AssetRevisionProps = Readonly<{
values: AssetValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class AssetRevision extends DomainEntity<AssetRevisionProps> {
static create(props: AssetRevisionProps) {
return new AssetRevision(props);
}
static rehydrate(props: AssetRevisionProps, id: UniqueID) {
return new AssetRevision(props, id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}
}

View File

@ -0,0 +1,31 @@
import { z } from "zod/v4";
import { FieldTypeSchema } from "./asset-definition";
export const AssetStatusSchema = z.enum(["available", "in_use", "maintenance", "retired"]);
export const AssetCharacteristicSchema = z.strictObject({
fieldId: z.uuid(),
name: z.string().min(1).max(100),
type: FieldTypeSchema,
unit: z.string().max(30),
choices: z.array(z.string().min(1).max(100)),
required: z.boolean(),
});
export const AssetValueSchema = z.union([z.string().max(10000), z.number().finite(), z.boolean()]);
export const AssetInputSchema = z.strictObject({
name: z.string().min(1).max(100),
templateId: z.uuid(),
siteId: z.uuid().nullable(),
personId: z.uuid().nullable(),
status: AssetStatusSchema,
notes: z.string().max(10000),
characteristics: z.record(z.uuid(), AssetValueSchema),
});
export const AssetSpecificationSchema = z.strictObject({
templateId: z.uuid(),
templateName: z.string().min(1).max(100),
fields: z.array(AssetCharacteristicSchema).min(1).max(100),
});
export type AssetInput = z.infer<typeof AssetInputSchema>;
export type AssetSpecificationData = z.infer<typeof AssetSpecificationSchema>;
export type AssetCharacteristic = z.infer<typeof AssetCharacteristicSchema>;

View File

@ -0,0 +1,70 @@
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { AssetCatalogError } from "./asset-definition";
import { AssetRevision } from "./asset-revision.entity";
import type { AssetSpecification, AssetValues } from "./value-objects/asset-values";
type AssetProps = Readonly<{
companyId: UniqueID;
specification: AssetSpecification;
values: AssetValues;
history: readonly AssetRevision[];
}>;
export class Asset extends AggregateRoot<AssetProps> {
private constructor(props: AssetProps, id?: UniqueID) {
super({ ...props, history: Object.freeze([...props.history]) }, id);
}
static create(
companyId: UniqueID,
specification: AssetSpecification,
values: AssetValues,
actorId: UniqueID,
recordedAt: string
) {
return Result.ok<Asset>(
new Asset({
companyId,
specification,
values,
history: [AssetRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: AssetProps, id: UniqueID) {
return new Asset(props, id);
}
get companyId() {
return this.props.companyId;
}
get specification() {
return this.props.specification;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(
values: AssetValues,
actorId: UniqueID,
recordedAt: string
): Result<Asset, AssetCatalogError> {
if (values.toPrimitive().templateId !== this.values.toPrimitive().templateId) {
return Result.fail(new AssetCatalogError("TEMPLATE_FIXED"));
}
return Result.ok(
this.values.equals(values)
? this
: new Asset(
{
...this.props,
values,
history: [...this.history, AssetRevision.create({ values, actorId, recordedAt })],
},
this.id
)
);
}
}

View File

@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Asset } from "./asset.aggregate";
import type { AssetInput } from "./asset-values";
import { AssetSpecification, AssetValues } from "./value-objects/asset-values";
const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const templateId = UniqueID.generateNewID().toPrimitive();
const ids = Array.from({ length: 5 }, () => UniqueID.generateNewID().toPrimitive());
function specification(required = false) {
const types = ["text", "number", "date", "boolean", "choice"] as const;
const result = AssetSpecification.create({
templateId,
templateName: "Portátil",
fields: ids.map((fieldId, index) => ({
fieldId,
name: types[index] ?? "text",
type: types[index] ?? "text",
unit: "",
choices: index === 4 ? ["A", "B"] : [],
required: required && index === 3,
})),
});
assert.ok(result.isSuccess);
return result.data;
}
function input(characteristics: AssetInput["characteristics"] = {}): AssetInput {
return {
name: " Equipo ",
templateId,
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics,
};
}
test("Características opcionales, tipos y límites; cero y falso son valores presentes", () => {
const spec = specification();
assert.ok(AssetValues.create(input(), spec).isSuccess);
const typed = Object.fromEntries(
ids.map((id, index) => [id, ["texto", 0, "2026-02-28", false, "A"][index] ?? ""])
);
const valid = AssetValues.create(input(typed), spec);
assert.ok(valid.isSuccess);
assert.equal(valid.data.toPrimitive().name, "Equipo");
for (const [index, value] of [
[1, "12"],
[2, "2026-02-30"],
[3, "false"],
[4, "C"],
] as const) {
const id = ids[index];
assert.ok(id);
const failed = AssetValues.create(input({ [id]: value }), spec);
assert.equal(failed.isSuccess, false);
}
assert.equal(
AssetValues.create(input({ [UniqueID.generateNewID().toPrimitive()]: "extra" }), spec)
.isSuccess,
false
);
const required = specification(true);
assert.equal(AssetValues.create(input(), required).isSuccess, false);
const boolean = ids[3];
assert.ok(boolean);
assert.ok(AssetValues.create(input({ [boolean]: false }), required).isSuccess);
assert.equal(
AssetValues.create(input({ [boolean]: false, [ids[0] ?? ""]: "x".repeat(10001) }), spec)
.isSuccess,
false
);
});
test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes y retirado no admite asignación", () => {
const spec = specification();
const first = AssetValues.create(input(), spec);
assert.ok(first.isSuccess);
const created = Asset.create(company, spec, first.data, actor, "2026-10-04T12:00:00.000Z");
assert.ok(created.isSuccess);
const asset = created.data;
assert.equal(asset.id.toPrimitive().charAt(14), "7");
assert.equal(asset.history[0]?.id.toPrimitive().charAt(14), "7");
const same = asset.update(first.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(same.isSuccess);
assert.equal(same.data, asset);
const next = AssetValues.create(
{ ...input(), personId: UniqueID.generateNewID().toPrimitive(), status: "in_use" },
spec
);
assert.ok(next.isSuccess);
const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(asset.id));
assert.equal(changed.data.history.length, 2);
assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null);
assert.equal(
AssetValues.create({ ...next.data.toPrimitive(), status: "retired" }, spec).isSuccess,
false
);
assert.ok(Object.isFrozen(next.data.toPrimitive().characteristics));
});

View File

@ -0,0 +1,95 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { AssetDefinitionSpec } from "../asset-definition";
import { AssetCatalogError, AssetDefinitionSchema } from "../asset-definition";
/** Definición inmutable con nombre y referencias reconstruidos como Value Objects. */
export class AssetDefinitionValues {
private constructor(
public readonly data: Readonly<AssetDefinitionSpec>,
public readonly name: Name,
public readonly fieldIds: readonly UniqueID[]
) {
Object.freeze(this);
}
static create(input: AssetDefinitionSpec): Result<AssetDefinitionValues, AssetCatalogError> {
const parsed = AssetDefinitionSchema.safeParse(input);
const name = Name.create(input.name, 100);
if (!(parsed.success && name.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const data = parsed.data;
if (data.kind === "field") {
const choices = data.choices.map((value) => value.trim());
if (
(data.type === "choice" && !choices.length) ||
(data.type !== "choice" && choices.length) ||
choices.some((choice) => !choice) ||
new Set(choices.map((choice) => choice.toLocaleLowerCase("es"))).size !== choices.length
) {
return Result.fail(new AssetCatalogError("INVALID_CHOICES"));
}
const values = {
...data,
name: name.data.toPrimitive(),
description: data.description.trim(),
unit: data.unit.trim(),
choices,
};
Object.freeze(values.choices);
Object.freeze(values);
return Result.ok(new AssetDefinitionValues(values, name.data, Object.freeze([])));
}
const ids: UniqueID[] = [];
for (const field of data.fields) {
const id = UniqueID.create(field.fieldId);
if (!id.isSuccess || ids.some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_FIELDS"));
}
ids.push(id.data);
}
const fields = data.fields.map((field, index) => ({
fieldId: ids[index]?.toPrimitive() ?? field.fieldId,
required: field.required,
}));
fields.forEach(Object.freeze);
Object.freeze(fields);
return Result.ok(
new AssetDefinitionValues(
Object.freeze({
...data,
name: name.data.toPrimitive(),
description: data.description.trim(),
fields,
}),
name.data,
Object.freeze(ids)
)
);
}
equals(other: AssetDefinitionValues): boolean {
const a = this.data;
const b = other.data;
if (a.kind !== b.kind || a.name !== b.name || a.description !== b.description) {
return false;
}
if (a.kind === "field" && b.kind === "field") {
return (
a.type === b.type &&
a.unit === b.unit &&
a.choices.length === b.choices.length &&
a.choices.every((choice, index) => choice === b.choices[index])
);
}
return (
a.kind === "template" &&
b.kind === "template" &&
a.fields.length === b.fields.length &&
a.fields.every(
(field, index) =>
field.fieldId === b.fields[index]?.fieldId && field.required === b.fields[index]?.required
)
);
}
}

View File

@ -0,0 +1,200 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { AssetCatalogError } from "../asset-definition";
import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values";
import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values";
export class AssetSpecification {
private constructor(
private readonly data: AssetSpecificationData,
public readonly templateId: UniqueID,
public readonly name: Name
) {
Object.freeze(this);
}
toPrimitive() {
return this.data;
}
static create(input: AssetSpecificationData): Result<AssetSpecification, AssetCatalogError> {
const parsed = AssetSpecificationSchema.safeParse(input);
if (
!parsed.success ||
new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length
) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const template = UniqueID.create(input.templateId);
const name = Name.create(input.templateName, 100);
if (!(template.isSuccess && name.isSuccess)) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const frozenFields = input.fields.map((field) => {
const choices = [...field.choices];
Object.freeze(choices);
return Object.freeze({ ...field, fieldId: field.fieldId.toLowerCase(), choices });
});
Object.freeze(frozenFields);
return Result.ok(
new AssetSpecification(
Object.freeze({
templateId: template.data.toPrimitive(),
templateName: name.data.toPrimitive(),
fields: frozenFields,
}),
template.data,
name.data
)
);
}
equals(other: AssetSpecification | undefined) {
if (!(other instanceof AssetSpecification)) {
return false;
}
const first = this.toPrimitive();
const second = other.toPrimitive();
return (
first.templateId === second.templateId &&
first.templateName === second.templateName &&
first.fields.length === second.fields.length &&
first.fields.every((field, index) => {
const candidate = second.fields[index];
return (
candidate !== undefined &&
field.fieldId === candidate.fieldId &&
field.name === candidate.name &&
field.type === candidate.type &&
field.unit === candidate.unit &&
field.required === candidate.required &&
field.choices.length === candidate.choices.length &&
field.choices.every((choice, position) => choice === candidate.choices[position])
);
})
);
}
}
function validCharacteristic(field: AssetCharacteristic, value: unknown) {
switch (field.type) {
case "text":
return typeof value === "string";
case "number":
return typeof value === "number" && Number.isFinite(value);
case "boolean":
return typeof value === "boolean";
case "choice":
return typeof value === "string" && field.choices.includes(value);
case "date":
return z.iso.date().safeParse(value).success;
}
}
export class AssetValues {
private constructor(
private readonly data: AssetInput,
public readonly name: Name,
public readonly siteId: Maybe<UniqueID>,
public readonly personId: Maybe<UniqueID>
) {
Object.freeze(this);
}
toPrimitive() {
return this.data;
}
static create(
input: AssetInput,
specification: AssetSpecification
): Result<AssetValues, AssetCatalogError> {
const parsed = AssetInputSchema.safeParse(input);
const name = Name.create(input.name, 100);
if (
!(parsed.success && name.isSuccess) ||
input.templateId.toLowerCase() !== specification.toPrimitive().templateId
) {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const data = parsed.data;
const site = optionalIdentity(data.siteId);
const person = optionalIdentity(data.personId);
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
if (!parsedCharacteristics.isSuccess) {
return parsedCharacteristics;
}
const characteristics = parsedCharacteristics.data;
if (data.status === "retired" && data.personId !== null) {
return Result.fail(new AssetCatalogError("RETIRED_ASSIGNED"));
}
return Result.ok(
new AssetValues(
Object.freeze({
...data,
name: name.data.toPrimitive(),
templateId: data.templateId.toLowerCase(),
siteId: data.siteId?.toLowerCase() ?? null,
personId: data.personId?.toLowerCase() ?? null,
notes: data.notes.trim(),
characteristics: Object.freeze(characteristics),
}),
name.data,
site,
person
)
);
}
equals(other: AssetValues | undefined) {
if (!(other instanceof AssetValues)) {
return false;
}
const first = this.toPrimitive();
const second = other.toPrimitive();
return (
first.name === second.name &&
first.templateId === second.templateId &&
first.siteId === second.siteId &&
first.personId === second.personId &&
first.status === second.status &&
first.notes === second.notes &&
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
Object.entries(first.characteristics).every(
([key, value]) => value === second.characteristics[key]
)
);
}
}
function normalizeCharacteristics(
input: AssetInput["characteristics"],
specification: AssetSpecification
): Result<AssetInput["characteristics"], AssetCatalogError> {
const characteristics: AssetInput["characteristics"] = {};
for (const [key, raw] of Object.entries(input)) {
const field = specification
.toPrimitive()
.fields.find((item) => item.fieldId === key.toLowerCase());
const value = typeof raw === "string" ? raw.trim() : raw;
if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) {
return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS"));
}
if (value !== "") {
characteristics[key.toLowerCase()] = value;
}
}
if (
specification
.toPrimitive()
.fields.some((field) => field.required && !(field.fieldId in characteristics))
) {
return Result.fail(new AssetCatalogError("REQUIRED_CHARACTERISTICS"));
}
return Result.ok(characteristics);
}
function optionalIdentity(value: string | null): Maybe<UniqueID> {
if (value === null) {
return Maybe.none();
}
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Referencia validada inconsistente.");
}
return Maybe.some(parsed.data);
}

View File

@ -0,0 +1,109 @@
import type { Result } from "@repo/rdx-utils";
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
AvailabilityRequestSchema,
CatalogItemParamsSchema,
CatalogParamsSchema,
SaveDefinitionRequestSchema,
} from "../../../common";
import type { AssetCatalogUseCases } from "../../application/asset-catalog.use-cases";
import { catalogScope } from "../../application/asset-catalog.use-cases";
import { buildCatalogResponse } from "../../application/asset-catalog-response.builder";
import type { AssetCatalog } from "../../domain/asset-catalog.aggregate";
import type { AssetCatalogError } from "../../domain/asset-definition";
function sendError(response: Response, code: string) {
const status = code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409;
response.status(status).json({ code, requestId: response.getHeader("x-request-id") });
}
function sendResult(
response: Response,
result: Result<AssetCatalog, AssetCatalogError>,
scope: string,
status = 200,
savedName?: { id?: string | undefined; name: string; kind: string }
) {
if (!result.isSuccess) {
sendError(response, result.error.code);
return;
}
const parsed = catalogScope(scope);
if (!parsed.isSuccess) {
sendError(response, "INVALID_REQUEST");
return;
}
const body = buildCatalogResponse(result.data, parsed.data);
if (!savedName) {
response.status(status).json(body);
return;
}
const savedId =
savedName.id?.toLowerCase() ??
body.items.find(
(item) =>
item.companyId === (scope === "global" ? null : scope.toLowerCase()) &&
item.values.kind === savedName.kind &&
item.values.name === savedName.name.trim()
)?.id;
if (!savedId) {
throw new Error("Resultado del catálogo inconsistente.");
}
response.status(status).json({ ...body, savedId });
}
export function registerAssetCatalogRoutes(
app: Application,
useCases: AssetCatalogUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:scope", (request, response, next) => {
const params = CatalogParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
.get(params.data.scope)
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
for (const path of ["/:scope", "/:scope/:id/update"]) {
router.post(path, (request, response, next) => {
const schema = path.endsWith("update") ? CatalogItemParamsSchema : CatalogParamsSchema;
const params = schema.safeParse(request.params);
const body = SaveDefinitionRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
const identity =
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
useCases
.save(params.data.scope, identity, body.data.values)
.then((result) =>
sendResult(response, result, params.data.scope, identity ? 200 : 201, {
id: identity,
name: body.data.values.name,
kind: body.data.values.kind,
})
)
.catch(next);
});
}
router.post("/:scope/:id/status", (request, response, next) => {
const params = CatalogItemParamsSchema.safeParse(request.params);
const body = AvailabilityRequestSchema.safeParse(request.body);
if (!(params.success && body.success)) {
sendError(response, "INVALID_REQUEST");
return;
}
useCases
.setActive(params.data.scope, params.data.id, body.data.active)
.then((result) => sendResult(response, result, params.data.scope))
.catch(next);
});
app.use("/api/asset-catalog", authenticate, router);
}

View File

@ -0,0 +1,75 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import {
AssetParamsSchema,
InventoryParamsSchema,
SaveAssetRequestSchema,
} from "../../../common/inventory";
import type { AssetUseCases } from "../../application/asset.use-cases";
import { buildAssetResponse } from "../../application/asset-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 registerAssetRoutes(
app: Application,
useCases: AssetUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId", (request, response, next) => {
const params = InventoryParamsSchema.safeParse(request.params);
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.get(params.data.companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({
assets: result.data.assets.map((asset) =>
buildAssetResponse(asset, result.data.actorNames)
),
});
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? AssetParamsSchema : InventoryParamsSchema
).safeParse(request.params);
const body = SaveAssetRequestSchema.safeParse(request.body);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (!(params.success && body.success && actor.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({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) });
})
.catch(next);
});
}
app.use("/api/assets", authenticate, router);
}

View File

@ -0,0 +1,133 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { AssetCatalog } from "../../domain/asset-catalog.aggregate";
import { AssetDefinitionSchema } from "../../domain/asset-definition";
import {
AssetDefinition,
CatalogAvailability,
TemplateField,
} from "../../domain/asset-definition.entity";
import { AssetDefinitionValues } from "../../domain/value-objects/asset-definition-values";
import type {
AvailabilityModel,
BindingModel,
CatalogModel,
DefinitionModel,
} from "./asset-catalog.model";
function identity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de catálogo persistida inconsistente.");
}
return parsed.data;
}
export class AssetCatalogPersistenceMapper {
toDomain(
root: CatalogModel,
rows: readonly DefinitionModel[],
fields: readonly BindingModel[],
overrides: readonly AvailabilityModel[]
) {
const definitions = rows.map((row) => {
const spec = AssetDefinitionSchema.safeParse(row.values);
if (!spec.success) {
throw new Error("Definición de activo persistida inconsistente.");
}
const values = AssetDefinitionValues.create(spec.data);
if (!values.isSuccess) {
throw new Error("Valores de catálogo persistidos inconsistentes.");
}
const bindings = fields
.filter((field) => field.templateId === row.id)
.sort((a, b) => a.position - b.position)
.map((field) =>
TemplateField.rehydrate(identity(field.fieldId), field.required, identity(field.id))
);
if (
bindings.length !== values.data.fieldIds.length ||
bindings.some(
(binding, index) =>
!binding.fieldId.equals(values.data.fieldIds[index]) ||
(values.data.data.kind === "template" &&
binding.required !== values.data.data.fields[index]?.required)
)
) {
throw new Error("Referencias de plantilla persistidas inconsistentes.");
}
return AssetDefinition.rehydrate(
{
values: values.data,
active: row.active,
companyId: row.companyId ? Maybe.some(identity(row.companyId)) : Maybe.none(),
stockKey: row.stockKey ? Maybe.some(row.stockKey) : Maybe.none(),
bindings,
},
identity(row.id)
);
});
const availability = overrides.map((row) =>
CatalogAvailability.rehydrate(
identity(row.companyId),
identity(row.definitionId),
row.active === null ? Maybe.none() : Maybe.some(row.active),
identity(row.id)
)
);
const catalog = AssetCatalog.rehydrate({ definitions, availability }, identity(root.id));
validateReferences(catalog);
return catalog;
}
toPersistence(catalog: AssetCatalog) {
return {
definitions: catalog.definitions.map((item) => ({
id: item.id.toPrimitive(),
catalogId: catalog.id.toPrimitive(),
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
stockKey: item.stockKey.getOrUndefined() ?? null,
values: item.values.data,
active: item.active,
})),
bindings: catalog.definitions.flatMap((item) =>
item.bindings.map((field, position) => ({
id: field.id.toPrimitive(),
templateId: item.id.toPrimitive(),
fieldId: field.fieldId.toPrimitive(),
required: field.required,
position,
}))
),
availability: catalog.availability.map((item) => ({
id: item.id.toPrimitive(),
catalogId: catalog.id.toPrimitive(),
companyId: item.companyId.toPrimitive(),
definitionId: item.definitionId.toPrimitive(),
active: item.active.getOrUndefined() ?? null,
})),
};
}
}
function validateReferences(catalog: AssetCatalog) {
for (const item of catalog.definitions) {
if (item.stockKey.getOrUndefined() && item.companyId.getOrUndefined()) {
throw new Error("Ámbito de definición inicial persistido inconsistente.");
}
for (const fieldId of item.values.fieldIds) {
const field = catalog
.visible(item.companyId)
.find((candidate) => candidate.id.equals(fieldId));
if (!field || field.values.data.kind !== "field") {
throw new Error("Ámbito de campo persistido inconsistente.");
}
}
}
for (const override of catalog.availability) {
const definition = catalog.definitions.find((item) => item.id.equals(override.definitionId));
if (!definition || definition.companyId.getOrUndefined()) {
throw new Error("Disponibilidad de catálogo persistida inconsistente.");
}
}
}

View File

@ -0,0 +1,121 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class CatalogModel extends Model<
InferAttributes<CatalogModel>,
InferCreationAttributes<CatalogModel>
> {
declare id: string;
declare catalogKey: string;
}
export class DefinitionModel extends Model<
InferAttributes<DefinitionModel>,
InferCreationAttributes<DefinitionModel>
> {
declare id: string;
declare catalogId: string;
declare companyId: string | null;
declare stockKey: string | null;
declare values: unknown;
declare active: boolean;
}
export class BindingModel extends Model<
InferAttributes<BindingModel>,
InferCreationAttributes<BindingModel>
> {
declare id: string;
declare templateId: string;
declare fieldId: string;
declare required: boolean;
declare position: number;
}
export class AvailabilityModel extends Model<
InferAttributes<AvailabilityModel>,
InferCreationAttributes<AvailabilityModel>
> {
declare id: string;
declare catalogId: string;
declare companyId: string;
declare definitionId: string;
declare active: boolean | null;
}
const id = { type: DataTypes.UUID, primaryKey: true };
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateAssetCatalog(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 create = async (name: string, columns: Parameters<typeof query.createTable>[1]) => {
if (!tables.some((table) => table.name === name)) {
await query.createTable(name, columns);
}
};
const catalog = {
id,
catalogKey: { type: DataTypes.STRING(20), unique: true, allowNull: false },
};
const definition = {
id,
catalogId: fk("asset_catalogs"),
companyId: fk("companies", true),
stockKey: { type: DataTypes.STRING(100), allowNull: true, unique: true },
values: { type: DataTypes.JSON, allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
};
const binding = {
id,
templateId: fk("asset_definitions"),
fieldId: fk("asset_definitions"),
required: { type: DataTypes.BOOLEAN, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
};
const availability = {
id,
catalogId: fk("asset_catalogs"),
companyId: fk("companies"),
definitionId: fk("asset_definitions"),
active: { type: DataTypes.BOOLEAN, allowNull: true },
};
await create("asset_catalogs", catalog);
await create("asset_definitions", definition);
await create("asset_template_fields", binding);
await create("asset_availability", availability);
for (const [table, fields, name] of [
["asset_template_fields", ["templateId", "fieldId"], "asset_template_field_unique"],
["asset_availability", ["companyId", "definitionId"], "asset_company_availability_unique"],
] as const) {
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table",
{ replacements: { table }, type: QueryTypes.SELECT }
);
if (!indexes.some((index) => index.name === name)) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
CatalogModel.init(catalog, {
sequelize: database,
tableName: "asset_catalogs",
timestamps: false,
});
DefinitionModel.init(definition, {
sequelize: database,
tableName: "asset_definitions",
timestamps: false,
});
BindingModel.init(binding, {
sequelize: database,
tableName: "asset_template_fields",
timestamps: false,
});
AvailabilityModel.init(availability, {
sequelize: database,
tableName: "asset_availability",
timestamps: false,
});
}

View File

@ -0,0 +1,103 @@
import { UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
import { Asset } from "../../domain/asset.aggregate";
import { AssetRevision } from "../../domain/asset-revision.entity";
import { AssetInputSchema, AssetSpecificationSchema } from "../../domain/asset-values";
import { AssetSpecification, AssetValues } from "../../domain/value-objects/asset-values";
import type { AssetModel, AssetRevisionModel } from "./asset.model";
export function inventoryIdentity(value: string) {
const parsed = UniqueID.create(value);
if (!parsed.isSuccess) {
throw new Error("Identidad de inventario persistida inconsistente.");
}
return parsed.data;
}
export class AssetPersistenceMapper {
toDomain(row: AssetModel, revisions: readonly AssetRevisionModel[]) {
const specification = AssetSpecification.create(
AssetSpecificationSchema.parse(row.specification)
);
if (!specification.isSuccess) {
throw new Error("Especificación de activo persistida inconsistente.");
}
const values = AssetValues.create(AssetInputSchema.parse(row.values), specification.data);
if (!values.isSuccess) {
throw new Error("Valores de activo persistidos inconsistentes.");
}
const current = values.data.toPrimitive();
if (
row.templateId !== current.templateId ||
row.siteId !== current.siteId ||
row.personId !== current.personId
) {
throw new Error("Referencias de activo persistidas inconsistentes.");
}
const history = [...revisions]
.sort((a, b) => a.position - b.position)
.map((revision, position) => {
const previous = AssetValues.create(
AssetInputSchema.parse(revision.values),
specification.data
);
if (
!previous.isSuccess ||
revision.position !== position ||
!z.iso.datetime().safeParse(revision.recordedAt).success
) {
throw new Error("Historial de activo persistido inconsistente.");
}
if (
revision.siteId !== previous.data.toPrimitive().siteId ||
revision.personId !== previous.data.toPrimitive().personId
) {
throw new Error("Referencias de revisión persistidas inconsistentes.");
}
return AssetRevision.rehydrate(
{
values: previous.data,
actorId: inventoryIdentity(revision.actorId),
recordedAt: revision.recordedAt,
},
inventoryIdentity(revision.id)
);
});
if (!(history.length && history.at(-1)?.values.equals(values.data))) {
throw new Error("Última revisión de activo inconsistente.");
}
return Asset.rehydrate(
{
companyId: inventoryIdentity(row.companyId),
specification: specification.data,
values: values.data,
history,
},
inventoryIdentity(row.id)
);
}
toPersistence(asset: Asset) {
const values = asset.values.toPrimitive();
return {
asset: {
id: asset.id.toPrimitive(),
companyId: asset.companyId.toPrimitive(),
templateId: values.templateId,
siteId: values.siteId,
personId: values.personId,
specification: asset.specification.toPrimitive(),
values,
},
revisions: asset.history.map((revision, position) => ({
id: revision.id.toPrimitive(),
assetId: asset.id.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
siteId: revision.values.toPrimitive().siteId,
personId: revision.values.toPrimitive().personId,
recordedAt: revision.recordedAt,
values: revision.values.toPrimitive(),
position,
})),
};
}
}

View File

@ -0,0 +1,89 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class AssetModel extends Model<
InferAttributes<AssetModel>,
InferCreationAttributes<AssetModel>
> {
declare id: string;
declare companyId: string;
declare templateId: string;
declare siteId: string | null;
declare personId: string | null;
declare specification: unknown;
declare values: unknown;
}
export class AssetRevisionModel extends Model<
InferAttributes<AssetRevisionModel>,
InferCreationAttributes<AssetRevisionModel>
> {
declare id: string;
declare assetId: string;
declare actorId: string;
declare recordedAt: string;
declare values: unknown;
declare position: number;
declare siteId: string | null;
declare personId: string | null;
}
const id = { type: DataTypes.UUID, primaryKey: true };
const fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateInventory(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 asset = {
id,
companyId: fk("companies"),
templateId: fk("asset_definitions"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
specification: { type: DataTypes.JSON, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
};
const revision = {
id,
assetId: fk("assets"),
actorId: fk("users"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
};
if (!tables.some((table) => table.name === "assets")) {
await query.createTable("assets", asset);
}
if (!tables.some((table) => table.name === "asset_revisions")) {
await query.createTable("asset_revisions", revision);
}
const revisionColumns = await query.describeTable("asset_revisions");
for (const [column, reference] of [
["siteId", "company_sites"],
["personId", "company_contacts"],
] as const) {
if (!(column in revisionColumns)) {
await query.addColumn("asset_revisions", column, fk(reference, true));
}
// Reconstruye las referencias de instalaciones que ya hubieran registrado revisiones.
const source =
column === "siteId"
? "UPDATE asset_revisions SET siteId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.siteId')), 'null')"
: "UPDATE asset_revisions SET personId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.personId')), 'null')";
await database.query(
source + (column === "siteId" ? " WHERE siteId IS NULL" : " WHERE personId IS NULL")
);
}
AssetModel.init(asset, { sequelize: database, tableName: "assets", timestamps: false });
AssetRevisionModel.init(revision, {
sequelize: database,
tableName: "asset_revisions",
timestamps: false,
});
}

View File

@ -0,0 +1,95 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe } from "@repo/rdx-utils";
import { Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Op, QueryTypes } from "sequelize";
import type { IAssetCatalogRepository } from "../../application/asset-catalog.repository";
import { AssetCatalogError } from "../../domain/asset-definition";
import {
AvailabilityModel,
BindingModel,
CatalogModel,
DefinitionModel,
} from "./asset-catalog.model";
import type { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
export class SequelizeAssetCatalogRepository implements IAssetCatalogRepository {
constructor(
private readonly database: Sequelize,
private readonly mapper: AssetCatalogPersistenceMapper
) {}
private async load(scope: Maybe<UniqueID>, transaction: Transaction, write: boolean) {
// Un bloqueo global serializa cambios de definiciones, referencias y disponibilidad.
const root = await CatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo de activos no inicializado.");
}
const company = scope.getOrUndefined();
if (company) {
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM companies WHERE id=:id",
{ replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT, transaction }
);
if (!rows.length) {
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
}
}
const definitions = await DefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const bindings = definitions.length
? await BindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
})
: [];
const overrides = await AvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
return Result.ok(this.mapper.toDomain(root, definitions, bindings, overrides));
}
get(scope: Maybe<UniqueID>) {
return this.database.transaction((transaction) => this.load(scope, transaction, false));
}
update(scope: Maybe<UniqueID>, action: Parameters<IAssetCatalogRepository["update"]>[1]) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(scope, transaction, true);
if (!loaded.isSuccess) {
return loaded;
}
const changed = action(loaded.data);
if (!changed.isSuccess || changed.data === loaded.data) {
return changed;
}
const rows = this.mapper.toPersistence(changed.data);
// Primero las definiciones, después sus relaciones: las FK no admiten huérfanos.
for (const row of rows.definitions) {
await DefinitionModel.upsert(row, { transaction });
}
const previousBindings = loaded.data.definitions.flatMap((item) => item.bindings);
const retained = new Set(rows.bindings.map((row) => row.id));
const removed = previousBindings.filter((field) => !retained.has(field.id.toPrimitive()));
if (removed.length) {
await BindingModel.destroy({
where: { id: { [Op.in]: removed.map((field) => field.id.toPrimitive()) } },
transaction,
});
}
for (const row of rows.bindings) {
await BindingModel.upsert(row, { transaction });
}
for (const row of rows.availability) {
await AvailabilityModel.upsert(row, { transaction });
}
return changed;
});
}
}

View File

@ -0,0 +1,147 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { QueryTypes } from "sequelize";
import type { IAssetRepository, InventoryContext } from "../../application/asset.repository";
import { AssetCatalogError } from "../../domain/asset-definition";
import { AssetModel, AssetRevisionModel } from "./asset.model";
import {
AvailabilityModel,
BindingModel,
CatalogModel,
DefinitionModel,
} from "./asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mapper";
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
export class SequelizeAssetRepository implements IAssetRepository {
private readonly mapper = new AssetPersistenceMapper();
constructor(private readonly database: Sequelize) {}
private async load(
companyId: UniqueID,
transaction: Transaction,
write: boolean
): Promise<Result<InventoryContext, AssetCatalogError>> {
// Orden fijo de bloqueos: catálogo antes de compañía, compartido con las escrituras del catálogo.
const root = await CatalogModel.findOne({
where: { catalogKey: "global" },
transaction,
...(write ? { lock: transaction.LOCK.UPDATE } : {}),
});
if (!root) {
throw new Error("Catálogo no inicializado.");
}
const replacements = { id: companyId.toPrimitive() };
const companies = await this.database.query<{ id: string }>(
`SELECT id FROM companies WHERE id=:id${write ? " FOR UPDATE" : ""}`,
{ replacements, type: QueryTypes.SELECT, transaction }
);
if (!companies.length) {
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
}
const definitions = await DefinitionModel.findAll({
where: { catalogId: root.id },
transaction,
});
const bindings = definitions.length
? await BindingModel.findAll({
where: { templateId: definitions.map((item) => item.id) },
transaction,
})
: [];
const overrides = await AvailabilityModel.findAll({
where: { catalogId: root.id },
transaction,
});
const catalog = new AssetCatalogPersistenceMapper().toDomain(
root,
definitions,
bindings,
overrides
);
const rows = await AssetModel.findAll({
where: { companyId: companyId.toPrimitive() },
transaction,
});
const revisions = rows.length
? await AssetRevisionModel.findAll({
where: { assetId: rows.map((item) => item.id) },
transaction,
})
: [];
const sites = await this.database.query<{ id: string }>(
"SELECT id FROM company_sites WHERE companyId=:id",
{ replacements, type: QueryTypes.SELECT, transaction }
);
const people = await this.database.query<{ id: string; active: boolean }>(
"SELECT id, active FROM company_contacts WHERE companyId=:id",
{ replacements, type: QueryTypes.SELECT, transaction }
);
const actors = await this.database.query<{ id: string; name: string }>(
"SELECT id, name FROM users",
{ type: QueryTypes.SELECT, transaction }
);
const assets = rows.map((row) => {
const asset = this.mapper.toDomain(
row,
revisions.filter((revision) => revision.assetId === row.id)
);
const spec = asset.specification.toPrimitive();
const template = definitions.find((item) => item.id === spec.templateId);
if (
!template ||
(template.companyId !== null && template.companyId !== companyId.toPrimitive())
) {
throw new Error("Plantilla de activo fuera de su compañía.");
}
for (const revision of asset.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))
) {
throw new Error("Asignación de inventario fuera de su compañía.");
}
}
return asset;
});
return Result.ok({
catalog,
assets,
sites: sites.map((site) => inventoryIdentity(site.id)),
people: people.map((person) => ({
id: inventoryIdentity(person.id),
active: Boolean(person.active),
})),
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
});
}
get(companyId: UniqueID) {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
save(companyId: UniqueID, action: Parameters<IAssetRepository["save"]>[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.assets.find((asset) => asset.id.equals(changed.data.id));
if (changed.data !== previous) {
const rows = this.mapper.toPersistence(changed.data);
await AssetModel.upsert(rows.asset, { transaction });
const existing = new Set(previous?.history.map((revision) => revision.id.toPrimitive()));
for (const revision of rows.revisions) {
if (!existing.has(revision.id)) {
await AssetRevisionModel.create(revision, { transaction });
}
}
}
return Result.ok({ asset: changed.data, actorNames: loaded.data.actorNames });
});
}
}

View File

@ -0,0 +1,44 @@
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { RequestHandler } from "express";
import type { Sequelize } from "sequelize";
import { AssetUseCases } from "./application/asset.use-cases";
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
import { migrateInventory } from "./infrastructure/persistence/asset.model";
import {
CatalogModel,
migrateAssetCatalog,
} from "./infrastructure/persistence/asset-catalog.model";
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
export async function createAssetModule(database: Sequelize, authenticate: RequestHandler) {
await migrateAssetCatalog(database);
await CatalogModel.findOrCreate({
where: { catalogKey: "global" },
defaults: { id: UniqueID.generateNewID().toPrimitive(), catalogKey: "global" },
});
const repository = new SequelizeAssetCatalogRepository(
database,
new AssetCatalogPersistenceMapper()
);
const seeded = await repository.update(Maybe.none(), (catalog) =>
Result.ok(seedAssetCatalog(catalog))
);
if (!seeded.isSuccess) {
throw new Error("No se pudo inicializar el catálogo de activos.");
}
await migrateInventory(database);
const inventory = new AssetUseCases(new SequelizeAssetRepository(database));
const useCases = new AssetCatalogUseCases(repository);
return {
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
registerAssetCatalogRoutes(app, useCases, authenticate);
registerAssetRoutes(app, inventory, authenticate);
},
};
}

View File

@ -0,0 +1,30 @@
import { z } from "zod/v4";
import { AssetDefinitionSchema, FieldTypeSchema } from "../api/domain/asset-definition";
export type { AssetDefinitionSpec, AssetFieldType } from "../api/domain/asset-definition";
export { AssetDefinitionSchema, FieldTypeSchema };
export const CatalogParamsSchema = z.strictObject({
scope: z.union([z.literal("global"), z.uuid()]),
});
export const CatalogItemParamsSchema = CatalogParamsSchema.extend({ id: z.uuid() });
export const SaveDefinitionRequestSchema = z.strictObject({ values: AssetDefinitionSchema });
export const AvailabilityRequestSchema = z.strictObject({ active: z.boolean().nullable() });
export const CatalogItemSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid().nullable(),
builtin: z.boolean(),
active: z.boolean(),
effectiveActive: z.boolean(),
overridden: z.boolean(),
values: AssetDefinitionSchema,
bindings: z.array(z.strictObject({ id: z.uuid(), fieldId: z.uuid(), required: z.boolean() })),
});
export const CatalogResponseSchema = z.strictObject({ items: z.array(CatalogItemSchema) });
export const SaveDefinitionResponseSchema = CatalogResponseSchema.extend({ savedId: z.uuid() });
export type CatalogItemDTO = z.infer<typeof CatalogItemSchema>;
export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
export * from "./inventory";

View File

@ -0,0 +1,33 @@
import { z } from "zod/v4";
import {
AssetInputSchema,
AssetSpecificationSchema,
AssetStatusSchema,
AssetValueSchema,
} from "../api/domain/asset-values";
export type { AssetCharacteristic, AssetInput } from "../api/domain/asset-values";
export { AssetInputSchema, AssetSpecificationSchema, AssetStatusSchema, AssetValueSchema };
export const InventoryParamsSchema = z.strictObject({ companyId: z.uuid() });
export const AssetParamsSchema = InventoryParamsSchema.extend({ id: z.uuid() });
export const SaveAssetRequestSchema = z.strictObject({ values: AssetInputSchema });
export const AssetRevisionSchema = z.strictObject({
id: z.uuid(),
values: AssetInputSchema,
actorId: z.uuid(),
actorName: z.string().min(1),
recordedAt: z.iso.datetime(),
});
export const AssetSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
specification: AssetSpecificationSchema,
values: AssetInputSchema,
history: z.array(AssetRevisionSchema).min(1),
});
export const InventoryResponseSchema = z.strictObject({ assets: z.array(AssetSchema) });
export const AssetResponseSchema = z.strictObject({ asset: AssetSchema });
export type AssetDTO = z.infer<typeof AssetSchema>;
export type AssetResponseDTO = z.infer<typeof AssetResponseSchema>;

View File

@ -0,0 +1,85 @@
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 { useTranslation } from "react-i18next";
import type { AssetCharacteristic, AssetInput } from "../common/inventory";
type CharacteristicValue = AssetInput["characteristics"][string];
export function AssetCharacteristicControl({
field,
value,
onChange,
prefix,
disabled,
}: {
field: AssetCharacteristic;
value: CharacteristicValue | undefined;
onChange: (value: CharacteristicValue | undefined) => void;
prefix: string;
disabled: boolean;
}) {
const { t } = useTranslation("assets");
const id = `${prefix}-${field.fieldId}`;
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{field.type === "choice" || field.type === "boolean" ? (
<SelectWithAction
disabled={disabled}
id={id}
label={label}
onValueChange={(choice) =>
onChange(
choice === "" ? undefined : field.type === "boolean" ? choice === "true" : choice
)
}
options={
field.type === "boolean"
? [
{ value: "true", label: t("yes") },
{ value: "false", label: t("no") },
]
: field.choices.map((choice) => ({ value: choice, label: choice }))
}
placeholder={t("noValue")}
required={field.required}
value={value === undefined ? "" : String(value)}
/>
) : (
<Input
disabled={disabled}
id={id}
maxLength={10000}
onChange={(event) => {
const input = event.currentTarget.value;
onChange(input === "" ? undefined : field.type === "number" ? Number(input) : input);
}}
onInput={
field.type === "date"
? (event) => {
const input = event.currentTarget.value;
onChange(input || undefined);
}
: undefined
}
required={field.required}
step={field.type === "number" ? "any" : undefined}
type={inputType(field.type)}
value={value === undefined ? "" : String(value)}
/>
)}
</div>
);
}
function inputType(type: AssetCharacteristic["type"]) {
if (type === "number") {
return "number";
}
if (type === "date") {
return "date";
}
return "text";
}

View File

@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import type { InventoryLookups } from "./asset-form";
export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: InventoryLookups }) {
const { t, i18n } = useTranslation("assets");
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
const display = (value: string | number | boolean | undefined) =>
value === undefined
? t("noValue")
: typeof value === "boolean"
? t(value ? "yes" : "no")
: String(value);
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>
<dl className="grid gap-3 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{asset.specification.templateName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(asset.values.status)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("site")}</dt>
<dd>{nameOf(asset.values.siteId, lookups.sites)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
</div>
{asset.specification.fields.map((field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="whitespace-pre-wrap break-words">
{display(asset.values.characteristics[field.fieldId])}
</dd>
</div>
))}
</dl>
{asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
)}
<h3 className="font-medium">
{t("history")} ({asset.history.length})
</h3>
<ol className="space-y-4">
{[...asset.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.status)}
</p>
<p>
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "}
{nameOf(revision.values.personId, lookups.people)}
</p>
<details>
<summary className="cursor-pointer">{t("characteristics")}</summary>
<dl className="mt-2 grid gap-2 sm:grid-cols-2">
{asset.specification.fields.map((field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd>{display(revision.values.characteristics[field.fieldId])}</dd>
</div>
))}
</dl>
{revision.values.notes && (
<p className="mt-2 whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}
</details>
</li>
))}
</ol>
<p className="text-xs text-muted-foreground">ID: {asset.id}</p>
</div>
);
}

View File

@ -0,0 +1,292 @@
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, 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 { CatalogItemDTO } from "../common";
import type { AssetDTO, AssetInput } from "../common/inventory";
import { AssetInputSchema, AssetStatusSchema } from "../common/inventory";
import { AssetCharacteristicControl } from "./asset-characteristic-control";
import { CatalogTemplateForm } from "./catalog-template-form";
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
export type InventoryLookups = {
sites: readonly { id: string; name: string }[];
people: readonly { id: string; name: string; active: boolean }[];
onCreateSite: (onCreated: (id: string) => void) => void;
onCreatePerson: (onCreated: (id: string) => void) => void;
};
export function InventoryFeedback({ error }: { error: Error | null }) {
const { t } = useTranslation("assets");
return error ? (
<p className="text-sm text-destructive" role="alert">
{t(`inventoryErrors.${error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"}`)}
</p>
) : null;
}
export function AssetForm({
companyId,
csrfToken,
asset,
items,
lookups,
onSaved,
}: {
companyId: string;
csrfToken: string;
asset?: AssetDTO | undefined;
items: readonly CatalogItemDTO[];
lookups: InventoryLookups;
onSaved: () => void;
}) {
const { t } = useTranslation("assets");
const prefix = useId();
const client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<AssetInput>({
resolver: zodResolver(AssetInputSchema),
defaultValues: asset?.values ?? {
name: "",
templateId: "",
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
},
});
const values = form.watch();
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
const template = templates.find((item) => item.id === values.templateId);
const fields =
asset?.specification.fields ??
(template?.values.kind === "template"
? template.values.fields.flatMap((binding) => {
const field = items.find((item) => item.id === binding.fieldId);
return field?.values.kind === "field"
? [
{
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
},
]
: [];
})
: []);
const mutation = useMutation({
mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
if (mounted.current) {
onSaved();
}
},
});
const setCharacteristic = (
fieldId: string,
value: AssetInput["characteristics"][string] | undefined
) => {
const characteristics = { ...form.getValues("characteristics") };
if (value === undefined) {
delete characteristics[fieldId];
} else {
characteristics[fieldId] = value;
}
form.setValue("characteristics", characteristics, { shouldDirty: true });
};
const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
<SelectWithAction
action={
asset
? undefined
: { label: t("newTemplate"), onClick: () => setCreatingTemplate(true) }
}
disabled={!!asset || mutation.isPending}
id={`${prefix}-template`}
label={t("template")}
onValueChange={(templateId) => {
form.setValue("templateId", templateId);
form.setValue("characteristics", {});
}}
options={
asset
? [{ value: asset.values.templateId, label: asset.specification.templateName }]
: templates.map((item) => ({ value: item.id, label: item.values.name }))
}
placeholder={t("chooseValue")}
required
value={values.templateId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-status`}>{t("status")}</Label>
<SelectWithAction
disabled={mutation.isPending}
id={`${prefix}-status`}
label={t("status")}
onValueChange={(value) => {
const parsed = AssetStatusSchema.safeParse(value);
if (parsed.success) {
form.setValue("status", parsed.data);
}
}}
options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseValue")}
required
value={values.status}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
{...selectProps}
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((site) => ({ value: site.id, label: site.name }))}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
<SelectWithAction
{...selectProps}
action={{
label: t("newPerson"),
onClick: () =>
lookups.onCreatePerson((id) => {
if (mounted.current) {
form.setValue("personId", id);
}
}),
}}
id={`${prefix}-person`}
label={t("person")}
onValueChange={(id) => form.setValue("personId", id || null)}
options={lookups.people
.filter((person) => person.active || person.id === asset?.values.personId)
.map((person) => ({
value: person.id,
label: person.name,
disabled: !person.active && person.id !== values.personId,
}))}
value={values.personId ?? ""}
/>
</div>
</div>
{fields.length > 0 && (
<section className="space-y-3">
<h3 className="font-medium">{t("characteristics")}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{fields.map((field) => (
<AssetCharacteristicControl
disabled={mutation.isPending}
field={field}
key={field.fieldId}
onChange={(value) => setCharacteristic(field.fieldId, value)}
prefix={prefix}
value={values.characteristics[field.fieldId]}
/>
))}
</div>
</section>
)}
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={10000}
rows={3}
{...form.register("notes")}
/>
</div>
</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} />
<ContextHelp
description={t("assetDescription")}
examples={t("assetExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("assetGuidance")}
scopeNote={t("assetScope")}
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingTemplate && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setCreatingTemplate(false);
}
}}
open
title={t("newTemplate")}
wide
>
<p className="text-sm text-muted-foreground">{t("relatedNotice")}</p>
<CatalogTemplateForm
allowQuickCreate={false}
csrfToken={csrfToken}
items={items}
onSaved={(id) => {
form.setValue("templateId", id);
form.setValue("characteristics", {});
setCreatingTemplate(false);
}}
scope={companyId}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,42 @@
import { z } from "zod/v4";
import type { AssetDefinitionSpec } from "../common";
import { CatalogResponseSchema, SaveDefinitionResponseSchema } from "../common";
export const catalogKeys = { scope: (scope: string) => ["asset-catalog", scope] };
export class CatalogApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/asset-catalog/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(json);
throw new CatalogApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return json;
}
export const catalogApi = {
async get(scope: string) {
return CatalogResponseSchema.parse(await request(scope));
},
async save(scope: string, values: AssetDefinitionSpec, csrfToken: string, id?: string) {
return SaveDefinitionResponseSchema.parse(
await request(`${scope}${id ? `/${id}/update` : ""}`, { values }, csrfToken)
);
},
async status(scope: string, id: string, active: boolean | null, csrfToken: string) {
return CatalogResponseSchema.parse(
await request(`${scope}/${id}/status`, { active }, csrfToken)
);
},
};

View File

@ -0,0 +1,85 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { CatalogHelp } from "./catalog-help";
export function CatalogDefinitionDialog({
item,
items,
onClose,
}: {
item?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onClose: () => void;
}) {
const { t } = useTranslation("assets");
if (!item) {
return null;
}
const values = item.values;
return (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t("viewDefinition", { name: values.name })}
wide={values.kind === "template"}
>
<div className="space-y-4">
{values.description && (
<p className="text-sm text-muted-foreground">{values.description}</p>
)}
{values.kind === "field" ? (
<dl className="space-y-2 text-sm">
<div>
<dt className="font-medium">{t("type")}</dt>
<dd>{t(values.type)}</dd>
</div>
{values.unit && (
<div>
<dt className="font-medium">{t("unit")}</dt>
<dd>{values.unit}</dd>
</div>
)}
{values.choices.length > 0 && (
<div>
<dt className="font-medium">{t("choices")}</dt>
<dd className="whitespace-pre-line">{values.choices.join("\n")}</dd>
</div>
)}
</dl>
) : (
<ul className="divide-y rounded-md border">
{values.fields.map((binding) => {
const field = items.find((entry) => entry.id === binding.fieldId);
return (
<li className="flex justify-between gap-4 p-3 text-sm" key={binding.fieldId}>
<div>
<p className="font-medium">{field?.values.name}</p>
{field?.values.kind === "field" && (
<p className="text-muted-foreground">
{t(field.values.type)}
{field.values.unit ? ` · ${field.values.unit}` : ""}
</p>
)}
{field && !field.effectiveActive && (
<p className="text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<span>{t(binding.required ? "required" : "optional")}</span>
</li>
);
})}
</ul>
)}
{item.builtin && <p className="text-sm text-muted-foreground">{t("protected")}</p>}
<CatalogHelp kind={values.kind} scope={item.companyId ?? "global"} />
</div>
</FormDialog>
);
}

View File

@ -0,0 +1,166 @@
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { AssetDefinitionSpec, CatalogItemDTO, SaveDefinitionResponseDTO } from "../common";
import { AssetDefinitionSchema, FieldTypeSchema } from "../common";
import { CatalogApiError, catalogApi, catalogKeys } from "./catalog-api";
import { CatalogHelp } from "./catalog-help";
export type CatalogSaved = (id: string, data: SaveDefinitionResponseDTO) => void;
export function useCatalogSave(
scope: string,
csrfToken: string,
onSaved: CatalogSaved,
id?: string
) {
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
return useMutation({
mutationFn: (values: AssetDefinitionSpec) => catalogApi.save(scope, values, csrfToken, id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), { items: response.items });
if (mounted.current) {
onSaved(response.savedId, response);
}
},
});
}
export function CatalogError({
error,
invalid = false,
}: {
error?: Error | null | undefined;
invalid?: boolean;
}) {
const { t } = useTranslation("assets");
if (!(error || invalid)) {
return null;
}
return (
<p className="text-sm text-destructive" role="alert">
{t(
`errors.${invalid ? "INVALID_REQUEST" : error instanceof CatalogApiError ? error.code : "INTERNAL_ERROR"}`
)}
</p>
);
}
const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({
choicesText: z.string().max(10000),
});
type FieldFormValues = z.infer<typeof FieldFormSchema>;
export function CatalogFieldForm({
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 === "field" ? item.values : undefined;
const form = useForm<FieldFormValues>({
resolver: zodResolver(FieldFormSchema),
defaultValues: {
kind: "field",
name: current?.name ?? "",
description: current?.description ?? "",
type: current?.type ?? "text",
unit: current?.unit ?? "",
choicesText: current?.choices.join("\n") ?? "",
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const type = form.watch("type");
return (
<form
className="space-y-4"
onSubmit={form.handleSubmit(({ choicesText, ...values }) =>
mutation.mutate({
...values,
choices:
values.type === "choice"
? choicesText
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
: [],
})
)}
>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("type")}</Label>
<SelectWithAction
disabled={!!item || mutation.isPending}
id={`${id}-type`}
label={t("type")}
onValueChange={(value) => {
const parsed = FieldTypeSchema.safeParse(value);
if (parsed.success) {
form.setValue("type", parsed.data);
}
}}
options={FieldTypeSchema.options.map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseType")}
required
value={type}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-unit`}>{t("unit")}</Label>
<Input id={`${id}-unit`} maxLength={30} {...form.register("unit")} />
</div>
{type === "choice" && (
<div className="space-y-2">
<Label htmlFor={`${id}-choices`}>{t("choices")}</Label>
<Textarea id={`${id}-choices`} required rows={5} {...form.register("choicesText")} />
<p className="text-xs text-muted-foreground">{t("choicesHelp")}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea
id={`${id}-description`}
maxLength={1000}
rows={3}
{...form.register("description")}
/>
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="field" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
export function CatalogHelp({ kind, scope }: { kind: "field" | "template"; scope: string }) {
const { t } = useTranslation("assets");
return (
<ContextHelp
description={t(`${kind}Description`)}
examples={t(`${kind}Examples`)}
examplesLabel={t("examplesLabel")}
guidance={t(`${kind}Guidance`)}
scopeNote={t(scope === "global" ? "globalHelp" : "companyHelp")}
title={t("helpTitle")}
/>
);
}

View File

@ -0,0 +1,299 @@
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 { Label } from "@repo/shadcn-ui/components/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../common";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogDefinitionDialog } from "./catalog-definition-dialog";
import { CatalogError, CatalogFieldForm } from "./catalog-form";
import { CatalogTemplateForm } from "./catalog-template-form";
type Editor = { kind: "field" | "template"; item?: CatalogItemDTO; copy?: CatalogItemDTO };
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
const { t } = useTranslation("assets");
const searchId = useId();
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [search, setSearch] = useState("");
const [view, setView] = useState<CatalogItemDTO>();
const [editor, setEditor] = useState<Editor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});
const mutation = useMutation({
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
catalogApi.status(scope, item.id, active, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
},
});
const items = query.data?.items ?? [];
const visible = items.filter(
(item) =>
item.values.kind === tab &&
item.values.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold">
{t(scope === "global" ? "title" : "companyTitle")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t(scope === "global" ? "introduction" : "companyIntroduction")}
</p>
</div>
<Button disabled={!query.data} onClick={() => setEditor({ kind: tab })}>
{t(tab === "field" ? "newField" : "newTemplate")}
</Button>
</div>
<nav aria-label={t("title")} className="flex gap-2">
{(["template", "field"] as const).map((kind) => (
<Button
aria-pressed={tab === kind}
key={kind}
onClick={() => {
setTab(kind);
setSearch("");
}}
variant={tab === kind ? "secondary" : "outline"}
>
{t(kind === "field" ? "fields" : "templates")}
</Button>
))}
</nav>
<p className="text-sm text-muted-foreground">{t("protected")}</p>
{query.isPending && <p role="status">{t("loading")}</p>}
<CatalogError error={query.error ?? mutation.error} />
{query.isError && (
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<Card>
<CardContent className="space-y-4 pt-6">
<div className="space-y-2">
<Label htmlFor={searchId}>{t("search")}</Label>
<Input
id={searchId}
onChange={(event) => setSearch(event.target.value)}
value={search}
/>
</div>
{query.data &&
(visible.length ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("origin")}</TableHead>
<TableHead>{t(tab === "field" ? "type" : "fields")}</TableHead>
<TableHead>{t("active")}</TableHead>
<TableHead>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((item) => (
<CatalogRow
item={item}
items={items}
key={item.id}
onCopy={() => setEditor({ kind: "template", copy: item })}
onEdit={() => setEditor({ kind: tab, item })}
onStatus={(active) => mutation.mutate({ item, active })}
onView={() => setView(item)}
pending={mutation.isPending}
scope={scope}
/>
))}
</TableBody>
</Table>
) : (
<p className="text-muted-foreground">{t("empty")}</p>
))}
</CardContent>
</Card>
<CatalogDefinitionDialog item={view} items={items} onClose={() => setView(undefined)} />
{editor && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(editorTitle(editor))}
wide={editor.kind === "template"}
>
{editor.kind === "field" ? (
<CatalogFieldForm
csrfToken={csrfToken}
item={editor.item}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
) : (
<CatalogTemplateForm
copy={editor.copy}
csrfToken={csrfToken}
item={editor.item}
items={items}
onSaved={() => setEditor(undefined)}
scope={scope}
/>
)}
</FormDialog>
)}
</div>
);
}
function CatalogRow({
item,
scope,
items,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
scope: string;
items: readonly CatalogItemDTO[];
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope);
const values = item.values;
const inactiveFields =
values.kind === "template" &&
values.fields.some(
(field) => !items.find((entry) => entry.id === field.fieldId)?.effectiveActive
);
return (
<TableRow>
<TableCell>
<p className="font-medium">{values.name}</p>
{values.description && (
<p className="text-xs text-muted-foreground">{values.description}</p>
)}
{inactiveFields && (
<p className="text-xs text-muted-foreground">{t("disabledFieldsNotice")}</p>
)}
</TableCell>
<TableCell>
{t(item.builtin ? "builtin" : "custom")}
<p className="text-xs text-muted-foreground">{t(item.companyId ? "own" : "global")}</p>
</TableCell>
<TableCell>
{values.kind === "field"
? t(values.type)
: t("fieldCount", { count: values.fields.length })}
</TableCell>
<TableCell>
{t(item.effectiveActive ? "active" : "inactive")}
{scope !== "global" && !item.companyId && (
<p className="text-xs text-muted-foreground">
{t(item.overridden ? "overridden" : "inherited")}
</p>
)}
</TableCell>
<TableCell>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</TableCell>
</TableRow>
);
}
function editorTitle(editor: Editor) {
if (editor.kind === "field") {
return editor.item ? "editField" : "newField";
}
return editor.item ? "editTemplate" : "newTemplate";
}
function CatalogRowActions({
item,
editable,
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
editable: boolean;
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="flex flex-wrap gap-2">
<Button onClick={onView} size="sm" variant="outline">
{t("view")}
</Button>
{editable && (
<Button onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
)}
{item.values.kind === "template" && (
<Button onClick={onCopy} size="sm" variant="outline">
{t("copy")}
</Button>
)}
<Button
disabled={pending}
onClick={() => onStatus(!item.effectiveActive)}
size="sm"
variant="outline"
>
{t(item.effectiveActive ? "deactivate" : "activate")}
</Button>
{item.overridden && (
<Button disabled={pending} onClick={() => onStatus(null)} size="sm" variant="ghost">
{t("reset")}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,198 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../common";
import { AssetDefinitionSchema } from "../common";
import type { CatalogSaved } from "./catalog-form";
import { CatalogError, CatalogFieldForm, useCatalogSave } from "./catalog-form";
import { CatalogHelp } from "./catalog-help";
const TemplateFormSchema = AssetDefinitionSchema.options[1];
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
export function CatalogTemplateForm({
scope,
csrfToken,
item,
copy,
items,
onSaved,
allowQuickCreate = true,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
copy?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: CatalogSaved;
allowQuickCreate?: boolean;
}) {
const { t } = useTranslation("assets");
const id = useId();
const [creatingField, setCreatingField] = useState(false);
const source = item ?? copy;
const current = source?.values.kind === "template" ? source.values : undefined;
const form = useForm<TemplateFormValues>({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: current?.fields.map((field) => ({ ...field })) ?? [],
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const selected = form.watch("fields");
const fields = items.filter((entry) => entry.values.kind === "field");
const add = (fieldId: string) => {
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
form.setValue("fields", [...selected, { fieldId, required: false }], {
shouldDirty: true,
shouldValidate: true,
});
}
};
return (
<>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
<Textarea
id={`${id}-description`}
maxLength={1000}
rows={2}
{...form.register("description")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
<SelectWithAction
action={
allowQuickCreate
? { label: t("addField"), onClick: () => setCreatingField(true) }
: undefined
}
disabled={mutation.isPending}
id={`${id}-field`}
label={t("selectField")}
onValueChange={add}
options={fields
.filter(
(entry) =>
entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
)
.map((entry) => ({ value: entry.id, label: entry.values.name }))}
placeholder={t("choose")}
value=""
/>
</div>
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
<div className="divide-y rounded-md border">
{selected.map((binding) => {
const field = fields.find((entry) => entry.id === binding.fieldId);
return (
<div className="flex items-center justify-between gap-3 p-3" key={binding.fieldId}>
<div>
<p className="text-sm font-medium">{field?.values.name ?? binding.fieldId}</p>
{field && !field.effectiveActive && (
<p className="text-xs text-muted-foreground">{t("disabledField")}</p>
)}
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={binding.required}
disabled={mutation.isPending}
id={`${id}-required-${binding.fieldId}`}
onCheckedChange={(checked) =>
form.setValue(
"fields",
selected.map((entry) =>
entry.fieldId === binding.fieldId
? { ...entry, required: checked === true }
: entry
),
{ shouldDirty: true }
)
}
/>
<Label htmlFor={`${id}-required-${binding.fieldId}`}>
{t("required")}
<span className="sr-only">
{": "}
{field?.values.name ?? ""}
</span>
</Label>
<Button
aria-label={t("remove", { name: field?.values.name ?? "" })}
disabled={mutation.isPending}
onClick={() =>
form.setValue(
"fields",
selected.filter((entry) => entry.fieldId !== binding.fieldId),
{ shouldDirty: true }
)
}
size="sm"
type="button"
variant="ghost"
>
×
</Button>
</div>
</div>
);
})}
</div>
<CatalogError
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<CatalogHelp kind="template" scope={scope} />
<FormActions>
<Button disabled={mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{creatingField && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setCreatingField(false);
}
}}
open
title={t("newField")}
>
<p className="text-sm text-muted-foreground">{t("quickNotice")}</p>
<CatalogFieldForm
csrfToken={csrfToken}
onSaved={(fieldId) => {
add(fieldId);
setCreatingField(false);
}}
scope={scope}
/>
</FormDialog>
)}
</>
);
}

View File

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
import { catalogKeys } from "./catalog-api";
import { CatalogFieldForm } from "./catalog-form";
import { AssetCatalogPage } from "./catalog-page";
import { CatalogTemplateForm } from "./catalog-template-form";
import { assetsEnglish, assetsSpanish } from "./i18n";
async function render(language: string, children: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
});
const client = new QueryClient();
client.setQueryData(
catalogKeys.scope("global"),
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("el catálogo muestra plantillas de serie y acción de alta sin formularios junto al listado", async () => {
for (const [language, create, copy] of [
["es", "Nueva plantilla", "Crear copia"],
["en", "New template", "Create copy"],
]) {
assert.ok(language && create && copy);
const html = await render(language, <AssetCatalogPage csrfToken="test" scope="global" />);
assert.ok(html.includes("Portátil"));
assert.ok(html.includes(create));
assert.ok(html.includes(copy));
assert.equal(html.includes("<form"), false);
assert.equal(html.includes(">Editar</button>"), false);
}
});
test("los formularios tienen ayuda inferior y selector de campo con alta integrada", async () => {
const field = await render(
"es",
<CatalogFieldForm csrfToken="test" onSaved={() => undefined} scope="global" />
);
assert.ok(field.indexOf('name="description"') < field.indexOf("<aside"));
assert.ok(field.indexOf("<aside") < field.indexOf('type="submit"'));
assert.ok(field.includes("El tipo de dato queda fijo"));
const template = await render(
"es",
<CatalogTemplateForm csrfToken="test" items={[]} onSaved={() => undefined} scope="global" />
);
assert.ok(template.includes('aria-label="Nuevo campo"'));
assert.ok(template.includes("Todos son opcionales inicialmente"));
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
});

View File

@ -0,0 +1,200 @@
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
export const assetsSpanish = {
...inventorySpanish,
yes: "Sí",
no: "No",
title: "Catálogo de activos",
companyTitle: "Plantillas de activos",
introduction: "Define las características y plantillas que utilizarás al registrar activos.",
companyIntroduction: "Utiliza las definiciones globales y añade las que necesite esta compañía.",
fields: "Campos",
templates: "Plantillas",
newField: "Nuevo campo",
editField: "Editar campo",
newTemplate: "Nueva plantilla",
editTemplate: "Editar plantilla",
name: "Nombre",
description: "Descripción (opcional)",
type: "Tipo de dato",
unit: "Unidad (opcional)",
choices: "Valores de la lista",
choicesHelp:
"Escribe un valor por línea. Los valores existentes se conservan; puedes añadir otros.",
text: "Texto",
number: "Número",
date: "Fecha",
boolean: "Sí / No",
choice: "Lista de valores",
active: "Activo",
inactive: "Inactivo",
activate: "Activar",
deactivate: "Desactivar",
inherited: "Configuración global",
overridden: "Personalizado en esta compañía",
reset: "Usar configuración global",
builtin: "De serie",
global: "Global",
own: "De la compañía",
custom: "Personalizado",
origin: "Origen",
actions: "Acciones",
edit: "Editar",
copy: "Crear copia",
copyName: "{{name}} (copia)",
fieldCount: "{{count}} campos",
search: "Buscar",
empty: "No hay resultados.",
loading: "Cargando catálogo…",
retry: "Reintentar",
save: "Guardar",
saving: "Guardando…",
cancel: "Cancelar",
selectField: "Añadir un campo a la plantilla",
choose: "Selecciona un campo",
addField: "Nuevo campo",
required: "Obligatorio",
chooseType: "Selecciona un tipo",
view: "Ver",
close: "Cerrar",
viewDefinition: "{{name}}",
optional: "Opcional",
remove: "Retirar campo: {{name}}",
noFields: "Añade al menos un campo. Todos son opcionales inicialmente.",
protected:
"Las definiciones de serie están protegidas. Puedes cambiar su disponibilidad o crear una copia de una plantilla.",
helpTitle: "¿Qué es y para qué sirve?",
examplesLabel: "Ver ejemplos y recomendaciones",
fieldDescription:
"Un campo describe una característica que podrás rellenar en un activo. Puedes reutilizarlo en varias plantillas.",
fieldExamples:
"Ejemplos: Procesador (texto), Pulgadas (número), Fin de garantía (fecha), PoE (sí/no) o Estado de batería (lista).",
fieldGuidance:
"Elige una unidad si ayuda, por ejemplo GB. No guardes contraseñas ni secretos. El tipo de dato queda fijo después del alta.",
templateDescription:
"Una plantilla reúne los campos que se mostrarán para un tipo de activo. No es un equipo concreto ni una marca o modelo comercial.",
templateExamples:
"Monitor: pulgadas, resolución y conexiones. Portátil: procesador, memoria RAM y almacenamiento.",
templateGuidance:
"Las características son opcionales por defecto. Marca como obligatorio solo lo que siempre se conozca. Retirar un campo de una plantilla no elimina el campo del catálogo.",
globalHelp: "Ámbito global: disponible para todas las compañías.",
companyHelp:
"Ámbito de compañía: estas adiciones y cambios de disponibilidad solo afectan a esta compañía.",
disabledField: "Campo inactivo: se conserva en esta plantilla.",
disabledFieldsNotice:
"Esta plantilla contiene campos inactivos. Puedes activarlos en el catálogo antes de utilizarla.",
quickNotice: "El campo se guardará aunque después canceles la plantilla.",
errors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHOICES:
"Una lista necesita valores únicos y no vacíos. Los demás tipos no admiten valores de lista.",
INVALID_FIELDS: "La plantilla necesita campos distintos y válidos.",
NAME_EXISTS: "Ya existe una definición de este tipo con ese nombre.",
FIELD_UNAVAILABLE: "Solo puedes añadir campos activos del catálogo global o de esta compañía.",
FIELD_TYPE_FIXED:
"El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.",
BUILTIN_PROTECTED: "Las definiciones de serie no se pueden modificar.",
DEFINITION_NOT_FOUND: "La definición no está disponible en este ámbito.",
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
INTERNAL_ERROR: "No se ha podido completar la operación.",
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
},
};
export const assetsEnglish = {
...inventoryEnglish,
yes: "Yes",
no: "No",
title: "Asset catalog",
companyTitle: "Asset templates",
introduction: "Define the characteristics and templates used when registering assets.",
companyIntroduction: "Use global definitions and add those needed by this company.",
fields: "Fields",
templates: "Templates",
newField: "New field",
editField: "Edit field",
newTemplate: "New template",
editTemplate: "Edit template",
name: "Name",
description: "Description (optional)",
type: "Data type",
unit: "Unit (optional)",
choices: "List values",
choicesHelp: "Enter one value per line. Existing values are retained; you can add others.",
text: "Text",
number: "Number",
date: "Date",
boolean: "Yes / No",
choice: "List of values",
active: "Active",
inactive: "Inactive",
activate: "Activate",
deactivate: "Deactivate",
inherited: "Global configuration",
overridden: "Customized for this company",
reset: "Use global configuration",
builtin: "Built in",
global: "Global",
own: "Company",
custom: "Custom",
origin: "Origin",
actions: "Actions",
edit: "Edit",
copy: "Create copy",
copyName: "{{name}} (copy)",
fieldCount: "{{count}} fields",
search: "Search",
empty: "No results.",
loading: "Loading catalog…",
retry: "Retry",
save: "Save",
saving: "Saving…",
cancel: "Cancel",
selectField: "Add a field to the template",
choose: "Select a field",
addField: "New field",
required: "Required",
chooseType: "Select a type",
view: "View",
close: "Close",
viewDefinition: "{{name}}",
optional: "Optional",
remove: "Remove field: {{name}}",
noFields: "Add at least one field. All fields are initially optional.",
protected:
"Built-in definitions are protected. You can change their availability or create a template copy.",
helpTitle: "What is it for?",
examplesLabel: "Show examples and guidance",
fieldDescription:
"A field describes an asset characteristic. You can reuse it in several templates.",
fieldExamples:
"Examples: Processor (text), Screen size (number), Warranty expiry (date), PoE (yes/no) or Battery status (list).",
fieldGuidance:
"Choose a useful unit, such as GB. Never store passwords or secrets. The data type is fixed after creation.",
templateDescription:
"A template groups the fields displayed for an asset type. It is not a specific device or a commercial brand or model.",
templateExamples:
"Monitor: screen size, resolution and connections. Laptop: processor, RAM and storage.",
templateGuidance:
"Characteristics are optional by default. Require only information that is always known. Removing a template field does not delete its catalog definition.",
globalHelp: "Global scope: available to all companies.",
companyHelp: "Company scope: additions and availability changes affect this company only.",
disabledField: "Inactive field: retained in this template.",
disabledFieldsNotice:
"This template contains inactive fields. You can activate them in the catalog before using it.",
quickNotice: "The field remains saved if you later cancel the template.",
errors: {
INVALID_REQUEST: "Check the form data.",
INVALID_CHOICES:
"A list needs unique non-empty values. Other data types do not accept list values.",
INVALID_FIELDS: "The template needs distinct valid fields.",
NAME_EXISTS: "A definition of this type already has this name.",
FIELD_UNAVAILABLE: "Only active global or company fields can be added.",
FIELD_TYPE_FIXED: "The data type cannot change and existing list values cannot be removed.",
BUILTIN_PROTECTED: "Built-in definitions cannot be edited.",
DEFINITION_NOT_FOUND: "This definition is unavailable in this scope.",
COMPANY_NOT_FOUND: "Company not found.",
INTERNAL_ERROR: "The operation could not be completed.",
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
UNAUTHENTICATED: "Sign in again.",
},
};

View File

@ -0,0 +1,3 @@
export { AssetCatalogPage } from "./catalog-page";
export { assetsEnglish, assetsSpanish } from "./i18n";
export { AssetInventoryPage } from "./inventory-page";

View File

@ -0,0 +1,37 @@
import { z } from "zod/v4";
import type { AssetInput } from "../common/inventory";
import { AssetResponseSchema, InventoryResponseSchema } from "../common/inventory";
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/assets/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const inventoryApi = {
async get(companyId: string) {
return InventoryResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: AssetInput, token: string, id?: string) {
return AssetResponseSchema.parse(
await request(`${companyId}${id ? `/${id}/update` : ""}`, { values }, token)
);
},
};

View File

@ -0,0 +1,102 @@
export const inventorySpanish = {
inventory: "Activos",
chooseValue: "Selecciona una opción",
relatedNotice:
"El elemento se guarda por separado y se conserva aunque después canceles el alta del activo.",
newAsset: "Nuevo activo",
editAsset: "Editar activo",
viewAsset: "Ficha e historial",
template: "Plantilla",
site: "Sede",
person: "Persona asignada",
status: "Estado",
notes: "Notas",
available: "Disponible",
in_use: "En uso",
maintenance: "En mantenimiento",
retired: "Retirado",
noAssets: "Esta compañía todavía no tiene activos.",
noAssignment: "Sin asignar",
noValue: "Sin indicar",
characteristics: "Características",
history: "Historial",
recordedBy: "Registrado por {{name}}",
snapshotNotice:
"La plantilla queda fijada al crear el activo. Sus características se conservan aunque después cambie el catálogo.",
newSite: "Nueva sede",
newPerson: "Nueva persona",
assetDescription:
"Un activo es un elemento informático concreto de esta compañía. La plantilla indica qué características puedes registrar.",
assetExamples: "Portátil de Ana, monitor de recepción o switch de la sede central.",
assetGuidance:
"Solo son obligatorios el nombre, la plantilla y los campos exigidos por tu plantilla. Puedes completar el resto más adelante. Retirar un activo requiere dejarlo sin persona asignada; su historial se conserva.",
assetScope:
"El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
saveAssetError: "No se pudo guardar el activo.",
inventoryErrors: {
INVALID_REQUEST: "Revisa los datos del formulario.",
INVALID_CHARACTERISTICS: "Alguna característica no corresponde a su campo o tipo.",
REQUIRED_CHARACTERISTICS: "Completa las características obligatorias de la plantilla.",
RETIRED_ASSIGNED: "Para retirar el activo, deja la persona asignada en «Sin asignar».",
TEMPLATE_FIXED: "No se puede cambiar la plantilla de un activo existente.",
TEMPLATE_UNAVAILABLE: "La plantilla debe estar activa y disponible para esta compañía.",
SITE_NOT_FOUND: "La sede no pertenece a esta compañía.",
PERSON_UNAVAILABLE: "La persona debe estar activa y pertenecer a esta compañía.",
COMPANY_NOT_FOUND: "Compañía no encontrada.",
ASSET_NOT_FOUND: "Activo no encontrado en esta compañía.",
INTERNAL_ERROR: "No se pudo completar la operación.",
FORBIDDEN: "Vuelve a iniciar sesión.",
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
},
};
export const inventoryEnglish = {
inventory: "Assets",
chooseValue: "Choose an option",
relatedNotice:
"This item is saved separately and remains available if you later cancel the asset.",
newAsset: "New asset",
editAsset: "Edit asset",
viewAsset: "Details and history",
template: "Template",
site: "Site",
person: "Assigned person",
status: "Status",
notes: "Notes",
available: "Available",
in_use: "In use",
maintenance: "Maintenance",
retired: "Retired",
noAssets: "This company has no assets yet.",
noAssignment: "Unassigned",
noValue: "Not specified",
characteristics: "Characteristics",
history: "History",
recordedBy: "Recorded by {{name}}",
snapshotNotice:
"The template is fixed when the asset is created. Its characteristics remain unchanged when the catalog changes.",
newSite: "New site",
newPerson: "New person",
assetDescription:
"An asset is a specific IT item belonging to this company. Its template defines the characteristics you can record.",
assetExamples: "Ana's laptop, the reception monitor or the main site's switch.",
assetGuidance:
"Only the name, template and characteristics required by your template are mandatory. Complete other data later. Unassign the person before retiring an asset; its history is retained.",
assetScope:
"The asset belongs to this company. Site and person are optional and must belong to it.",
saveAssetError: "The asset could not be saved.",
inventoryErrors: {
INVALID_REQUEST: "Check the form data.",
INVALID_CHARACTERISTICS: "A characteristic does not match its field or type.",
REQUIRED_CHARACTERISTICS: "Complete the template's required characteristics.",
RETIRED_ASSIGNED: "Unassign the person before retiring the asset.",
TEMPLATE_FIXED: "An existing asset's template cannot change.",
TEMPLATE_UNAVAILABLE: "The template must be active and available to this company.",
SITE_NOT_FOUND: "The site does not belong to this company.",
PERSON_UNAVAILABLE: "The person must be active and belong to this company.",
COMPANY_NOT_FOUND: "Company not found.",
ASSET_NOT_FOUND: "Asset not found in this company.",
INTERNAL_ERROR: "The operation could not be completed.",
FORBIDDEN: "Sign in again.",
UNAUTHENTICATED: "Sign in again.",
},
};

View File

@ -0,0 +1,161 @@
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 { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { CatalogError } from "./catalog-form";
import { inventoryApi, inventoryKeys } from "./inventory-api";
export function AssetInventoryPage({
companyId,
csrfToken,
lookups,
}: {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
}) {
const { t } = useTranslation("assets");
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string | undefined>();
const [viewId, setViewId] = useState<string | undefined>();
const assets = query.data?.assets ?? [];
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
const person = lookups.people.find((person) => person.id === asset.values.personId);
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
return [
asset.values.name,
asset.specification.templateName,
person?.name ?? "",
site?.name ?? "",
t(asset.values.status),
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
{t("newAsset")}
</Button>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("search")}
value={search}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
<CatalogError error={catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.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(assets.length ? "noResults" : "noAssets")}
</p>
)}
{filtered.map((asset) => (
<div
className="flex flex-wrap items-center justify-between gap-3 py-4"
key={asset.id}
>
<div>
<h3 className="font-medium">{asset.values.name}</h3>
<p className="text-sm text-muted-foreground">
{asset.specification.templateName} · {t(asset.values.status)}
</p>
<p className="text-sm text-muted-foreground">
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}{" "}
·{" "}
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
t("noAssignment")}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => setViewId(asset.id)} size="sm" variant="outline">
{t("viewAsset")}
</Button>
<Button
disabled={!catalog.data}
onClick={() => setEditor(asset.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{editor && catalog.data && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setEditor(undefined);
}
}}
open
title={t(edited ? "editAsset" : "newAsset")}
wide
>
<AssetForm
asset={edited}
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
onSaved={() => setEditor(undefined)}
/>
</FormDialog>
)}
{viewed && (
<FormDialog
cancelLabel={t("close")}
onOpenChange={(open) => {
if (!open) {
setViewId(undefined);
}
}}
open
title={viewed.values.name}
wide
>
<AssetDetail asset={viewed} lookups={lookups} />
</FormDialog>
)}
</div>
);
}

View File

@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { buildCatalogResponse } from "../api/application/asset-catalog-response.builder";
import { AssetCatalog } from "../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../api/domain/asset-catalog-seed";
import { AssetForm } from "./asset-form";
import { catalogKeys } from "./catalog-api";
import { CatalogTemplateForm } from "./catalog-template-form";
import { assetsEnglish, assetsSpanish } from "./i18n";
import { inventoryKeys } from "./inventory-api";
import { AssetInventoryPage } from "./inventory-page";
const companyId = UniqueID.generateNewID().toPrimitive();
const lookups = {
sites: [],
people: [],
onCreateSite: () => undefined,
onCreatePerson: () => undefined,
};
async function render(language: string, node: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
});
const client = new QueryClient();
client.setQueryData(inventoryKeys.company(companyId), { assets: [] });
client.setQueryData(
catalogKeys.scope(companyId),
buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
);
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("Inventario traducido con alta de cabecera y sin formulario junto al listado", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<AssetInventoryPage companyId={companyId} csrfToken="test" lookups={lookups} />
);
assert.equal(html.includes("<form"), false);
assert.ok(html.includes(language === "es" ? "Nuevo activo" : "New asset"));
assert.ok(
html.includes(
language === "es"
? "Esta compañía todavía no tiene activos."
: "This company has no assets yet."
)
);
}
});
test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una tercera alta", async () => {
const html = await render(
"es",
<AssetForm
companyId={companyId}
csrfToken="test"
items={[]}
lookups={lookups}
onSaved={() => undefined}
/>
);
for (const label of ["Nueva plantilla", "Nueva sede", "Nueva persona"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.ok(html.indexOf('name="notes"') < html.indexOf("<aside"));
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
const child = await render(
"es",
<CatalogTemplateForm
allowQuickCreate={false}
csrfToken="test"
items={[]}
onSaved={() => undefined}
scope={companyId}
/>
);
assert.equal(child.includes('aria-label="Nuevo campo"'), false);
});

View File

@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/buildless.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src"]
}

View File

@ -20,7 +20,8 @@
"zod": "4.3.6",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3"
"@tanstack/react-table": "8.21.3",
"@metis/assets": "workspace:*"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",

View File

@ -1,3 +1,4 @@
import { AssetCatalogPage } from "@metis/assets/client";
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";
@ -11,6 +12,7 @@ import { companyApi, companyKeys } from "../api/company-api";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
import { CompanyInventory } from "./company-inventory";
import { DepartmentSection } from "./department-section";
import { PeopleSection } from "./people-section";
import { SiteForm } from "./site-form";
@ -88,6 +90,8 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
</div>
);
}
type CompanyTab = "people" | "departments" | "sites" | "access" | "assets" | "inventory";
export function CompanyDetail({
id,
csrfToken,
@ -98,10 +102,13 @@ export function CompanyDetail({
onBack: () => void;
}) {
const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<"people" | "departments" | "sites" | "access">("people");
const [tab, setTab] = useState<
"people" | "departments" | "sites" | "access" | "assets" | "inventory"
>("people");
const company = query.data?.company;
return (
<div className="space-y-6">
@ -177,18 +184,51 @@ export function CompanyDetail({
>
{accessTranslation("title")}
</Button>
<Button
aria-pressed={tab === "assets"}
onClick={() => setTab("assets")}
variant={tab === "assets" ? "secondary" : "outline"}
>
{assetTranslation("companyTitle")}
</Button>
<Button
aria-pressed={tab === "inventory"}
onClick={() => setTab("inventory")}
variant={tab === "inventory" ? "secondary" : "outline"}
>
{assetTranslation("inventory")}
</Button>
</nav>
{tab === "people" ? (
<PeopleSection company={company} csrfToken={csrfToken} />
) : tab === "departments" ? (
<DepartmentSection company={company} csrfToken={csrfToken} />
) : tab === "access" ? (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
) : (
<SiteSection company={company} csrfToken={csrfToken} />
)}
<CompanySection company={company} csrfToken={csrfToken} tab={tab} />
</>
)}
</div>
);
}
function CompanySection({
company,
csrfToken,
tab,
}: {
company: CompanyDTO;
csrfToken: string;
tab: CompanyTab;
}) {
switch (tab) {
case "people":
return <PeopleSection company={company} csrfToken={csrfToken} />;
case "departments":
return <DepartmentSection company={company} csrfToken={csrfToken} />;
case "sites":
return <SiteSection company={company} csrfToken={csrfToken} />;
case "access":
return (
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
);
case "inventory":
return <CompanyInventory company={company} csrfToken={csrfToken} />;
case "assets":
return <AssetCatalogPage csrfToken={csrfToken} scope={company.id} />;
}
}

View File

@ -0,0 +1,73 @@
import { AssetInventoryPage } from "@metis/assets/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../common";
import { PersonForm } from "./person-form";
import { SiteForm } from "./site-form";
export function CompanyInventory({
company,
csrfToken,
}: {
company: CompanyDTO;
csrfToken: string;
}) {
const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const [quick, setQuick] = useState<{
kind: "site" | "person";
onCreated: (id: string) => void;
}>();
return (
<>
<AssetInventoryPage
companyId={company.id}
csrfToken={csrfToken}
lookups={{
sites: company.sites,
people: company.people,
onCreateSite: (onCreated) => setQuick({ kind: "site", onCreated }),
onCreatePerson: (onCreated) => setQuick({ kind: "person", onCreated }),
}}
/>
{quick && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open
title={t(quick.kind === "site" ? "newSite" : "newPerson")}
>
<p className="text-sm text-muted-foreground">{assetTranslation("relatedNotice")}</p>
{quick.kind === "site" ? (
<SiteForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={(id) => {
quick.onCreated(id);
setQuick(undefined);
}}
showTitle={false}
/>
) : (
<PersonForm
companyId={company.id}
csrfToken={csrfToken}
onSaved={(id) => {
quick.onCreated(id);
setQuick(undefined);
}}
requireActive
/>
)}
</FormDialog>
)}
</>
);
}

View File

@ -26,7 +26,7 @@ export function SiteForm({
site?: SiteDTO;
companyId: string;
csrfToken: string;
onSaved?: () => void;
onSaved?: (id: string) => void;
showTitle?: boolean;
}) {
const { t } = useTranslation("companies");
@ -49,7 +49,12 @@ export function SiteForm({
if (!site) {
form.reset();
}
onSaved?.();
const saved = site
? response.company.sites.find((item) => item.id === site.id)
: response.company.sites.at(-1);
if (saved) {
onSaved?.(saved.id);
}
},
});
return (

View File

@ -26,6 +26,7 @@ function sendError(response: Response, code: string, status: number): void {
.status(status)
.json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() });
}
function businessError(response: Response, error: IdentityError): void {
const statuses = {
INVALID_REQUEST: 400,
@ -229,6 +230,7 @@ export function registerIdentityRoutes(
}
response.json({ user: buildUserResponse(result.data) });
});
registerBody(
"/users/:id/reset-password",
ResetPasswordRequestSchema,

View File

@ -26,6 +26,9 @@ importers:
apps/server:
dependencies:
'@metis/assets':
specifier: workspace:*
version: link:../../modules/assets
'@metis/companies':
specifier: workspace:*
version: link:../../modules/companies
@ -72,6 +75,9 @@ importers:
apps/web:
dependencies:
'@metis/assets':
specifier: workspace:*
version: link:../../modules/assets
'@metis/companies':
specifier: workspace:*
version: link:../../modules/companies
@ -122,11 +128,75 @@ importers:
specifier: 8.3.0
version: 8.3.0(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)
modules/assets:
dependencies:
'@hookform/resolvers':
specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
'@tanstack/react-query':
specifier: 5.98.0
version: 5.98.0(react@19.2.5)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
express:
specifier: 4.22.1
version: 4.22.1
i18next:
specifier: 26.0.4
version: 26.0.4(typescript@6.0.2)
react:
specifier: 19.2.5
version: 19.2.5
react-dom:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
react-hook-form:
specifier: 7.72.1
version: 7.72.1(react@19.2.5)
react-i18next:
specifier: 17.0.2
version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
sequelize:
specifier: 6.37.8
version: 6.37.8(mariadb@3.5.4)
zod:
specifier: 4.3.6
version: 4.3.6
devDependencies:
'@repo/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/express':
specifier: 4.17.21
version: 4.17.21
'@types/node':
specifier: 25.6.0
version: 25.6.0
'@types/react':
specifier: 19.2.14
version: 19.2.14
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.14)
modules/companies:
dependencies:
'@hookform/resolvers':
specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@metis/assets':
specifier: workspace:*
version: link:../assets
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd