Marcas
This commit is contained in:
parent
ecfd689e44
commit
d8ece23f9e
4
TODO.md
Normal file
4
TODO.md
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
- Traducciones: Quiero que audites todo el código para extraer y reunir todas las cadenas de traducciones en ficheros JSON. El modelo a seguir es el módulo "core". En "modules/core/src/common/locales" se pueden ver los fichero "es.json" y "en.json" con las cadenas de cada idioma. Quiero lo mismo para el resto de módulos. Asi es más facil de mantener y de añadir nuevos idiomas ya que todo está localizado en un mismo sitio.
|
||||
|
||||
181
apps/server/src/brands.integration.ts
Normal file
181
apps/server/src/brands.integration.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
AssetResponseSchema,
|
||||
BrandResponseSchema,
|
||||
BrandsResponseSchema,
|
||||
CatalogResponseSchema,
|
||||
} from "@metis/assets/common";
|
||||
import { QueryTypes, type Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
importLegacyBrands,
|
||||
migrateBrands,
|
||||
} from "../../../modules/assets/src/api/infrastructure/persistence/brand-migration";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyBrands(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const path = "/asset-brands";
|
||||
async function list() {
|
||||
const response = await request(path);
|
||||
assert.equal(response.status, 200);
|
||||
return BrandsResponseSchema.parse(await response.json()).brands;
|
||||
}
|
||||
async function save(values: unknown, id?: string) {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
|
||||
return BrandResponseSchema.parse(await response.json()).brand;
|
||||
}
|
||||
async function error(url: string, body: unknown, status: number, code: string) {
|
||||
const response = await request(url, body);
|
||||
assert.equal(response.status, status, await response.clone().text());
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
}
|
||||
const initial = await list();
|
||||
for (const name of ["Microsoft", "HP", "LG", "Kyocera", "Zebra", "Logitech", "Clónico"]) {
|
||||
assert.ok(initial.some((brand) => brand.name === name && brand.builtin));
|
||||
}
|
||||
const builtin = initial.find((brand) => brand.name === "LG");
|
||||
assert.ok(builtin);
|
||||
await error(
|
||||
`${path}/${builtin.id}/update`,
|
||||
{ values: { name: "Otro LG", active: true } },
|
||||
409,
|
||||
"BUILTIN_PROTECTED"
|
||||
);
|
||||
await error(path, { values: { name: " lg ", active: true } }, 409, "NAME_EXISTS");
|
||||
await error(path, { values: { name: " ", active: true } }, 400, "INVALID_REQUEST");
|
||||
await error(
|
||||
`${path}/invalid/update`,
|
||||
{ values: { name: "Válida", active: true } },
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await error(
|
||||
`${path}/${randomUUID()}/update`,
|
||||
{ values: { name: "Válida", active: true } },
|
||||
404,
|
||||
"BRAND_NOT_FOUND"
|
||||
);
|
||||
assert.equal(
|
||||
(await request(path, { values: { name: "Sin CSRF", active: true } }, undefined, "invalid"))
|
||||
.status,
|
||||
403
|
||||
);
|
||||
const name = `Marca prueba ${randomUUID()}`;
|
||||
const created = await save({ name: ` ${name} `, active: true });
|
||||
assert.equal(created.name, name);
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.builtin, false);
|
||||
const concurrentName = `Concurrente ${randomUUID()}`;
|
||||
const concurrent = await Promise.all([
|
||||
request(path, { values: { name: concurrentName, active: true } }),
|
||||
request(path, { values: { name: concurrentName.toUpperCase(), active: true } }),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
|
||||
const catalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/asset-catalog/${companyId}`)).json()
|
||||
);
|
||||
const template = catalog.items.find((item) => item.values.name === "Portátil");
|
||||
assert.ok(template);
|
||||
const field = catalog.items.find((item) => item.referenceCatalog === "brands");
|
||||
assert.ok(field);
|
||||
const values = {
|
||||
name: "PORT-MARCA",
|
||||
templateId: template.id,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
status: "available",
|
||||
notes: "",
|
||||
brandId: created.id,
|
||||
characteristics: { [field.id]: "Nombre manipulado" },
|
||||
};
|
||||
async function asset(company: string, input: unknown, id?: string) {
|
||||
const response = await request(`/assets/${company}${id ? `/${id}/update` : ""}`, {
|
||||
values: input,
|
||||
});
|
||||
assert.equal(response.status, id ? 200 : 201, await response.clone().text());
|
||||
return AssetResponseSchema.parse(await response.json()).asset;
|
||||
}
|
||||
const first = await asset(companyId, values);
|
||||
assert.equal(first.values.brandId, created.id);
|
||||
assert.equal(first.values.characteristics[field.id], name);
|
||||
const same = await asset(companyId, { ...first.values }, first.id);
|
||||
assert.equal(same.history.length, 1);
|
||||
const foreignCatalog = CatalogResponseSchema.parse(
|
||||
await (await request(`/asset-catalog/${otherId}`)).json()
|
||||
);
|
||||
const foreignTemplate = foreignCatalog.items.find((item) => item.values.name === "Portátil");
|
||||
assert.ok(foreignTemplate);
|
||||
const second = await asset(otherId, { ...values, templateId: foreignTemplate.id });
|
||||
assert.equal(second.values.brandId, created.id);
|
||||
await save({ name, active: false }, created.id);
|
||||
await error(`/assets/${companyId}`, { values }, 409, "BRAND_UNAVAILABLE");
|
||||
const retained = await asset(
|
||||
companyId,
|
||||
{ ...first.values, notes: "Conserva marca inactiva" },
|
||||
first.id
|
||||
);
|
||||
assert.equal(retained.values.brandId, created.id);
|
||||
await save({ name: `${name} renombrada`, active: true }, created.id);
|
||||
const renamed = await asset(companyId, retained.values, first.id);
|
||||
assert.equal(renamed.values.characteristics[field.id], `${name} renombrada`);
|
||||
assert.equal(renamed.history[0]?.values.characteristics[field.id], name);
|
||||
await error(
|
||||
`/assets/${companyId}`,
|
||||
{ values: { ...values, brandId: randomUUID() } },
|
||||
409,
|
||||
"BRAND_UNAVAILABLE"
|
||||
);
|
||||
const snapshots = await database.query<{ brandId: string; values: unknown }>(
|
||||
"SELECT brandId,`values` FROM asset_revisions WHERE assetId=:id ORDER BY position",
|
||||
{ replacements: { id: first.id }, type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.ok(snapshots.every((row) => row.brandId === created.id));
|
||||
const legacyName = `Antigua ${randomUUID()}`;
|
||||
const legacyValues = {
|
||||
...values,
|
||||
brandId: undefined,
|
||||
characteristics: { [field.id]: legacyName },
|
||||
};
|
||||
const legacy = await asset(companyId, legacyValues);
|
||||
const before = await database.query(
|
||||
"SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position",
|
||||
{ replacements: { id: legacy.id }, type: QueryTypes.SELECT }
|
||||
);
|
||||
await database.query("UPDATE brand_catalog_lock SET legacyImported=0 WHERE id=1");
|
||||
await importLegacyBrands(database);
|
||||
const imported = (await list()).find((brand) => brand.name === legacyName);
|
||||
assert.ok(imported);
|
||||
assert.equal(imported.builtin, false);
|
||||
await save({ name: `${legacyName} editada`, active: false }, imported.id);
|
||||
await importLegacyBrands(database);
|
||||
assert.equal(
|
||||
(await list()).some((brand) => brand.name === legacyName),
|
||||
false
|
||||
);
|
||||
assert.deepEqual(
|
||||
await database.query("SELECT * FROM asset_revisions WHERE assetId=:id ORDER BY position", {
|
||||
replacements: { id: legacy.id },
|
||||
type: QueryTypes.SELECT,
|
||||
}),
|
||||
before
|
||||
);
|
||||
const linked = await asset(companyId, { ...legacy.values, brandId: builtin.id }, legacy.id);
|
||||
assert.equal(linked.history.length, 2);
|
||||
assert.equal(linked.history[0]?.values.characteristics[field.id], legacyName);
|
||||
assert.equal(linked.history[0]?.values.brandId, undefined);
|
||||
await save({ name: builtin.name, active: false }, builtin.id);
|
||||
await migrateBrands(database);
|
||||
const after = (await list()).find((brand) => brand.id === builtin.id);
|
||||
assert.equal(after?.active, false);
|
||||
assert.equal((await list()).length, new Set((await list()).map((brand) => brand.id)).size);
|
||||
await save({ name: builtin.name, active: true }, builtin.id);
|
||||
}
|
||||
@ -25,6 +25,7 @@ import { verifyAccess } from "./access.integration";
|
||||
import { createApp } from "./app";
|
||||
import { verifyApplications } from "./applications.integration";
|
||||
import { verifyAssets } from "./assets.integration";
|
||||
import { verifyBrands } from "./brands.integration";
|
||||
import { verifyCertificates } from "./certificates.integration";
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
@ -197,6 +198,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code);
|
||||
}
|
||||
assert.equal((await fetch(`${baseUrl}/asset-brands`)).status, 401);
|
||||
const admin = browser();
|
||||
const competitor = browser();
|
||||
assert.equal((await admin.session()).setupRequired, true);
|
||||
@ -844,6 +846,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
customer.id,
|
||||
secondCompany.id
|
||||
);
|
||||
await verifyBrands(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||
|
||||
@ -102,3 +102,6 @@ InterventionTemplate es un agregado con ámbito inmutable, clave idempotente y r
|
||||
## Intervenciones
|
||||
|
||||
Intervention es AggregateRoot con compañía, referencia, clave idempotente y procedimiento opcional inmutables. InterventionValues es Value Object, InterventionStep e InterventionRevision son DomainEntity. Los pasos mantienen UUID al actualizar/reordenar y generan identidades nuevas al aplicar plantillas. InterventionProcedure captura la identidad, revisión y valores originales de una plantilla mediante un Value Object inmutable. El historial permite conocer quién completó cada paso, incluso tras editar sus notas.
|
||||
|
||||
|
||||
Brand es la raíz del catálogo global de marcas, con Name como Value Object y UniqueID propio. Su repositorio conserva identidad y protege las marcas de serie. AssetValues reconstruye brandId opcional como Maybe<UniqueID>; los activos y sus revisiones conservan una instantánea del nombre junto a la referencia. Consultar [Catálogo global de marcas](../catalogo-global-de-marcas.md).
|
||||
|
||||
70
docs/catalogo-global-de-marcas.md
Normal file
70
docs/catalogo-global-de-marcas.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Catálogo global de marcas
|
||||
|
||||
## Finalidad y alcance
|
||||
|
||||
Marca identifica al fabricante de un activo; no es una plantilla ni un modelo comercial. El catálogo es global y común a todas las compañías. Por ahora no hay marcas particulares ni anulaciones por compañía, roles nuevos o catálogo de modelos comerciales.
|
||||
|
||||
Cada marca tiene UUID v7 estable, nombre de hasta 100 caracteres, estado activo/inactivo y origen de serie/personalizado. Los nombres se normalizan sin espacios exteriores y son únicos también entre marcas inactivas, sin distinguir mayúsculas o acentos.
|
||||
|
||||
Se incluyen 24 marcas habituales: Acer, Apple, ASUS, Brother, Canon, Clónico, Dell, Epson, HP, Huawei, Intel, Kyocera, Lenovo, LG, Logitech, Microsoft, MikroTik, MSI, QNAP, Samsung, Synology, TP-Link, Ubiquiti y Zebra. Clónico sirve para equipos montados con componentes de distintos fabricantes.
|
||||
|
||||
## Operaciones
|
||||
|
||||
- Añadir marcas globales personalizadas.
|
||||
- Renombrar y activar/desactivar marcas personalizadas.
|
||||
- Activar/desactivar marcas de serie, conservando su nombre protegido.
|
||||
- No hay borrado físico ni conversión de origen.
|
||||
- Una marca desactivada queda fuera de nuevas selecciones. Los activos que ya la tienen pueden conservarla, cambiarla o retirarla.
|
||||
- Marca continúa siendo opcional, salvo que una plantilla marque expresamente este campo obligatorio.
|
||||
|
||||
El usuario interno autenticado gestiona el catálogo desde Catálogos globales → Marcas. Los cambios están protegidos por sesión y CSRF. Las personas de compañías no acceden a esta pantalla.
|
||||
|
||||
## Interfaz y componentes
|
||||
|
||||
BrandCatalogPage utiliza EntityListHeader y EntityTable: título/subtítulo y alta en cabecera, búsqueda y filtro de estado, resultados y configuración, tabla y paginación. Columnas Nombre, Estado, Origen y Acciones. Altas y edición mediante FormDialog; BrandForm coloca ContextHelp inmediatamente antes de Guardar/Cancelar.
|
||||
|
||||
En un activo, el campo de serie Marca usa BrandSelect, adaptador funcional de SelectWithAction. El catálogo identifica ese campo por stockKey=field:brand y publica referenceCatalog=brands; no se detecta por nombre, por lo que campos personalizados homónimos y campos de recursos conservan su significado.
|
||||
|
||||
El botón de añadir abre el mismo BrandForm, mantiene el borrador del activo y selecciona el nuevo UUID. En este contexto la marca se crea activa. Cancelar el activo no elimina una marca que se haya guardado. Se respeta el límite existente de una sola alta anidada. Carga, error, reintento, etiqueta accesible y español/inglés siguen los componentes existentes.
|
||||
|
||||
La biblioteca genérica sigue independiente del dominio; consultas, DTO y reglas de marcas permanecen en el módulo assets.
|
||||
|
||||
## Dominio, persistencia e historial
|
||||
|
||||
Brand es una raíz AggregateRoot, con Name como Value Object y UniqueID como identidad. mapBrandInput valida la entrada y construye sus propiedades. BrandUseCases consulta el catálogo y aplica unicidad; el agregado protege origen/nombre y conserva identidad. Actualizaciones idénticas devuelven la misma instancia.
|
||||
|
||||
IBrandRepository define el puerto. BrandPersistenceMapper reconstruye y serializa los valores. SequelizeBrandRepository guarda en transacción, con un bloqueo de catálogo y restricción única en MariaDB. El inventario adquiere ese mismo bloqueo y lee las marcas con bloqueo de filas antes de validar nuevas asociaciones, evitando seleccionar una marca desactivada concurrentemente.
|
||||
|
||||
AssetValues incorpora brandId opcional como Maybe<UniqueID>. Activos y revisiones tienen clave foránea nullable con borrado restringido. El servidor comprueba disponibilidad y presencia del campo de serie en la especificación del activo, y escribe el nombre canónico de la marca como característica. Cada revisión conserva su UUID de marca y una instantánea de su nombre: renombrar el catálogo no reescribe el historial. Al guardar de nuevo el activo se captura el nombre actual.
|
||||
|
||||
## Migración compatible
|
||||
|
||||
migrateBrands crea tablas y semillas de manera idempotente, sin reactivar marcas desactivadas ni sustituir sus UUID. migrateInventory añade las referencias nullable explícitamente.
|
||||
|
||||
importLegacyBrands importa una única vez los nombres válidos que encuentre en el campo de serie de los activos existentes, como marcas personalizadas salvo coincidencia con una de serie. No modifica el JSON de activos, no crea revisiones y no toca el historial. Los textos anteriores permanecen visibles como Valor anterior y pueden conservarse o sustituirse por una selección explícita; no se asocian automáticamente por coincidencia de nombre. Valores antiguos que no cumplan el límite del catálogo permanecen en su ficha.
|
||||
|
||||
Un marcador transaccional evita que reinicios posteriores vuelvan a crear nombres de marcas personalizadas ya renombradas. Las semillas mantienen los nombres originales protegidos. El catálogo de campos/plantillas de recursos continúa separado.
|
||||
|
||||
## HTTP
|
||||
|
||||
- GET /api/asset-brands: listado global.
|
||||
- POST /api/asset-brands: alta con { values: { name, active } }.
|
||||
- POST /api/asset-brands/:id/update: actualización del mismo contrato.
|
||||
|
||||
Respuestas BrandsResponseSchema y BrandResponseSchema; parámetros, cuerpo y consultas estrictos. Duplicados y protección de serie devuelven 409, entrada inválida 400 y marca ausente 404. No se incorporan endpoints de eliminación ni nuevos permisos.
|
||||
|
||||
## Verificación
|
||||
|
||||
Pruebas de dominio y mappers: normalización, límites, UUID v7, origen protegido, estado, idempotencia y persistencia inválida. Pruebas de interfaz: traducción, estructura común, modal, ayuda inferior, nombre de serie protegido, valores anteriores/inactivos y límite de altas anidadadas.
|
||||
|
||||
La integración HTTP/MariaDB cubre autenticación y CSRF, unicidad concurrente, uso global en compañías diferentes, referencias válidas, nombre canónico, conservación de marca inactiva, revisión histórica tras renombrado, importación sin cambios del historial y semillas idempotentes.
|
||||
|
||||
|
||||
### Resultado de la entrega (7 de octubre de 2026)
|
||||
|
||||
- pnpm typecheck: correcto, 10 tareas.
|
||||
- pnpm test: correcto, 202 pruebas; incluye integración HTTP/MariaDB en metis_desk_test.
|
||||
- pnpm build: correcto, 2 tareas; se mantiene el aviso previo del tamaño del paquete web.
|
||||
- pnpm lint / pnpm check: ejecutados; conservan los 25/72 errores anteriores, fuera de los archivos modificados. Los 35 archivos de código/contratos modificados o nuevos pasan la comprobación dirigida sin avisos.
|
||||
- Navegador: catálogo y marcas importadas visibles, búsqueda y limpieza, filtro de estado y vacío, edición protegida, desplegable del activo, alta relacionada y cancelación conservando selección/borrador. Verificados español/inglés y anchura móvil sin desbordamiento horizontal de página.
|
||||
- La revisión visual no guardó activos ni marcas de prueba en la base de desarrollo. Las migraciones incorporan las semillas y nombres previos; las mutaciones de prueba se ejecutan sobre la base dedicada.
|
||||
@ -101,3 +101,10 @@ La paginación es local sobre los conjuntos que las API actuales ya devuelven. N
|
||||
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «tickets» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
|
||||
|
||||
Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas.
|
||||
|
||||
|
||||
## Catálogo de marcas y selector funcional
|
||||
|
||||
BrandCatalogPage reutiliza EntityListHeader/EntityTable con búsqueda, filtro de estado, columnas, ordenación y paginación. BrandForm reutiliza FormDialog, ContextHelp y FormActions tanto para la gestión global como para las altas relacionadas.
|
||||
|
||||
BrandSelect, dentro del módulo assets, adapta SelectWithAction al catálogo remoto: filtra marcas activas, conserva la selección inactiva actual y textos anteriores, ofrece alta rápida y respeta una sola anidación. El borrador padre se conserva y la nueva marca se selecciona por UUID. El componente genérico no conoce BrandDTO, consultas ni endpoints. Véase [Catálogo global de marcas](catalogo-global-de-marcas.md).
|
||||
|
||||
@ -45,3 +45,8 @@ Las pruebas verifican características opcionales y obligatorias, tipos, fechas,
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Los perfiles y sus configuraciones están implementados, así como la [autorización de teletrabajo](teletrabajo.md). La conectividad remota, el registro mínimo de equipos personales y los procesos de salida mantienen su orden en el plan.
|
||||
|
||||
La ficha de cada activo, incluida su vista lateral, incorpora [Asistencia técnica remota](asistencia-tecnica-remota.md): varios métodos, catálogo de herramientas, preferido opcional e historial.
|
||||
|
||||
|
||||
## Marca del activo
|
||||
|
||||
El campo de serie Marca se selecciona desde el [catálogo global de marcas](catalogo-global-de-marcas.md), con alta rápida. Continúa siendo opcional por defecto. AssetValues guarda una referencia UUID opcional y la característica conserva el nombre como instantánea en cada revisión. Las marcas inactivas existentes y los textos anteriores se mantienen; la importación no reescribe fichas ni historial.
|
||||
|
||||
@ -72,6 +72,7 @@ La aplicación navega por espacios y secciones en memoria, no mediante rutas URL
|
||||
| Plantillas de intervención, global/compañía | companies/intervention-templates/templates-page.tsx | Sí |
|
||||
| Tickets, global/compañía | companies/tickets/tickets-page.tsx | Sí |
|
||||
| Intervenciones, global/compañía | companies/interventions/interventions-page.tsx | Sí |
|
||||
| Marcas globales | assets/brands-page.tsx | Sí; EntityListHeader y EntityTable, filtro de estado y modales |
|
||||
| Herramientas de asistencia | assets/support-tools.tsx | Sí; catálogo dentro de un modal |
|
||||
| Asignaciones departamentales | PeopleSection → PersonDetails | Sublistado de ficha; conserva sus operaciones y adopta tabla común |
|
||||
|
||||
|
||||
@ -119,3 +119,8 @@ Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperació
|
||||
## Plantillas de intervención
|
||||
|
||||
Implementadas las plantillas globales y propias por compañía con pasos ordenados, historial y seis procedimientos iniciales. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md). Las intervenciones concretas y la ejecución de los pasos están implementadas; las actuaciones sencillas admiten registro sin plantilla.
|
||||
|
||||
|
||||
## Catálogo global de marcas: implementado
|
||||
|
||||
24 marcas de serie protegidas y marcas personalizadas, nombre único, activación/desactivación y UUID estable. Listado global común, modales con ayuda inferior y selección con alta rápida en activos. Importación compatible de nombres existentes y referencia opcional con instantáneas históricas. Consultar [Catálogo global de marcas](catalogo-global-de-marcas.md).
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx src/api/domain/support.test.ts src/web/support.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog.test.tsx src/web/inventory.test.tsx src/api/domain/support.test.ts src/web/support.test.tsx src/api/domain/brand.test.ts src/web/brands.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -12,6 +12,9 @@ export function buildCatalogResponse(
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
builtin: item.stockKey.getOrUndefined() !== undefined,
|
||||
...(item.stockKey.getOrUndefined() === "field:brand"
|
||||
? { referenceCatalog: "brands" as const }
|
||||
: {}),
|
||||
active: item.active,
|
||||
effectiveActive: catalog.active(item, scope),
|
||||
overridden: catalog.availability.some(
|
||||
|
||||
@ -55,6 +55,49 @@ export function mapAssetInput(
|
||||
}
|
||||
specification = created.data;
|
||||
}
|
||||
const values = AssetValues.create(input, specification);
|
||||
const mapped = mapBrandReference(input, specification, context, previous);
|
||||
if (!mapped.isSuccess) {
|
||||
return mapped;
|
||||
}
|
||||
const values = AssetValues.create(mapped.data, specification);
|
||||
return values.isSuccess ? Result.ok({ specification, values: values.data }) : values;
|
||||
}
|
||||
|
||||
function mapBrandReference(
|
||||
input: AssetInput,
|
||||
specification: AssetSpecification,
|
||||
context: InventoryContext,
|
||||
previous?: Asset
|
||||
): Result<AssetInput, AssetCatalogError> {
|
||||
let normalized = input;
|
||||
if (input.brandId) {
|
||||
const brand = context.brands.find(
|
||||
(item) => item.id.toPrimitive() === input.brandId?.toLowerCase()
|
||||
);
|
||||
const field = context.catalog.definitions.find(
|
||||
(item) => item.stockKey.getOrUndefined() === "field:brand"
|
||||
);
|
||||
if (
|
||||
!brand ||
|
||||
(!brand.active && previous?.values.toPrimitive().brandId !== brand.id.toPrimitive())
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("BRAND_UNAVAILABLE"));
|
||||
}
|
||||
if (
|
||||
!(
|
||||
field &&
|
||||
specification.toPrimitive().fields.some((item) => item.fieldId === field.id.toPrimitive())
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS"));
|
||||
}
|
||||
normalized = {
|
||||
...input,
|
||||
characteristics: {
|
||||
...input.characteristics,
|
||||
[field.id.toPrimitive()]: brand.name.toPrimitive(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return Result.ok(normalized);
|
||||
}
|
||||
|
||||
@ -5,12 +5,14 @@ import type { Asset } from "../domain/asset.aggregate";
|
||||
import type { AssetCatalog } from "../domain/asset-catalog.aggregate";
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import type { AssetRelationship } from "../domain/asset-relationship.aggregate";
|
||||
import type { Brand } from "../domain/brand.aggregate";
|
||||
import type { AssetValues } from "../domain/value-objects/asset-values";
|
||||
import type { Workstation } from "../domain/workstation.aggregate";
|
||||
|
||||
export type InventoryContext = Readonly<{
|
||||
workstations: readonly Workstation[];
|
||||
catalog: AssetCatalog;
|
||||
brands: readonly Brand[];
|
||||
assets: readonly Asset[];
|
||||
relationships: readonly AssetRelationship[];
|
||||
sites: readonly UniqueID[];
|
||||
|
||||
20
modules/assets/src/api/application/brand-input.mapper.ts
Normal file
20
modules/assets/src/api/application/brand-input.mapper.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Name } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
BrandError,
|
||||
type BrandInput,
|
||||
BrandInputSchema,
|
||||
type BrandProps,
|
||||
} from "../domain/brand.aggregate";
|
||||
export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandProps, BrandError> {
|
||||
const parsed = BrandInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new BrandError("INVALID_REQUEST"));
|
||||
}
|
||||
const name = Name.create(parsed.data.name, 100);
|
||||
if (!name.isSuccess) {
|
||||
return Result.fail(new BrandError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok({ name: name.data, active: parsed.data.active, builtin });
|
||||
}
|
||||
10
modules/assets/src/api/application/brand-response.builder.ts
Normal file
10
modules/assets/src/api/application/brand-response.builder.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { BrandDTO } from "../../common/brands";
|
||||
import type { Brand } from "../domain/brand.aggregate";
|
||||
export function buildBrand(brand: Brand): BrandDTO {
|
||||
return {
|
||||
id: brand.id.toPrimitive(),
|
||||
name: brand.name.toPrimitive(),
|
||||
active: brand.active,
|
||||
builtin: brand.builtin,
|
||||
};
|
||||
}
|
||||
9
modules/assets/src/api/application/brand.repository.ts
Normal file
9
modules/assets/src/api/application/brand.repository.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Brand, BrandError } from "../domain/brand.aggregate";
|
||||
export interface IBrandRepository {
|
||||
list(): Promise<readonly Brand[]>;
|
||||
save(
|
||||
action: (brands: readonly Brand[]) => Result<Brand, BrandError>
|
||||
): Promise<Result<Brand, BrandError>>;
|
||||
}
|
||||
42
modules/assets/src/api/application/brand.use-cases.ts
Normal file
42
modules/assets/src/api/application/brand.use-cases.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Brand, BrandError, type BrandInput } from "../domain/brand.aggregate";
|
||||
|
||||
import type { IBrandRepository } from "./brand.repository";
|
||||
import { mapBrandInput } from "./brand-input.mapper";
|
||||
export class BrandUseCases {
|
||||
constructor(private readonly repository: IBrandRepository) {}
|
||||
list() {
|
||||
return this.repository.list();
|
||||
}
|
||||
save(id: string | undefined, input: BrandInput): Promise<Result<Brand, BrandError>> {
|
||||
if (id && !UniqueID.create(id).isSuccess) {
|
||||
return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.save((brands) => {
|
||||
const previous = id
|
||||
? brands.find((brand) => brand.id.toPrimitive() === id.toLowerCase())
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new BrandError("BRAND_NOT_FOUND"));
|
||||
}
|
||||
const mapped = mapBrandInput(input, previous?.builtin ?? false);
|
||||
if (!mapped.isSuccess) {
|
||||
return Result.fail(mapped.error);
|
||||
}
|
||||
if (
|
||||
brands.some(
|
||||
(brand) =>
|
||||
brand !== previous &&
|
||||
brand.name
|
||||
.toPrimitive()
|
||||
.localeCompare(mapped.data.name.toPrimitive(), "en", { sensitivity: "base" }) === 0
|
||||
)
|
||||
) {
|
||||
return Result.fail(new BrandError("NAME_EXISTS"));
|
||||
}
|
||||
return previous ? previous.update(mapped.data) : Brand.create(mapped.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ export const AssetInputSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
siteId: z.uuid().nullable(),
|
||||
personId: z.uuid().nullable(),
|
||||
brandId: z.uuid().nullable().optional(),
|
||||
status: AssetStatusSchema,
|
||||
notes: z.string().max(10000),
|
||||
characteristics: z.record(z.uuid(), AssetValueSchema),
|
||||
|
||||
70
modules/assets/src/api/domain/brand.aggregate.ts
Normal file
70
modules/assets/src/api/domain/brand.aggregate.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { AggregateRoot, type Name, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const BrandInputSchema = z.strictObject({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
});
|
||||
export type BrandInput = z.infer<typeof BrandInputSchema>;
|
||||
export class BrandError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export type BrandProps = Readonly<{ name: Name; active: boolean; builtin: boolean }>;
|
||||
export class Brand extends AggregateRoot<BrandProps> {
|
||||
private constructor(props: BrandProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props }), id);
|
||||
}
|
||||
static create(props: BrandProps): Result<Brand, BrandError> {
|
||||
return Result.ok(new Brand(props));
|
||||
}
|
||||
static rehydrate(props: BrandProps, id: UniqueID) {
|
||||
return new Brand(props, id);
|
||||
}
|
||||
get name() {
|
||||
return this.props.name;
|
||||
}
|
||||
get active() {
|
||||
return this.props.active;
|
||||
}
|
||||
get builtin() {
|
||||
return this.props.builtin;
|
||||
}
|
||||
update(props: BrandProps): Result<Brand, BrandError> {
|
||||
if (props.builtin !== this.builtin || (this.builtin && !this.name.equals(props.name))) {
|
||||
return Result.fail(new BrandError("BUILTIN_PROTECTED"));
|
||||
}
|
||||
if (this.name.equals(props.name) && this.active === props.active) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(new Brand(props, this.id));
|
||||
}
|
||||
}
|
||||
export const BUILTIN_BRANDS = [
|
||||
"Acer",
|
||||
"Apple",
|
||||
"ASUS",
|
||||
"Brother",
|
||||
"Canon",
|
||||
"Clónico",
|
||||
"Dell",
|
||||
"Epson",
|
||||
"HP",
|
||||
"Huawei",
|
||||
"Intel",
|
||||
"Kyocera",
|
||||
"Lenovo",
|
||||
"LG",
|
||||
"Logitech",
|
||||
"Microsoft",
|
||||
"MikroTik",
|
||||
"MSI",
|
||||
"QNAP",
|
||||
"Samsung",
|
||||
"Synology",
|
||||
"TP-Link",
|
||||
"Ubiquiti",
|
||||
"Zebra",
|
||||
] as const;
|
||||
84
modules/assets/src/api/domain/brand.test.ts
Normal file
84
modules/assets/src/api/domain/brand.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import type { IBrandRepository } from "../application/brand.repository";
|
||||
import { BrandUseCases } from "../application/brand.use-cases";
|
||||
import { mapBrandInput } from "../application/brand-input.mapper";
|
||||
import { BrandPersistenceMapper } from "../infrastructure/persistence/brand-persistence.mapper";
|
||||
|
||||
import { Brand } from "./brand.aggregate";
|
||||
|
||||
function brand(name = "LG", builtin = true) {
|
||||
const props = mapBrandInput({ name, active: true }, builtin);
|
||||
assert.ok(props.isSuccess);
|
||||
const created = Brand.create(props.data);
|
||||
assert.ok(created.isSuccess);
|
||||
return created.data;
|
||||
}
|
||||
test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempotencia", () => {
|
||||
for (const name of ["", " ", "x".repeat(101)]) {
|
||||
assert.equal(mapBrandInput({ name, active: true }).isSuccess, false);
|
||||
}
|
||||
const original = brand(" LG ");
|
||||
assert.equal(original.id.toPrimitive()[14], "7");
|
||||
assert.equal(original.name.toPrimitive(), "LG");
|
||||
const same = original.update({ name: original.name, active: true, builtin: true });
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, original);
|
||||
const inactive = original.update({ name: original.name, active: false, builtin: true });
|
||||
assert.ok(inactive.isSuccess);
|
||||
assert.equal(inactive.data.id.toPrimitive(), original.id.toPrimitive());
|
||||
assert.equal(inactive.data.active, false);
|
||||
const renamed = mapBrandInput({ name: "Otra", active: true }, true);
|
||||
assert.ok(renamed.isSuccess);
|
||||
const blocked = original.update(renamed.data);
|
||||
assert.equal(blocked.isSuccess, false);
|
||||
if (!blocked.isSuccess) {
|
||||
assert.equal(blocked.error.code, "BUILTIN_PROTECTED");
|
||||
}
|
||||
assert.equal(original.update({ ...renamed.data, builtin: false }).isSuccess, false);
|
||||
const custom = brand("Propia", false);
|
||||
assert.ok(custom.update({ ...renamed.data, builtin: false }).isSuccess);
|
||||
});
|
||||
test("Marcas: mapper bidireccional conserva identidad y rechaza persistencia inválida", () => {
|
||||
const mapper = new BrandPersistenceMapper();
|
||||
const original = brand();
|
||||
const row = mapper.toPersistence(original);
|
||||
const restored = mapper.toDomain({ ...row, active: 1, builtin: 1 });
|
||||
assert.ok(restored.id.equals(original.id));
|
||||
assert.ok(restored.name.equals(original.name));
|
||||
assert.throws(() => mapper.toDomain({ ...row, active: 2 }));
|
||||
assert.throws(() => mapper.toDomain({ ...row, id: "invalid" }));
|
||||
assert.throws(() => mapper.toDomain({ ...row, name: " " }));
|
||||
});
|
||||
test("Marcas: catálogo rechaza duplicados, también inactivos, y permite renombrar personalizados", async () => {
|
||||
let brands: Brand[] = [brand(), brand("Propia", false)];
|
||||
const repository: IBrandRepository = {
|
||||
list: async () => brands,
|
||||
save: async (action) => {
|
||||
const result = action(brands);
|
||||
if (result.isSuccess) {
|
||||
brands = [...brands.filter((item) => !item.id.equals(result.data.id)), result.data];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const useCases = new BrandUseCases(repository);
|
||||
const existing = brands[1];
|
||||
assert.ok(existing);
|
||||
const disabled = await useCases.save(existing.id.toPrimitive(), {
|
||||
name: "Propia",
|
||||
active: false,
|
||||
});
|
||||
assert.ok(disabled.isSuccess);
|
||||
const duplicate = await useCases.save(undefined, { name: " PROPIA ", active: true });
|
||||
assert.equal(duplicate.isSuccess, false);
|
||||
if (!duplicate.isSuccess) {
|
||||
assert.equal(duplicate.error.code, "NAME_EXISTS");
|
||||
}
|
||||
const renamed = await useCases.save(existing.id.toPrimitive(), { name: "Nueva", active: true });
|
||||
assert.ok(renamed.isSuccess);
|
||||
assert.equal(renamed.data.id.toPrimitive(), existing.id.toPrimitive());
|
||||
const invalid = await useCases.save("invalid", { name: "Nueva", active: true });
|
||||
assert.equal(invalid.isSuccess, false);
|
||||
});
|
||||
@ -93,7 +93,8 @@ export class AssetValues {
|
||||
private readonly data: AssetInput,
|
||||
public readonly name: Name,
|
||||
public readonly siteId: Maybe<UniqueID>,
|
||||
public readonly personId: Maybe<UniqueID>
|
||||
public readonly personId: Maybe<UniqueID>,
|
||||
public readonly brandId: Maybe<UniqueID>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
@ -131,12 +132,14 @@ export class AssetValues {
|
||||
templateId: data.templateId.toLowerCase(),
|
||||
siteId: data.siteId?.toLowerCase() ?? null,
|
||||
personId: data.personId?.toLowerCase() ?? null,
|
||||
...(data.brandId === undefined ? {} : { brandId: data.brandId?.toLowerCase() ?? null }),
|
||||
notes: data.notes.trim(),
|
||||
characteristics: Object.freeze(characteristics),
|
||||
}),
|
||||
name.data,
|
||||
site,
|
||||
person
|
||||
person,
|
||||
optionalIdentity(data.brandId ?? null)
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -151,6 +154,7 @@ export class AssetValues {
|
||||
first.templateId === second.templateId &&
|
||||
first.siteId === second.siteId &&
|
||||
first.personId === second.personId &&
|
||||
(first.brandId ?? null) === (second.brandId ?? null) &&
|
||||
first.status === second.status &&
|
||||
first.notes === second.notes &&
|
||||
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
||||
|
||||
56
modules/assets/src/api/infrastructure/http/brand.routes.ts
Normal file
56
modules/assets/src/api/infrastructure/http/brand.routes.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { BrandInputSchema } from "../../../common/brands";
|
||||
import type { BrandUseCases } from "../../application/brand.use-cases";
|
||||
import { buildBrand } from "../../application/brand-response.builder";
|
||||
|
||||
const Empty = z.strictObject({});
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code === "BRAND_NOT_FOUND" ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
export function registerBrandRoutes(
|
||||
app: Application,
|
||||
useCases: BrandUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/", (request, response, next) => {
|
||||
if (!Empty.safeParse(request.query).success) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.list()
|
||||
.then((brands) => response.json({ brands: brands.map(buildBrand) }))
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/", "/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (path.endsWith("update") ? z.strictObject({ id: z.uuid() }) : Empty).safeParse(
|
||||
request.params
|
||||
);
|
||||
const body = z.strictObject({ values: BrandInputSchema }).safeParse(request.body);
|
||||
if (!(params.success && body.success && Empty.safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.save(id, body.data.values)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({ brand: buildBrand(result.data) });
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/asset-brands", authenticate, router);
|
||||
}
|
||||
@ -30,7 +30,8 @@ export class AssetPersistenceMapper {
|
||||
if (
|
||||
row.templateId !== current.templateId ||
|
||||
row.siteId !== current.siteId ||
|
||||
row.personId !== current.personId
|
||||
row.personId !== current.personId ||
|
||||
row.brandId !== (current.brandId ?? null)
|
||||
) {
|
||||
throw new Error("Referencias de activo persistidas inconsistentes.");
|
||||
}
|
||||
@ -50,7 +51,8 @@ export class AssetPersistenceMapper {
|
||||
}
|
||||
if (
|
||||
revision.siteId !== previous.data.toPrimitive().siteId ||
|
||||
revision.personId !== previous.data.toPrimitive().personId
|
||||
revision.personId !== previous.data.toPrimitive().personId ||
|
||||
revision.brandId !== (previous.data.toPrimitive().brandId ?? null)
|
||||
) {
|
||||
throw new Error("Referencias de revisión persistidas inconsistentes.");
|
||||
}
|
||||
@ -85,6 +87,7 @@ export class AssetPersistenceMapper {
|
||||
templateId: values.templateId,
|
||||
siteId: values.siteId,
|
||||
personId: values.personId,
|
||||
brandId: values.brandId ?? null,
|
||||
specification: asset.specification.toPrimitive(),
|
||||
values,
|
||||
},
|
||||
@ -94,6 +97,7 @@ export class AssetPersistenceMapper {
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
siteId: revision.values.toPrimitive().siteId,
|
||||
personId: revision.values.toPrimitive().personId,
|
||||
brandId: revision.values.toPrimitive().brandId ?? null,
|
||||
recordedAt: revision.recordedAt,
|
||||
values: revision.values.toPrimitive(),
|
||||
position,
|
||||
|
||||
@ -9,6 +9,7 @@ export class AssetModel extends Model<
|
||||
declare templateId: string;
|
||||
declare siteId: string | null;
|
||||
declare personId: string | null;
|
||||
declare brandId: string | null;
|
||||
declare specification: unknown;
|
||||
declare values: unknown;
|
||||
}
|
||||
@ -24,6 +25,7 @@ export class AssetRevisionModel extends Model<
|
||||
declare position: number;
|
||||
declare siteId: string | null;
|
||||
declare personId: string | null;
|
||||
declare brandId: string | null;
|
||||
}
|
||||
const id = { type: DataTypes.UUID, primaryKey: true };
|
||||
const fk = (model: string, nullable = false) => ({
|
||||
@ -44,6 +46,7 @@ export async function migrateInventory(database: Sequelize) {
|
||||
templateId: fk("asset_definitions"),
|
||||
siteId: fk("company_sites", true),
|
||||
personId: fk("company_contacts", true),
|
||||
brandId: fk("asset_brands", true),
|
||||
specification: { type: DataTypes.JSON, allowNull: false },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
};
|
||||
@ -53,6 +56,7 @@ export async function migrateInventory(database: Sequelize) {
|
||||
actorId: fk("users"),
|
||||
siteId: fk("company_sites", true),
|
||||
personId: fk("company_contacts", true),
|
||||
brandId: fk("asset_brands", true),
|
||||
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
@ -63,6 +67,12 @@ export async function migrateInventory(database: Sequelize) {
|
||||
if (!tables.some((table) => table.name === "asset_revisions")) {
|
||||
await query.createTable("asset_revisions", revision);
|
||||
}
|
||||
for (const table of ["assets", "asset_revisions"]) {
|
||||
const columns = await query.describeTable(table);
|
||||
if (!("brandId" in columns)) {
|
||||
await query.addColumn(table, "brandId", fk("asset_brands", true));
|
||||
}
|
||||
}
|
||||
const revisionColumns = await query.describeTable("asset_revisions");
|
||||
for (const [column, reference] of [
|
||||
["siteId", "company_sites"],
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { mapBrandInput } from "../../application/brand-input.mapper";
|
||||
import { BUILTIN_BRANDS } from "../../domain/brand.aggregate";
|
||||
export async function migrateBrands(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 }
|
||||
);
|
||||
if (!tables.some((table) => table.name === "asset_brands")) {
|
||||
await query.createTable("asset_brands", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
builtin: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
});
|
||||
}
|
||||
if (!tables.some((table) => table.name === "brand_catalog_lock")) {
|
||||
await query.createTable("brand_catalog_lock", {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true },
|
||||
legacyImported: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
});
|
||||
}
|
||||
await database.query("INSERT IGNORE INTO brand_catalog_lock (id,legacyImported) VALUES (1,0)");
|
||||
await database.transaction(async (transaction) => {
|
||||
await database.query("SELECT id FROM brand_catalog_lock WHERE id=1 FOR UPDATE", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
for (const name of BUILTIN_BRANDS) {
|
||||
await database.query(
|
||||
"INSERT INTO asset_brands (id,name,active,builtin) SELECT :id,:name,1,1 WHERE NOT EXISTS (SELECT 1 FROM asset_brands WHERE name=:name)",
|
||||
{ replacements: { id: UniqueID.generateNewID().toPrimitive(), name }, transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Importación única: nunca reescribe activos ni revisiones ni vuelve a crear nombres renombrados.
|
||||
export async function importLegacyBrands(database: Sequelize) {
|
||||
await database.transaction(async (transaction) => {
|
||||
const locks = await database.query<{ legacyImported: number }>(
|
||||
"SELECT legacyImported FROM brand_catalog_lock WHERE id=1 FOR UPDATE",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (locks[0]?.legacyImported) {
|
||||
return;
|
||||
}
|
||||
const definitions = await database.query<{ id: string }>(
|
||||
"SELECT id FROM asset_definitions WHERE stockKey='field:brand'",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const fieldId = definitions[0]?.id;
|
||||
if (fieldId) {
|
||||
const rows = await database.query<{ values: unknown }>("SELECT `values` FROM assets", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
for (const row of rows) {
|
||||
const name = legacyBrandName(row.values, fieldId);
|
||||
if (name === undefined) {
|
||||
continue;
|
||||
}
|
||||
await database.query(
|
||||
"INSERT INTO asset_brands (id,name,active,builtin) SELECT :id,:name,1,0 WHERE NOT EXISTS (SELECT 1 FROM asset_brands WHERE name=:name)",
|
||||
{
|
||||
replacements: {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
name,
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
await database.query("UPDATE brand_catalog_lock SET legacyImported=1 WHERE id=1", {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const LegacyValues = z.object({ characteristics: z.record(z.string(), z.unknown()) });
|
||||
function legacyBrandName(raw: unknown, fieldId: string): string | undefined {
|
||||
const parsed = LegacyValues.safeParse(typeof raw === "string" ? JSON.parse(raw) : raw);
|
||||
if (!parsed.success) {
|
||||
return undefined;
|
||||
}
|
||||
const name = parsed.data.characteristics[fieldId];
|
||||
if (typeof name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const mapped = mapBrandInput({ name, active: true });
|
||||
return mapped.isSuccess ? mapped.data.name.toPrimitive() : undefined;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { Brand } from "../../domain/brand.aggregate";
|
||||
|
||||
const Row = z.strictObject({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
active: z.union([z.boolean(), z.literal(0), z.literal(1)]),
|
||||
builtin: z.union([z.boolean(), z.literal(0), z.literal(1)]),
|
||||
});
|
||||
export class BrandPersistenceMapper {
|
||||
toDomain(input: unknown) {
|
||||
const row = Row.parse(input);
|
||||
const id = UniqueID.create(row.id);
|
||||
const name = Name.create(row.name, 100);
|
||||
if (!(id.isSuccess && name.isSuccess)) {
|
||||
throw new Error("Marca persistida inconsistente.");
|
||||
}
|
||||
return Brand.rehydrate(
|
||||
{ name: name.data, active: Boolean(row.active), builtin: Boolean(row.builtin) },
|
||||
id.data
|
||||
);
|
||||
}
|
||||
toPersistence(brand: Brand) {
|
||||
return {
|
||||
id: brand.id.toPrimitive(),
|
||||
name: brand.name.toPrimitive(),
|
||||
active: brand.active,
|
||||
builtin: brand.builtin,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.m
|
||||
import { RelationshipModel } from "./asset-relationship.model";
|
||||
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
||||
import { loadWorkstations } from "./load-workstations";
|
||||
import { readBrands } from "./sequelize-brand.repository";
|
||||
import {
|
||||
WorkstationAssetModel,
|
||||
WorkstationModel,
|
||||
@ -143,6 +144,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
const workstations = await loadWorkstations(companyId, transaction, assets, sites, people);
|
||||
return Result.ok({
|
||||
workstations,
|
||||
brands: await readBrands(this.database, transaction, write),
|
||||
relationships,
|
||||
catalog,
|
||||
assets,
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type { IBrandRepository } from "../../application/brand.repository";
|
||||
|
||||
import { BrandPersistenceMapper } from "./brand-persistence.mapper";
|
||||
export async function readBrands(database: Sequelize, transaction?: Transaction, lock = false) {
|
||||
if (lock && transaction) {
|
||||
await database.query("SELECT id FROM brand_catalog_lock WHERE id=1 FOR UPDATE", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
}
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
`SELECT id,name,active,builtin FROM asset_brands ORDER BY name${lock ? " FOR UPDATE" : ""}`,
|
||||
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
|
||||
);
|
||||
return rows.map((row) => new BrandPersistenceMapper().toDomain(row));
|
||||
}
|
||||
export class SequelizeBrandRepository implements IBrandRepository {
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
list() {
|
||||
return readBrands(this.database);
|
||||
}
|
||||
save(action: Parameters<IBrandRepository["save"]>[0]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const brands = await readBrands(this.database, transaction, true);
|
||||
const result = action(brands);
|
||||
if (!result.isSuccess) {
|
||||
return result;
|
||||
}
|
||||
const previous = brands.find((brand) => brand.id.equals(result.data.id));
|
||||
if (previous !== result.data) {
|
||||
await this.database.query(
|
||||
previous
|
||||
? "UPDATE asset_brands SET name=:name,active=:active WHERE id=:id"
|
||||
: "INSERT INTO asset_brands (id,name,active,builtin) VALUES (:id,:name,:active,:builtin)",
|
||||
{ replacements: new BrandPersistenceMapper().toPersistence(result.data), transaction }
|
||||
);
|
||||
}
|
||||
return Result.ok(result.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,14 @@ import type { Sequelize } from "sequelize";
|
||||
import { AssetUseCases } from "./application/asset.use-cases";
|
||||
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
|
||||
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
||||
import { BrandUseCases } from "./application/brand.use-cases";
|
||||
import { SupportUseCases } from "./application/support.use-cases";
|
||||
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
||||
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
||||
import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
||||
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
|
||||
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
||||
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
|
||||
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
|
||||
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
||||
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||
@ -21,8 +23,10 @@ import {
|
||||
} from "./infrastructure/persistence/asset-catalog.model";
|
||||
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { importLegacyBrands, migrateBrands } from "./infrastructure/persistence/brand-migration";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||
import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository";
|
||||
import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository";
|
||||
import { migrateSupport } from "./infrastructure/persistence/support-migration";
|
||||
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
||||
@ -42,7 +46,10 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
if (!seeded.isSuccess) {
|
||||
throw new Error("No se pudo inicializar el catálogo de activos.");
|
||||
}
|
||||
await migrateBrands(database);
|
||||
await migrateInventory(database);
|
||||
await importLegacyBrands(database);
|
||||
const brands = new BrandUseCases(new SequelizeBrandRepository(database));
|
||||
await migrateRelationships(database);
|
||||
await migrateWorkstations(database);
|
||||
await migrateSupport(database);
|
||||
@ -81,6 +88,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
},
|
||||
},
|
||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||
registerBrandRoutes(app, brands, authenticate);
|
||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||
registerAssetRoutes(app, inventory, authenticate);
|
||||
registerRelationshipRoutes(app, relationships, authenticate);
|
||||
|
||||
16
modules/assets/src/common/brands.ts
Normal file
16
modules/assets/src/common/brands.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { BrandInputSchema } from "../api/domain/brand.aggregate";
|
||||
|
||||
export type { BrandInput } from "../api/domain/brand.aggregate";
|
||||
|
||||
export { BrandInputSchema };
|
||||
export const BrandSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
builtin: z.boolean(),
|
||||
});
|
||||
export const BrandsResponseSchema = z.strictObject({ brands: z.array(BrandSchema) });
|
||||
export const BrandResponseSchema = z.strictObject({ brand: BrandSchema });
|
||||
export type BrandDTO = z.infer<typeof BrandSchema>;
|
||||
@ -15,6 +15,7 @@ export const CatalogItemSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid().nullable(),
|
||||
builtin: z.boolean(),
|
||||
referenceCatalog: z.literal("brands").optional(),
|
||||
active: z.boolean(),
|
||||
effectiveActive: z.boolean(),
|
||||
overridden: z.boolean(),
|
||||
@ -27,6 +28,7 @@ export type CatalogItemDTO = z.infer<typeof CatalogItemSchema>;
|
||||
export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
|
||||
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
||||
|
||||
export * from "./brands";
|
||||
export * from "./inventory";
|
||||
export * from "./relationships";
|
||||
export * from "./support";
|
||||
|
||||
@ -16,6 +16,7 @@ import type { AssetDTO, AssetInput } from "../common/inventory";
|
||||
import { AssetInputSchema, AssetStatusSchema } from "../common/inventory";
|
||||
|
||||
import { AssetCharacteristicControl } from "./asset-characteristic-control";
|
||||
import { BrandSelect } from "./brand-select";
|
||||
import { CatalogTemplateForm } from "./catalog-template-form";
|
||||
import { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
|
||||
export type InventoryLookups = {
|
||||
@ -73,6 +74,7 @@ export function AssetForm({
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const brandFieldId = items.find((item) => item.referenceCatalog === "brands")?.id;
|
||||
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
|
||||
const template = templates.find((item) => item.id === values.templateId);
|
||||
const fields =
|
||||
@ -142,6 +144,7 @@ export function AssetForm({
|
||||
onValueChange={(templateId) => {
|
||||
form.setValue("templateId", templateId);
|
||||
form.setValue("characteristics", {});
|
||||
form.setValue("brandId", null);
|
||||
}}
|
||||
options={
|
||||
asset
|
||||
@ -230,16 +233,38 @@ export function AssetForm({
|
||||
<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]}
|
||||
/>
|
||||
))}
|
||||
{fields.map((field) =>
|
||||
field.fieldId === brandFieldId ? (
|
||||
<BrandSelect
|
||||
allowQuickCreate={allowQuickCreate}
|
||||
brandId={values.brandId ?? null}
|
||||
csrfToken={csrfToken}
|
||||
disabled={mutation.isPending}
|
||||
id={`${prefix}-${field.fieldId}`}
|
||||
key={field.fieldId}
|
||||
label={field.name}
|
||||
legacyName={
|
||||
typeof values.characteristics[field.fieldId] === "string"
|
||||
? String(values.characteristics[field.fieldId])
|
||||
: ""
|
||||
}
|
||||
onChange={(brand) => {
|
||||
form.setValue("brandId", brand?.id ?? null, { shouldDirty: true });
|
||||
setCharacteristic(field.fieldId, brand?.name);
|
||||
}}
|
||||
required={field.required}
|
||||
/>
|
||||
) : (
|
||||
<AssetCharacteristicControl
|
||||
disabled={mutation.isPending}
|
||||
field={field}
|
||||
key={field.fieldId}
|
||||
onChange={(value) => setCharacteristic(field.fieldId, value)}
|
||||
prefix={prefix}
|
||||
value={values.characteristics[field.fieldId]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@ -294,6 +319,7 @@ export function AssetForm({
|
||||
onSaved={(id) => {
|
||||
form.setValue("templateId", id);
|
||||
form.setValue("characteristics", {});
|
||||
form.setValue("brandId", null);
|
||||
setCreatingTemplate(false);
|
||||
}}
|
||||
scope={companyId}
|
||||
|
||||
34
modules/assets/src/web/brand-api.ts
Normal file
34
modules/assets/src/web/brand-api.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { type BrandInput, BrandResponseSchema, BrandsResponseSchema } from "../common/brands";
|
||||
export const brandKeys = { all: ["asset-brands"] as const };
|
||||
export class BrandApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(path = "", values?: BrandInput, token?: string) {
|
||||
const response = await fetch(`/api/asset-brands${path}`, {
|
||||
method: values === undefined ? "GET" : "POST",
|
||||
credentials: "same-origin",
|
||||
headers:
|
||||
values === undefined
|
||||
? {}
|
||||
: { "content-type": "application/json", "x-csrf-token": token ?? "" },
|
||||
...(values === undefined ? {} : { body: JSON.stringify({ values }) }),
|
||||
});
|
||||
const data: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = z.object({ code: z.string() }).safeParse(data);
|
||||
throw new BrandApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export const brandApi = {
|
||||
async list() {
|
||||
return BrandsResponseSchema.parse(await request());
|
||||
},
|
||||
async save(values: BrandInput, token: string, id?: string) {
|
||||
return BrandResponseSchema.parse(await request(id ? `/${id}/update` : "", values, token));
|
||||
},
|
||||
};
|
||||
104
modules/assets/src/web/brand-form.tsx
Normal file
104
modules/assets/src/web/brand-form.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
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 } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
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 { type BrandDTO, type BrandInput, BrandInputSchema } from "../common/brands";
|
||||
|
||||
import { BrandApiError, brandApi, brandKeys } from "./brand-api";
|
||||
|
||||
export function BrandForm({
|
||||
csrfToken,
|
||||
brand,
|
||||
onSaved,
|
||||
quickCreate = false,
|
||||
}: {
|
||||
csrfToken: string;
|
||||
brand?: BrandDTO;
|
||||
onSaved: (brand: BrandDTO) => void;
|
||||
quickCreate?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const client = useQueryClient();
|
||||
const form = useForm<BrandInput>({
|
||||
resolver: zodResolver(BrandInputSchema),
|
||||
defaultValues: brand ? { name: brand.name, active: brand.active } : { name: "", active: true },
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: BrandInput) => brandApi.save(values, csrfToken, brand?.id),
|
||||
onSuccess: async (response) => {
|
||||
// Publicar primero garantiza que el selector pueda mostrar y seleccionar el nuevo valor.
|
||||
client.setQueryData<{ brands: BrandDTO[] }>(brandKeys.all, (previous) => ({
|
||||
brands: [
|
||||
...(previous?.brands ?? []).filter((item) => item.id !== response.brand.id),
|
||||
response.brand,
|
||||
],
|
||||
}));
|
||||
await client.invalidateQueries({ queryKey: brandKeys.all });
|
||||
if (mounted.current) {
|
||||
onSaved(response.brand);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
readOnly={brand?.builtin}
|
||||
required
|
||||
{...form.register("name")}
|
||||
/>
|
||||
</div>
|
||||
{!quickCreate && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
{t("brands.active")}
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
{(mutation.error || Object.keys(form.formState.errors).length > 0) && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(
|
||||
"brands.errors." +
|
||||
(mutation.error instanceof BrandApiError
|
||||
? mutation.error.code
|
||||
: mutation.error
|
||||
? "INTERNAL_ERROR"
|
||||
: "INVALID_REQUEST")
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ContextHelp
|
||||
description={t("brands.help")}
|
||||
examples={t("brands.examples")}
|
||||
examplesLabel={t("examplesLabel")}
|
||||
guidance={t("brands.guidance")}
|
||||
scopeNote={t("globalHelp")}
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
64
modules/assets/src/web/brand-i18n.ts
Normal file
64
modules/assets/src/web/brand-i18n.ts
Normal file
@ -0,0 +1,64 @@
|
||||
export const brandsSpanish = {
|
||||
title: "Marcas",
|
||||
introduction: "Gestiona las marcas disponibles para los activos de todas las compañías.",
|
||||
entityLabel: "marcas",
|
||||
new: "Añadir marca",
|
||||
edit: "Editar marca",
|
||||
active: "Activa",
|
||||
inactive: "Inactiva",
|
||||
name: "Marca",
|
||||
all: "Todas",
|
||||
search: "Buscar por marca...",
|
||||
loading: "Cargando marcas…",
|
||||
loadError: "No se han podido cargar las marcas.",
|
||||
empty: "No hay marcas que coincidan con la búsqueda.",
|
||||
noBrand: "Sin especificar",
|
||||
legacy: "Valor anterior",
|
||||
quickNotice: "La marca se guardará en el catálogo global aunque después canceles el activo.",
|
||||
help: "Una marca identifica al fabricante de un activo. Es común a todas las compañías y no define sus modelos comerciales.",
|
||||
examples:
|
||||
"Ejemplos: LG, HP, Microsoft, Kyocera, Zebra o Clónico para equipos montados con componentes de distintos fabricantes.",
|
||||
guidance:
|
||||
"Puedes añadir marcas y cambiar su disponibilidad. Las marcas de serie conservan su nombre; las personalizadas se pueden renombrar. Desactivar no borra los activos ni su historial.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Indica un nombre de marca válido.",
|
||||
NAME_EXISTS: "Ya existe una marca con ese nombre, incluso si está desactivada.",
|
||||
BUILTIN_PROTECTED: "El nombre de una marca de serie está protegido.",
|
||||
BRAND_NOT_FOUND: "La marca ya no está disponible.",
|
||||
INTERNAL_ERROR: "No se ha podido guardar la marca.",
|
||||
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
|
||||
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
|
||||
},
|
||||
};
|
||||
export const brandsEnglish = {
|
||||
title: "Brands",
|
||||
introduction: "Manage the brands available for assets across all companies.",
|
||||
entityLabel: "brands",
|
||||
new: "Add brand",
|
||||
edit: "Edit brand",
|
||||
active: "Active",
|
||||
inactive: "Inactive",
|
||||
name: "Brand",
|
||||
all: "All",
|
||||
search: "Search by brand...",
|
||||
loading: "Loading brands…",
|
||||
loadError: "Brands could not be loaded.",
|
||||
empty: "No brands match the search.",
|
||||
noBrand: "Unspecified",
|
||||
legacy: "Previous value",
|
||||
quickNotice: "The brand remains in the global catalog if you later cancel the asset.",
|
||||
help: "A brand identifies an asset manufacturer. It is shared across all companies and does not define commercial models.",
|
||||
examples:
|
||||
"Examples: LG, HP, Microsoft, Kyocera, Zebra or Clónico for computers assembled from different manufacturers.",
|
||||
guidance:
|
||||
"Add brands and change their availability. Built-in names are protected; custom brands can be renamed. Deactivation preserves assets and their history.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Enter a valid brand name.",
|
||||
NAME_EXISTS: "A brand already has this name, even if inactive.",
|
||||
BUILTIN_PROTECTED: "The name of a built-in brand is protected.",
|
||||
BRAND_NOT_FOUND: "This brand is no longer available.",
|
||||
INTERNAL_ERROR: "The brand could not be saved.",
|
||||
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
|
||||
UNAUTHENTICATED: "Sign in again.",
|
||||
},
|
||||
};
|
||||
111
modules/assets/src/web/brand-select.tsx
Normal file
111
modules/assets/src/web/brand-select.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrandDTO } from "../common/brands";
|
||||
|
||||
import { brandApi, brandKeys } from "./brand-api";
|
||||
import { BrandForm } from "./brand-form";
|
||||
|
||||
const LEGACY = "legacy";
|
||||
export function BrandSelect({
|
||||
id,
|
||||
label,
|
||||
brandId,
|
||||
legacyName,
|
||||
csrfToken,
|
||||
disabled,
|
||||
required,
|
||||
allowQuickCreate,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
brandId: string | null;
|
||||
legacyName: string;
|
||||
csrfToken: string;
|
||||
disabled: boolean;
|
||||
required: boolean;
|
||||
allowQuickCreate: boolean;
|
||||
onChange: (brand: BrandDTO | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
|
||||
const [open, setOpen] = useState(false);
|
||||
const brands = (query.data?.brands ?? []).filter((brand) => brand.active || brand.id === brandId);
|
||||
// Un valor anterior permanece visible sin asociarlo o sobrescribirlo por una coincidencia de nombre.
|
||||
const legacy = !brandId && legacyName !== "";
|
||||
const options = brands.map((brand) => ({
|
||||
value: brand.id,
|
||||
label: brand.name + (brand.active ? "" : ` (${t("brands.inactive")})`),
|
||||
}));
|
||||
if (legacy) {
|
||||
options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` });
|
||||
}
|
||||
if (brandId && !brands.some((brand) => brand.id === brandId)) {
|
||||
options.unshift({ value: brandId, label: legacyName || t("brands.loading") });
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate ? { label: t("brands.new"), onClick: () => setOpen(true) } : undefined
|
||||
}
|
||||
disabled={disabled || query.isPending || query.isError}
|
||||
id={id}
|
||||
label={label}
|
||||
onValueChange={(value) => {
|
||||
if (value === LEGACY) {
|
||||
return;
|
||||
}
|
||||
const brand = brands.find((item) => item.id === value);
|
||||
if (brand || value === "") {
|
||||
onChange(brand ?? null);
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
placeholder={query.isPending ? t("brands.loading") : t("brands.noBrand")}
|
||||
required={required}
|
||||
value={brandId ?? (legacy ? LEGACY : "")}
|
||||
/>
|
||||
{query.isError && (
|
||||
<div role="alert">
|
||||
<p className="text-sm text-destructive">{t("brands.loadError")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setOpen}
|
||||
open
|
||||
title={t("brands.new")}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t("brands.quickNotice")}</p>
|
||||
<BrandForm
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(brand) => {
|
||||
onChange(brand);
|
||||
setOpen(false);
|
||||
}}
|
||||
quickCreate
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
modules/assets/src/web/brands-page.tsx
Normal file
144
modules/assets/src/web/brands-page.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BrandDTO } from "../common/brands";
|
||||
|
||||
import { brandApi, brandKeys } from "./brand-api";
|
||||
import { BrandForm } from "./brand-form";
|
||||
|
||||
export function BrandCatalogPage({ csrfToken }: { csrfToken: string }) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const id = useId();
|
||||
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
|
||||
const [edit, setEdit] = useState<BrandDTO | undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const items = (query.data?.brands ?? []).filter(
|
||||
(brand) => !status || brand.active === (status === "active")
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
+ {t("brands.new")}
|
||||
</Button>
|
||||
}
|
||||
description={t("brands.introduction")}
|
||||
locale={i18n.language}
|
||||
title={t("brands.title")}
|
||||
/>
|
||||
{query.isError && (
|
||||
<div role="alert">
|
||||
<p>{t("brands.loadError")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
hideable: false,
|
||||
value: (brand) => brand.name,
|
||||
render: (brand) => <span className="font-medium">{brand.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
label: t("status"),
|
||||
value: (brand) => brand.active,
|
||||
render: (brand) => (
|
||||
<EntityStatus active={brand.active}>
|
||||
{t(brand.active ? "brands.active" : "brands.inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
label: t("origin"),
|
||||
value: (brand) => t(brand.builtin ? "builtin" : "custom"),
|
||||
render: (brand) => <>{t(brand.builtin ? "builtin" : "custom")}</>,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (brand) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
<EntityAction
|
||||
onClick={() => {
|
||||
setEdit(brand);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("brands.empty")}
|
||||
entityLabel={t("brands.entityLabel")}
|
||||
filterKey={status}
|
||||
filters={
|
||||
<SelectWithAction
|
||||
id={id}
|
||||
label={t("status")}
|
||||
onValueChange={setStatus}
|
||||
options={[
|
||||
{ value: "active", label: t("brands.active") },
|
||||
{ value: "inactive", label: t("brands.inactive") },
|
||||
]}
|
||||
placeholder={t("brands.all")}
|
||||
value={status}
|
||||
/>
|
||||
}
|
||||
filtersApplied={status !== ""}
|
||||
items={items}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setStatus("")}
|
||||
searchPlaceholder={t("brands.search")}
|
||||
title={t("brands.title")}
|
||||
unavailable={query.isError}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
title={t(edit ? "brands.edit" : "brands.new")}
|
||||
>
|
||||
<BrandForm
|
||||
csrfToken={csrfToken}
|
||||
key={edit?.id ?? "new"}
|
||||
{...(edit ? { brand: edit } : {})}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
modules/assets/src/web/brands.test.tsx
Normal file
147
modules/assets/src/web/brands.test.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
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 { brandKeys } from "./brand-api";
|
||||
import { BrandForm } from "./brand-form";
|
||||
import { BrandSelect } from "./brand-select";
|
||||
import { BrandCatalogPage } from "./brands-page";
|
||||
import { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
|
||||
const brand = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
name: "LG",
|
||||
active: true,
|
||||
builtin: true,
|
||||
};
|
||||
async function render(language: string, node: ReactNode, active = true) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(brandKeys.all, { brands: [{ ...brand, active }] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("Marcas: listado común traducido, filtros y alta modal sin formulario junto a la tabla", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(language, <BrandCatalogPage csrfToken="test" />);
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.ok(html.includes(language === "es" ? "Añadir marca" : "Add brand"));
|
||||
assert.ok(
|
||||
html.includes(language === "es" ? "Mostrando 1-1 de 1 marcas" : "Showing 1-1 of 1 brands")
|
||||
);
|
||||
assert.ok(html.includes("LG"));
|
||||
}
|
||||
});
|
||||
test("Marcas: nombre de serie protegido y ayuda inferior; alta rápida siempre activa", async () => {
|
||||
const html = await render(
|
||||
"es",
|
||||
<BrandForm brand={brand} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
assert.ok(html.includes('readOnly=""'));
|
||||
assert.ok(html.indexOf("Una marca identifica") < html.lastIndexOf("Guardar"));
|
||||
const quick = await render(
|
||||
"en",
|
||||
<BrandForm csrfToken="test" onSaved={() => undefined} quickCreate />
|
||||
);
|
||||
assert.equal(quick.includes('type="checkbox"'), false);
|
||||
assert.ok(quick.includes("Global scope"));
|
||||
});
|
||||
test("Marcas: selector mantiene valores antiguos e inactivos y respeta el límite de alta anidada", async () => {
|
||||
const props = {
|
||||
id: "brand",
|
||||
label: "Marca",
|
||||
brandId: brand.id,
|
||||
legacyName: "LG",
|
||||
csrfToken: "test",
|
||||
disabled: false,
|
||||
required: false,
|
||||
allowQuickCreate: true,
|
||||
onChange: () => undefined,
|
||||
};
|
||||
const inactive = await render("es", <BrandSelect {...props} />, false);
|
||||
assert.ok(inactive.includes("LG (Inactiva)"));
|
||||
assert.ok(inactive.includes('aria-label="Añadir marca"'));
|
||||
const legacy = await render(
|
||||
"es",
|
||||
<BrandSelect {...props} allowQuickCreate={false} brandId={null} legacyName="Antigua" />
|
||||
);
|
||||
assert.ok(legacy.includes("Antigua (Valor anterior)"));
|
||||
assert.equal(legacy.includes('aria-label="Añadir marca"'), false);
|
||||
});
|
||||
test("Marcas: el campo de serie se identifica por referencia de catálogo, no por el nombre", async () => {
|
||||
const items = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none()).items;
|
||||
const template = items.find((item) => item.values.name === "Portátil");
|
||||
assert.ok(template);
|
||||
const field = items.find((item) => item.referenceCatalog === "brands");
|
||||
assert.ok(field);
|
||||
const asset = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId: UniqueID.generateNewID().toPrimitive(),
|
||||
values: {
|
||||
name: "PORT-01",
|
||||
templateId: template.id,
|
||||
siteId: null,
|
||||
personId: null,
|
||||
brandId: brand.id,
|
||||
status: "available" as const,
|
||||
notes: "",
|
||||
characteristics: { [field.id]: "LG" },
|
||||
},
|
||||
specification: {
|
||||
templateId: template.id,
|
||||
templateName: "Portátil",
|
||||
fields: [
|
||||
{
|
||||
fieldId: field.id,
|
||||
name: "Marca",
|
||||
type: "text" as const,
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
const html = await render(
|
||||
"es",
|
||||
<AssetForm
|
||||
asset={asset}
|
||||
companyId={asset.companyId}
|
||||
csrfToken="test"
|
||||
items={items}
|
||||
lookups={{
|
||||
sites: [],
|
||||
people: [],
|
||||
onCreatePerson: () => undefined,
|
||||
onCreateSite: () => undefined,
|
||||
}}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
assert.ok(html.includes('aria-label="Añadir marca"'));
|
||||
assert.ok(html.includes("LG"));
|
||||
});
|
||||
@ -1,8 +1,10 @@
|
||||
import { brandsEnglish, brandsSpanish } from "./brand-i18n";
|
||||
import { inventoryEnglish, inventorySpanish } from "./inventory-i18n";
|
||||
import { relationshipsEnglish, relationshipsSpanish } from "./relationship-i18n";
|
||||
import { supportEnglish, supportSpanish } from "./support-i18n";
|
||||
import { workstationsEnglish, workstationsSpanish } from "./workstation-i18n";
|
||||
export const assetsSpanish = {
|
||||
brands: brandsSpanish,
|
||||
support: supportSpanish,
|
||||
...inventorySpanish,
|
||||
...workstationsSpanish,
|
||||
@ -108,6 +110,7 @@ export const assetsSpanish = {
|
||||
},
|
||||
};
|
||||
export const assetsEnglish = {
|
||||
brands: brandsEnglish,
|
||||
support: supportEnglish,
|
||||
...inventoryEnglish,
|
||||
...workstationsEnglish,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export { AssetForm } from "./asset-form";
|
||||
export { AssetSupport } from "./asset-support";
|
||||
export { BrandCatalogPage } from "./brands-page";
|
||||
export { catalogApi, catalogKeys } from "./catalog-api";
|
||||
export { AssetCatalogPage } from "./catalog-page";
|
||||
export { assetsEnglish, assetsSpanish } from "./i18n";
|
||||
|
||||
@ -38,6 +38,7 @@ export const inventorySpanish = {
|
||||
"El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
|
||||
saveAssetError: "No se pudo guardar el activo.",
|
||||
inventoryErrors: {
|
||||
BRAND_UNAVAILABLE: "La marca no está disponible para nuevas asignaciones.",
|
||||
SELF_RELATIONSHIP: "Un activo no puede relacionarse consigo mismo.",
|
||||
RELATIONSHIP_EXISTS: "Ya existe esa relación entre estos activos.",
|
||||
RELATIONSHIP_NOT_FOUND: "La relación no existe en esta compañía.",
|
||||
@ -101,6 +102,7 @@ export const inventoryEnglish = {
|
||||
"The asset belongs to this company. Site and person are optional and must belong to it.",
|
||||
saveAssetError: "The asset could not be saved.",
|
||||
inventoryErrors: {
|
||||
BRAND_UNAVAILABLE: "This brand is unavailable for new assignments.",
|
||||
SELF_RELATIONSHIP: "An asset cannot link to itself.",
|
||||
RELATIONSHIP_EXISTS: "This relationship already exists between these assets.",
|
||||
RELATIONSHIP_NOT_FOUND: "Relationship not found in this company.",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AssetCatalogPage } from "@metis/assets/client";
|
||||
import { AssetCatalogPage, BrandCatalogPage } from "@metis/assets/client";
|
||||
import { ResourceCatalogPage } from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Building2Icon, LayersIcon } from "@repo/shadcn-ui/lib/icons";
|
||||
@ -39,7 +39,7 @@ export function CompaniesWorkspace({
|
||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||
const [location, setLocation] = useState<WorkspaceLocation>();
|
||||
const [catalog, setCatalog] = useState<
|
||||
"access" | "assets" | "resources" | "intervention-templates"
|
||||
"access" | "assets" | "brands" | "resources" | "intervention-templates"
|
||||
>("access");
|
||||
const companies = query.data?.companies ?? [];
|
||||
const current =
|
||||
@ -169,6 +169,13 @@ export function CompaniesWorkspace({
|
||||
>
|
||||
{t("interventionTemplates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "brands"}
|
||||
onClick={() => setCatalog("brands")}
|
||||
variant="ghost"
|
||||
>
|
||||
{assetTranslation("brands.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "resources"}
|
||||
onClick={() => setCatalog("resources")}
|
||||
@ -203,7 +210,7 @@ function GlobalCatalogContent({
|
||||
catalog,
|
||||
csrfToken,
|
||||
}: {
|
||||
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
|
||||
csrfToken: string;
|
||||
}) {
|
||||
switch (catalog) {
|
||||
@ -213,6 +220,8 @@ function GlobalCatalogContent({
|
||||
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
|
||||
case "assets":
|
||||
return <AssetCatalogPage csrfToken={csrfToken} scope="global" />;
|
||||
case "brands":
|
||||
return <BrandCatalogPage csrfToken={csrfToken} />;
|
||||
case "resources":
|
||||
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
|
||||
}
|
||||
@ -237,7 +246,7 @@ function WorkspaceContent({
|
||||
onSelectCompany,
|
||||
}: {
|
||||
area: WorkspaceLocation["area"];
|
||||
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
|
||||
csrfToken: string;
|
||||
onSelectCompany: (id: string) => void;
|
||||
}) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user