Compare commits
No commits in common. "6f83ceb0b46ad59abc219625e6cd35faccc1dfdc" and "5cd16ee972d5fff0146e2c8fbef333a2eced89bb" have entirely different histories.
6f83ceb0b4
...
5cd16ee972
@ -45,24 +45,6 @@ export async function verifyBrands(
|
||||
}
|
||||
const builtin = initial.find((brand) => brand.name === "LG");
|
||||
assert.ok(builtin);
|
||||
assert.ok(
|
||||
initial.every((brand) => !brand.builtin || brand.name === "Clónico" || brand.logoSvg !== null)
|
||||
);
|
||||
assert.equal(initial.find((brand) => brand.name === "Clónico")?.logoSvg, null);
|
||||
const logoSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M1 1h20v20H1z"/></svg>';
|
||||
await error(
|
||||
path,
|
||||
{
|
||||
values: {
|
||||
name: "SVG inválido",
|
||||
active: true,
|
||||
logoSvg: "<svg><script>alert(1)</script></svg>",
|
||||
},
|
||||
},
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await error(
|
||||
`${path}/${builtin.id}/update`,
|
||||
{ values: { name: "Otro LG", active: true } },
|
||||
@ -88,37 +70,11 @@ export async function verifyBrands(
|
||||
.status,
|
||||
403
|
||||
);
|
||||
await error(`${path}/${builtin.id}/delete`, {}, 409, "BUILTIN_PROTECTED");
|
||||
await error(`${path}/invalid/delete`, {}, 400, "INVALID_REQUEST");
|
||||
await error(`${path}/${randomUUID()}/delete`, {}, 404, "BRAND_NOT_FOUND");
|
||||
for (const active of [true, false]) {
|
||||
const unused = await save({ name: `Eliminar ${randomUUID()}`, active, logoSvg });
|
||||
const url = `${path}/${unused.id}/delete`;
|
||||
await error(url, { unexpected: true }, 400, "INVALID_REQUEST");
|
||||
await error(`${url}?unexpected=true`, {}, 400, "INVALID_REQUEST");
|
||||
assert.equal((await request(url, {}, undefined, "invalid")).status, 403);
|
||||
assert.ok((await list()).some((brand) => brand.id === unused.id));
|
||||
const response = await request(url, {});
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
assert.deepEqual(BrandResponseSchema.parse(await response.json()).brand, unused);
|
||||
await error(url, {}, 404, "BRAND_NOT_FOUND");
|
||||
await migrateBrands(database);
|
||||
await importLegacyBrands(database);
|
||||
assert.equal(
|
||||
(await list()).some((brand) => brand.id === unused.id),
|
||||
false
|
||||
);
|
||||
const recreated = await save({ name: unused.name, active });
|
||||
assert.notEqual(recreated.id, unused.id);
|
||||
assert.equal((await request(`${path}/${recreated.id}/delete`, {})).status, 200);
|
||||
}
|
||||
const name = `Marca prueba ${randomUUID()}`;
|
||||
const created = await save({ name: ` ${name} `, active: true, logoSvg });
|
||||
const created = await save({ name: ` ${name} `, active: true });
|
||||
assert.equal(created.name, name);
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.builtin, false);
|
||||
assert.equal(created.logoSvg, logoSvg);
|
||||
assert.equal((await list()).find((brand) => brand.id === created.id)?.logoSvg, logoSvg);
|
||||
const concurrentName = `Concurrente ${randomUUID()}`;
|
||||
const concurrent = await Promise.all([
|
||||
request(path, { values: { name: concurrentName, active: true } }),
|
||||
@ -161,10 +117,7 @@ export async function verifyBrands(
|
||||
assert.ok(foreignTemplate);
|
||||
const second = await asset(otherId, { ...values, templateId: foreignTemplate.id });
|
||||
assert.equal(second.values.brandId, created.id);
|
||||
await error(`${path}/${created.id}/delete`, {}, 409, "BRAND_IN_USE");
|
||||
assert.ok((await list()).some((brand) => brand.id === created.id));
|
||||
await save({ name, active: false }, created.id);
|
||||
assert.equal((await list()).find((brand) => brand.id === created.id)?.logoSvg, logoSvg);
|
||||
await error(`/assets/${companyId}`, { values }, 409, "BRAND_UNAVAILABLE");
|
||||
const retained = await asset(
|
||||
companyId,
|
||||
@ -221,37 +174,9 @@ export async function verifyBrands(
|
||||
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);
|
||||
const changed = await save({ name: builtin.name, active: false, logoSvg }, builtin.id);
|
||||
assert.equal(changed.logoSvg, logoSvg);
|
||||
await migrateBrands(database);
|
||||
const after = (await list()).find((brand) => brand.id === builtin.id);
|
||||
assert.ok(after);
|
||||
assert.equal(after.active, false);
|
||||
assert.equal(after.logoSvg, logoSvg);
|
||||
await save({ name: builtin.name, active: false, logoSvg: null }, builtin.id);
|
||||
await migrateBrands(database);
|
||||
assert.equal((await list()).find((brand) => brand.id === builtin.id)?.logoSvg, null);
|
||||
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, logoSvg: builtin.logoSvg }, builtin.id);
|
||||
// El historial sigue protegiendo la marca aunque ningún activo la use actualmente.
|
||||
await asset(companyId, { ...renamed.values, brandId: null }, first.id);
|
||||
await asset(otherId, { ...second.values, brandId: null }, second.id);
|
||||
await error(`${path}/${created.id}/delete`, {}, 409, "BRAND_IN_USE");
|
||||
assert.ok((await list()).some((brand) => brand.id === created.id));
|
||||
const racing = await save({ name: `Concurrente eliminación ${randomUUID()}`, active: true });
|
||||
const [association, deletion] = await Promise.all([
|
||||
request(`/assets/${companyId}`, { values: { ...values, brandId: racing.id } }),
|
||||
request(`${path}/${racing.id}/delete`, {}),
|
||||
]);
|
||||
if (deletion.status === 200) {
|
||||
assert.equal(association.status, 409, await association.clone().text());
|
||||
assert.equal(
|
||||
(await list()).some((brand) => brand.id === racing.id),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
assert.equal(association.status, 201, await association.clone().text());
|
||||
assert.equal(deletion.status, 409, await deletion.clone().text());
|
||||
assert.equal(z.object({ code: z.string() }).parse(await deletion.json()).code, "BRAND_IN_USE");
|
||||
}
|
||||
await save({ name: builtin.name, active: true }, builtin.id);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { test } from "node:test";
|
||||
|
||||
@ -212,11 +211,6 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await competitor.session();
|
||||
await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await admin.request("/asset-catalog/global"), 401, "UNAUTHENTICATED");
|
||||
await expectError(
|
||||
await admin.request(`/asset-brands/${randomUUID()}/delete`, {}),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(await admin.request("/resource-catalog/global"), 401, "UNAUTHENTICATED");
|
||||
const first = {
|
||||
name: "Admin test",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
@import "@repo/shadcn-ui/globals.css";
|
||||
|
||||
@source "../../../modules";
|
||||
@source "../../../packages/rdx-ui/src";
|
||||
@source "../../../packages/shadcn-ui/src/components";
|
||||
|
||||
/* El shell conserva su identidad visual sin alterar los tokens semánticos del vendor. */
|
||||
|
||||
@ -115,4 +115,4 @@ InterventionTemplate es un agregado con ámbito inmutable, clave idempotente y r
|
||||
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 y BrandLogo como Value Objects, Maybe para el SVG opcional 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).
|
||||
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).
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
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, origen de serie/personalizado y logotipo SVG opcional. Los nombres se normalizan sin espacios exteriores y son únicos también entre marcas inactivas, sin distinguir mayúsculas o acentos.
|
||||
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.
|
||||
|
||||
@ -13,9 +13,7 @@ Se incluyen 24 marcas habituales: Acer, Apple, ASUS, Brother, Canon, Clónico, D
|
||||
- Añadir marcas globales personalizadas.
|
||||
- Renombrar y activar/desactivar marcas personalizadas.
|
||||
- Activar/desactivar marcas de serie, conservando su nombre protegido.
|
||||
- Añadir, sustituir o quitar el logotipo de cualquier marca, incluidas las de serie.
|
||||
- Eliminar marcas personalizadas sin referencias en activos ni revisiones, previa confirmación. Las marcas de serie no se eliminan; no hay conversión de origen.
|
||||
- Una referencia histórica impide borrar la marca aunque ya no la use ningún activo. En ese caso se puede desactivar, sin modificar activos ni historial.
|
||||
- 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.
|
||||
|
||||
@ -23,25 +21,17 @@ El usuario interno autenticado gestiona el catálogo desde Catálogos globales
|
||||
|
||||
## 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 Logotipo, Nombre, Estado, Origen y Acciones. Altas y edición mediante FormDialog; BrandForm coloca ContextHelp inmediatamente antes de Guardar/Cancelar.
|
||||
|
||||
BrandLogo presenta el SVG como una imagen con dimensiones reservadas y texto alternativo; nunca lo inserta como HTML. BrandLogoField permite seleccionar un archivo, previsualizarlo y quitarlo. El formulario conserva sus cambios hasta guardar, bloquea envíos durante la lectura y no guarda tras una selección inválida. Cancelar no modifica el catálogo. Los mensajes y controles mantienen español e inglés.
|
||||
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 eliminación aparece únicamente en las acciones de marcas personalizadas. DeleteBrandDialog, en `web/brands/delete/ui`, confirma la marca y mantiene los errores dentro del modal. Cancelar no modifica datos; durante la petición se bloquean acciones duplicadas y cierre. Tras el éxito se actualiza e invalida únicamente la consulta de marcas, también compartida por los selectores.
|
||||
|
||||
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 y BrandLogo como Value Objects, Maybe para la ausencia de logotipo 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.
|
||||
|
||||
El SVG se almacena en asset_brands.logoSvg (TEXT nullable) y se guarda atómicamente con nombre y estado. Se admite SVG vectorial estático con viewBox y hasta 32 KiB UTF-8, 1000 elementos y 32 niveles. Se validan XML, namespace, elementos y atributos mediante una lista permitida. Se rechazan scripts, handlers, animaciones, foreignObject, imágenes incrustadas, DTD, entidades personalizadas, hojas de estilo y referencias externas. Se admiten degradados y recortes con referencias locales. El usuario debe aportar el archivo sin fondo; la validación no elimina figuras de fondo de un SVG. Los formatos que exportan metadatos o CSS deben guardarse como SVG simple.
|
||||
|
||||
Se añade saxes 6.0.0 para analizar XML real: Node no tiene parser XML nativo y una expresión regular no valida su estructura ni las entidades. El mismo schema se utiliza en entrada HTTP, formulario, respuesta y reconstrucción persistida. El renderizado usa una URL de datos local y no necesita descargas externas.
|
||||
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.
|
||||
|
||||
@ -51,8 +41,6 @@ AssetValues incorpora brandId opcional como Maybe<UniqueID>. Activos y revisione
|
||||
|
||||
migrateBrands crea tablas y semillas de manera idempotente, sin reactivar marcas desactivadas ni sustituir sus UUID. migrateInventory añade las referencias nullable explícitamente.
|
||||
|
||||
La migración añade logoSvg y brand_catalog_lock.logosImported. Incorpora una sola vez los 23 SVG de fabricantes conocidos donde no haya un logotipo; Clónico queda sin imagen porque no es una marca. No sobrescribe logotipos existentes. El marcador transaccional evita reponer imágenes eliminadas o sustituidas después. Las altas iniciales de marcas de serie reciben su SVG local. Las fuentes se registran en [Fuentes de logotipos de marcas](fuentes-de-logotipos-de-marcas.md).
|
||||
|
||||
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.
|
||||
@ -60,13 +48,10 @@ Un marcador transaccional evita que reinicios posteriores vuelvan a crear nombre
|
||||
## HTTP
|
||||
|
||||
- GET /api/asset-brands: listado global.
|
||||
- POST /api/asset-brands: alta con { values: { name, active, logoSvg } }.
|
||||
- POST /api/asset-brands: alta con { values: { name, active } }.
|
||||
- POST /api/asset-brands/:id/update: actualización del mismo contrato.
|
||||
- POST /api/asset-brands/:id/delete: eliminación de una marca personalizada no referenciada, con cuerpo vacío `{}` y respuesta BrandResponseSchema de la marca eliminada.
|
||||
|
||||
Respuestas BrandsResponseSchema y BrandResponseSchema; parámetros, cuerpo y consultas estrictos. Duplicados, protección de serie y BRAND_IN_USE devuelven 409, entrada inválida 400 y marca ausente 404. La eliminación conserva autenticación y CSRF; no introduce nuevos permisos. Comprobar referencias y borrar se hace en una transacción, conservando las claves foráneas RESTRICT. Primero se bloquea el catálogo global de activos y después el catálogo de marcas, en el mismo orden que las escrituras del inventario, para evitar asociaciones concurrentes y lecturas con instantáneas anteriores al borrado en MariaDB. Repetir la eliminación devuelve 404 sin efectos adicionales; las migraciones no recrean marcas personalizadas eliminadas.
|
||||
|
||||
logoSvg contiene el SVG o null para ausencia/eliminación. Si se omite en una actualización, se conserva el logotipo: cambiar el estado o el nombre no debe borrarlo de forma implícita. En altas, la omisión significa ausencia. Las respuestas incluyen siempre logoSvg, nullable. Los permisos de sesión y CSRF existentes también protegen este campo.
|
||||
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
|
||||
|
||||
@ -74,8 +59,6 @@ Pruebas de dominio y mappers: normalización, límites, UUID v7, origen protegid
|
||||
|
||||
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.
|
||||
|
||||
La eliminación se prueba en marcas personalizadas activas e inactivas, con entradas inválidas, CSRF, protección de serie, referencias actuales e históricas, repetición, reutilización del nombre y asociación concurrente de un activo. No se borran marcas de la base de desarrollo durante las pruebas.
|
||||
|
||||
|
||||
### Resultado de la entrega (7 de octubre de 2026)
|
||||
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
# Componentes de interfaz
|
||||
|
||||
Metis Desk usa shadcn/ui, con Base UI y el estilo base-vega, siguiendo la organización de factuges-erp.
|
||||
`packages/shadcn-ui` contiene únicamente el código del registro oficial y no se adapta ni se
|
||||
extiende desde este proyecto. Se consume mediante `@repo/shadcn-ui`.
|
||||
|
||||
Los componentes genéricos compuestos o reutilizables propios viven en `packages/rdx-ui` y se
|
||||
consumen mediante `@repo/rdx-ui`. Los componentes que conocen contratos, textos, consultas o
|
||||
reglas de Metis viven en el módulo o aplicación que los utiliza; nunca en ninguno de los dos
|
||||
paquetes genéricos.
|
||||
Los componentes viven en packages/shadcn-ui y se consumen mediante el paquete @repo/shadcn-ui.
|
||||
El código se genera desde el registro oficial y queda versionado en este repositorio, sin depender del ERP.
|
||||
|
||||
## Componentes actuales
|
||||
|
||||
@ -18,8 +13,7 @@ paquetes genéricos.
|
||||
- Checkbox: estado activo de usuarios, compañías, personas y departamentos.
|
||||
- Table: listados de usuarios, compañías, personas y departamentos.
|
||||
- Textarea: dirección opcional de las sedes, con varias líneas.
|
||||
- Dialog: primitiva de shadcn/ui, con foco y cierre por teclado.
|
||||
- RDX UI: FormDialog, FormActions, ContextHelp, SelectWithAction, EntityTable y StatisticCard.
|
||||
- Dialog: formularios de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos.
|
||||
|
||||
## Convención de navegación
|
||||
|
||||
@ -36,9 +30,8 @@ El paquete exporta componentes individuales. Importar solo el componente necesar
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
```
|
||||
|
||||
El tema base se define en `packages/shadcn-ui/src/styles/globals.css`; las extensiones propias
|
||||
se declaran fuera del vendor. La aplicación importa ese CSS y declara las fuentes de Tailwind de
|
||||
los módulos y de `packages/rdx-ui`.
|
||||
El tema se define una sola vez en packages/shadcn-ui/src/styles/globals.css.
|
||||
La aplicación importa ese CSS y declara las fuentes de Tailwind de los módulos y del paquete compartido.
|
||||
Usar tokens semánticos como bg-background, text-foreground y border-border en vez de repetir colores por pantalla.
|
||||
|
||||
## Añadir componentes
|
||||
@ -57,8 +50,8 @@ y los componentes equivalentes existentes. Ubicar cada componente en la entidad
|
||||
responsables, o en su ámbito compartido cuando tenga varios consumidores reales. Los
|
||||
componentes específicos de Metis Desk no pertenecen al paquete visual genérico.
|
||||
|
||||
La incorporación o actualización de primitivas en el paquete vendor requiere una instrucción
|
||||
explícita del usuario según `AGENTS.md`. En ese caso, utilizar el procedimiento siguiente.
|
||||
La incorporación de primitivas al paquete vendor requiere una instrucción explícita del
|
||||
usuario según `AGENTS.md`. En ese caso, utilizar el procedimiento siguiente.
|
||||
|
||||
Desde la raíz del proyecto, dentro de WSL:
|
||||
|
||||
@ -92,10 +85,7 @@ La ayuda de los formularios se coloca en su parte inferior, inmediatamente antes
|
||||
|
||||
## SelectWithAction: desplegable con acción integrada
|
||||
|
||||
El componente compartido vive en `@repo/rdx-ui/components/select-with-action`. Compone Select,
|
||||
Button y ButtonGroup de shadcn/ui (Base UI, Vega). Presenta selección y botón «+» como un único
|
||||
grupo visual, manteniendo controles separados para teclado y lectores de pantalla. ButtonGroup
|
||||
utiliza fieldset para expresar semánticamente la agrupación.
|
||||
El componente compartido vive en `@repo/shadcn-ui/components/select-with-action`. Compone Select, Button y ButtonGroup de shadcn/ui (Base UI, Vega). Presenta selección y botón «+» como un único grupo visual, manteniendo controles separados para teclado y lectores de pantalla. ButtonGroup utiliza fieldset para expresar semánticamente la agrupación.
|
||||
|
||||
Su contrato recibe id, etiqueta, opciones, placeholder, valor controlado y callback de selección. La acción es opcional y contiene etiqueta accesible, callback y estado deshabilitado. Admite opciones deshabilitadas, estado inválido, descripción accesible y obligatoriedad. Deshabilitar el selector también deshabilita la acción. El botón usa type=button y no envía el formulario.
|
||||
|
||||
@ -122,7 +112,7 @@ El inventario añade altas rápidas de plantilla, sede y persona en los activos.
|
||||
|
||||
## Listados de entidades
|
||||
|
||||
El estándar se define en [Patrón de listados de entidades](patron-de-listados-de-entidades.md), con inventario de pantallas y excepciones. EntityListHeader, EntityTable, EntityRowActions, EntityAction y EntityStatus viven en @repo/rdx-ui/components/entity-list.
|
||||
El estándar se define en [Patrón de listados de entidades](patron-de-listados-de-entidades.md), con inventario de pantallas y excepciones. EntityListHeader, EntityTable, EntityRowActions, EntityAction y EntityStatus viven en @repo/shadcn-ui/components/entity-list.
|
||||
|
||||
EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsqueda, ordenación, visibilidad y paginación local. Admite búsqueda y filtros controlados por el módulo, conservando su semántica y ámbito. Las columnas de identificación y acciones quedan visibles. Los menús utilizan Base UI y conservan los callbacks de las acciones originales. No realiza peticiones ni conoce DTO de negocio.
|
||||
|
||||
@ -137,10 +127,6 @@ Se declara @tanstack/react-table en el paquete compartido porque es quien consum
|
||||
|
||||
BrandCatalogPage reutiliza EntityListHeader/EntityTable con búsqueda, filtro de estado, columnas, ordenación y paginación. BrandEditorDialog compone FormDialog y BrandForm tanto para la gestión global como para las altas relacionadas; el formulario conserva ContextHelp y FormActions.
|
||||
|
||||
BrandLogo y BrandLogoField muestran y permiten seleccionar SVG estáticos locales, con vista previa, errores de campo y acción de quitar. La columna de logotipo reutiliza esa presentación. Los límites, validación y fuentes se describen en [Catálogo global de marcas](catalogo-global-de-marcas.md).
|
||||
|
||||
DeleteBrandDialog compone FormDialog y DialogFooter para confirmar la eliminación de una marca personalizada. El listado solo coordina selección y cierre; el controller de `brands/delete` gestiona mutación y caché. Conserva cancelación y Escape, bloquea cierre y botones durante la petición y mantiene errores en el modal. Las referencias actuales o históricas se protegen en el servidor.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
@ -170,7 +156,7 @@ EntityRowActions muestra un botón icon-sm con EllipsisIcon, etiqueta accesible
|
||||
|
||||
## Tarjetas de indicadores
|
||||
|
||||
StatisticCard (`@repo/rdx-ui/components/statistic-card`) representa un indicador con etiqueta, valor, icono y tinte discreto. Admite carga/error, evita sustituir datos desconocidos por cero y utiliza un botón con foco visible cuando hay navegación. No depende de DTO ni endpoints. Inicio la reutiliza para sus cinco KPI; la organización de bloques y sus estados permanece local al dashboard.
|
||||
StatisticCard (`@repo/shadcn-ui/components/statistic-card`) representa un indicador con etiqueta, valor, icono y tinte discreto. Admite carga/error, evita sustituir datos desconocidos por cero y utiliza un botón con foco visible cuando hay navegación. No depende de DTO ni endpoints. Inicio la reutiliza para sus cinco KPI; la organización de bloques y sus estados permanece local al dashboard.
|
||||
|
||||
## Shell global
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Dashboard de inicio
|
||||
|
||||
Inicio es la pantalla inicial después del acceso. Mantiene el layout de compañías como espacios, el usuario, el idioma y los formularios existentes. La cabecera no incluye selector de compañía. El dashboard muestra el resumen global o el ámbito indicado en la URL; el resumen de una compañía recibe su identificador desde la ficha. Ese alcance se aplica a todos los bloques y a las altas rápidas.
|
||||
Inicio es la pantalla inicial después del acceso. Mantiene el layout de compañías como espacios, el usuario, el idioma y los formularios existentes. El selector permite consultar todas las compañías o una concreta; su alcance se aplica a todos los bloques y a las altas rápidas.
|
||||
|
||||
## Composición y navegación
|
||||
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Fuentes de logotipos de marcas
|
||||
|
||||
Los 23 SVG se descargaron el 9 de octubre de 2026 y se incorporaron en `modules/assets/src/api/infrastructure/persistence/builtin-brand-logos.json`. Esa fuente única contiene el SVG y su procedencia; las migraciones lo almacenan en MariaDB. No se usan CDN en ejecución ni se instala una biblioteca completa de iconos.
|
||||
|
||||
Se prefieren archivos oficiales disponibles. Para 17 marcas se utiliza la distribución 16.0.0 de [Simple Icons](https://github.com/simple-icons/simple-icons/tree/16.0.0), con el color declarado en sus metadatos. Canon utiliza el SVG publicado como icono de su web. Brother procede de [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Brother_logo.svg). Clónico no tiene fabricante ni logotipo propio.
|
||||
|
||||
| Marca | Archivo SVG | Referencia |
|
||||
| --- | --- | --- |
|
||||
| Acer | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/acer.svg) | [Fuente](https://www.acer.com) |
|
||||
| Apple | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/apple.svg) | [Fuente](https://www.apple.com) |
|
||||
| ASUS | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/asus.svg) | [Fuente](https://www.asus.com) |
|
||||
| Brother | [SVG](https://upload.wikimedia.org/wikipedia/commons/8/87/Brother_logo.svg) | [Fuente](https://commons.wikimedia.org/wiki/File:Brother_logo.svg) |
|
||||
| Canon | [SVG](https://www.canon.co.uk/safari-pinned-tab.svg) | [Fuente](https://www.canon.co.uk/safari-pinned-tab.svg) |
|
||||
| Dell | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/dell.svg) | [Fuente](https://www.dell.com) |
|
||||
| Epson | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/epson.svg) | [Fuente](https://global.epson.com/IR/library/) |
|
||||
| HP | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/hp.svg) | [Fuente](https://brandcentral.hp.com/us/en/elements/hp-logo.html) |
|
||||
| Huawei | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/huawei.svg) | [Fuente](https://e.huawei.com/ph/material/partner/0a72728b864949c48b22106454352483) |
|
||||
| Intel | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/intel.svg) | [Fuente](https://www.intel.com/content/www/us/en/newsroom/resources/press-kits-intel-overview.html) |
|
||||
| Kyocera | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/kyocera.svg) | [Fuente](https://uk.kyocera.com) |
|
||||
| Lenovo | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/lenovo.svg) | [Fuente](https://news.lenovo.com/press-kits/) |
|
||||
| LG | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/lg.svg) | [Fuente](https://www.lg.com/global/our-brand/brand-expression/elements/logo/index.jsp) |
|
||||
| Logitech | [SVG](https://www.logitech.com/content/dam/logitech/en/nav/brand-logos/logitech.svg) | [Fuente](https://www.logitech.com/content/dam/logitech/en/nav/brand-logos/logitech.svg) |
|
||||
| Microsoft | [SVG](https://learn.microsoft.com/en-us/entra/identity-platform/media/howto-add-branding-in-apps/ms-symbollockup_mssymbol_19.svg) | [Fuente](https://learn.microsoft.com/en-us/entra/identity-platform/media/howto-add-branding-in-apps/ms-symbollockup_mssymbol_19.svg) |
|
||||
| MikroTik | [SVG](https://mikrotik.com/logo/library/logo/SVG/MT_Black.svg) | [Fuente](https://mikrotik.com/logo/library/logo/SVG/MT_Black.svg) |
|
||||
| MSI | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/msi.svg) | [Fuente](https://www.msi.com/page/brochure) |
|
||||
| QNAP | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/qnap.svg) | [Fuente](https://marketing.qnap.com/resource/qnap-logo-k100) |
|
||||
| Samsung | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/samsung.svg) | [Fuente](https://www.samsung.com/us/about-us/brand-identity/logo/) |
|
||||
| Synology | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/synology.svg) | [Fuente](https://www.synology.com/en-global/company/branding) |
|
||||
| TP-Link | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/tplink.svg) | [Fuente](https://www.tp-link.com) |
|
||||
| Ubiquiti | [SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/16.0.0/icons/ubiquiti.svg) | [Fuente](https://www.ui.com) |
|
||||
| Zebra | [SVG](https://www.zebra.com/content/dam/zebra_dam/global/graphics/logos/Zebra-Logo-horizontal.svg) | [Fuente](https://www.zebra.com/content/dam/zebra_dam/global/graphics/logos/Zebra-Logo-horizontal.svg) |
|
||||
|
||||
Los SVG no contienen imágenes rasterizadas ni fondo añadido. Se retiraron el DTD y los metadatos técnicos de Canon y Brother; en Brother se convirtió la regla CSS de color a atributos de relleno, conservando los trazados y su color. Los logos se escalan conservando proporciones.
|
||||
|
||||
La licencia de Simple Icons es [CC0](https://github.com/simple-icons/simple-icons/blob/16.0.0/LICENSE.md); el proyecto advierte que los derechos sobre las marcas son independientes. Los logotipos se usan para identificar fabricantes, sin expresar afiliación. Antes de distribuir el producto, revisar las condiciones de los titulares, incluidas las de [Logitech](https://www.logitech.com/en-us/tos/trademark-guidelines), [MikroTik](https://mikrotik.com/logo) y [Microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps). Este inventario no equivale a una autorización de uso de marca.
|
||||
@ -6,16 +6,11 @@ Drawer, FormDialog, portales, elementos dialog, aria-modal y roles dialog/alertd
|
||||
La búsqueda no dependió del nombre de los componentes.
|
||||
|
||||
El código propio contiene **81 componentes que construyen una interacción modal**. Todos
|
||||
usan FormDialog de @repo/rdx-ui y tienen un componente principal por fichero. Un componente
|
||||
usan FormDialog de @repo/shadcn-ui y tienen un componente principal por fichero. Un componente
|
||||
puede representar variantes de alta, edición o copia, y catálogos con distintos tipos; el número
|
||||
no es un recuento de todos los estados posibles de las ventanas. No se encontraron otras
|
||||
implementaciones propias con AlertDialog, Sheet, Drawer ni diálogos HTML/manuales.
|
||||
|
||||
Actualización de 2026-10-09: se añade DeleteBrandDialog para confirmar la eliminación de
|
||||
marcas personalizadas; el inventario pasa a 82 componentes. Se ha comprobado esta operación
|
||||
en navegador con API simulada: Cancelar/Escape, errores, bloqueo durante el envío, éxito,
|
||||
español/inglés y anchura móvil. Esta comprobación no amplía la revisión al resto de modales.
|
||||
|
||||
La implementación vendor de packages/shadcn-ui queda fuera de la refactorización según AGENTS.md.
|
||||
Sus primitivas y el diálogo de configuración de tablas se consultan y consumen, no se duplican
|
||||
ni reorganizan. Los popovers de selectores y menús de acciones no se contabilizan como formularios
|
||||
@ -47,7 +42,6 @@ Las rutas reflejan la [estructura por entidades y operaciones](estructura-web-po
|
||||
- [AssetDetailDialog](../modules/assets/src/web/assets/detail/ui/asset-detail-dialog.tsx)
|
||||
- [AssetEditorDialog](../modules/assets/src/web/assets/shared/ui/asset-editor-dialog.tsx)
|
||||
- [BrandEditorDialog](../modules/assets/src/web/brands/shared/ui/brand-editor-dialog.tsx)
|
||||
- [DeleteBrandDialog](../modules/assets/src/web/brands/delete/ui/delete-brand-dialog.tsx)
|
||||
- [CatalogDefinitionDialog](../modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx)
|
||||
- [CatalogEditorDialog](../modules/assets/src/web/catalog/shared/ui/catalog-editor-dialog.tsx)
|
||||
- [CatalogFieldCreateDialog](../modules/assets/src/web/catalog-fields/create/ui/catalog-field-create-dialog.tsx)
|
||||
|
||||
@ -13,31 +13,29 @@
|
||||
"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/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@metis/core": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-ui": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"react-hook-form": "7.72.1",
|
||||
"saxes": "6.0.0",
|
||||
"sequelize": "6.37.8",
|
||||
"zod": "4.3.6"
|
||||
"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/express": "4.17.21",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "5.98.0",
|
||||
"express": "4.22.1",
|
||||
"i18next": "26.0.4",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-i18next": "17.0.2"
|
||||
"i18next": "26.0.4",
|
||||
"react-i18next": "17.0.2",
|
||||
"@tanstack/react-query": "5.98.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { Name } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { BrandInput, BrandProps } from "../domain/brand.aggregate";
|
||||
import { BrandError, BrandInputSchema } from "../domain/brand.aggregate";
|
||||
import { BrandLogo } from "../domain/value-objects/brand-logo.vo";
|
||||
export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandProps, BrandError> {
|
||||
const parsed = BrandInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
@ -13,14 +12,5 @@ export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandP
|
||||
if (!name.isSuccess) {
|
||||
return Result.fail(new BrandError("INVALID_REQUEST"));
|
||||
}
|
||||
const logo = parsed.data.logoSvg ? BrandLogo.create(parsed.data.logoSvg) : undefined;
|
||||
if (logo && !logo.isSuccess) {
|
||||
return Result.fail(new BrandError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok({
|
||||
name: name.data,
|
||||
active: parsed.data.active,
|
||||
builtin,
|
||||
logo: logo?.isSuccess ? Maybe.some(logo.data) : Maybe.none<BrandLogo>(),
|
||||
});
|
||||
return Result.ok({ name: name.data, active: parsed.data.active, builtin });
|
||||
}
|
||||
|
||||
@ -6,6 +6,5 @@ export function buildBrand(brand: Brand): BrandDTO {
|
||||
name: brand.name.toPrimitive(),
|
||||
active: brand.active,
|
||||
builtin: brand.builtin,
|
||||
logoSvg: brand.logo.getOrUndefined()?.toPrimitive() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,7 +6,4 @@ export interface IBrandRepository {
|
||||
save(
|
||||
action: (brands: readonly Brand[]) => Result<Brand, BrandError>
|
||||
): Promise<Result<Brand, BrandError>>;
|
||||
remove(
|
||||
action: (brands: readonly Brand[]) => Result<Brand, BrandError>
|
||||
): Promise<Result<Brand, BrandError>>;
|
||||
}
|
||||
|
||||
@ -11,22 +11,6 @@ export class BrandUseCases {
|
||||
list() {
|
||||
return this.repository.list();
|
||||
}
|
||||
remove(id: string): Promise<Result<Brand, BrandError>> {
|
||||
const identity = UniqueID.create(id);
|
||||
if (!identity.isSuccess) {
|
||||
return Promise.resolve(Result.fail<BrandError>(new BrandError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.remove((brands) => {
|
||||
const brand = brands.find((item) => item.id.equals(identity.data));
|
||||
if (!brand) {
|
||||
return Result.fail(new BrandError("BRAND_NOT_FOUND"));
|
||||
}
|
||||
if (brand.builtin) {
|
||||
return Result.fail(new BrandError("BUILTIN_PROTECTED"));
|
||||
}
|
||||
return Result.ok(brand);
|
||||
});
|
||||
}
|
||||
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")));
|
||||
@ -53,12 +37,7 @@ export class BrandUseCases {
|
||||
) {
|
||||
return Result.fail(new BrandError("NAME_EXISTS"));
|
||||
}
|
||||
return previous
|
||||
? previous.update({
|
||||
...mapped.data,
|
||||
logo: input.logoSvg === undefined ? previous.logo : mapped.data.logo,
|
||||
})
|
||||
: Brand.create(mapped.data);
|
||||
return previous ? previous.update(mapped.data) : Brand.create(mapped.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,11 @@
|
||||
import type { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { AggregateRoot } from "@repo/rdx-ddd";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { BrandLogo } from "./value-objects/brand-logo.vo";
|
||||
import { BrandLogoSchema } from "./value-objects/brand-logo.vo";
|
||||
|
||||
export const BrandInputSchema = z.strictObject({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
logoSvg: BrandLogoSchema.nullable().optional(),
|
||||
});
|
||||
export type BrandInput = z.infer<typeof BrandInputSchema>;
|
||||
export class BrandError extends Error {
|
||||
@ -18,12 +13,7 @@ export class BrandError extends Error {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export type BrandProps = Readonly<{
|
||||
name: Name;
|
||||
active: boolean;
|
||||
builtin: boolean;
|
||||
logo: Maybe<BrandLogo>;
|
||||
}>;
|
||||
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);
|
||||
@ -43,18 +33,11 @@ export class Brand extends AggregateRoot<BrandProps> {
|
||||
get builtin() {
|
||||
return this.props.builtin;
|
||||
}
|
||||
get logo() {
|
||||
return this.props.logo;
|
||||
}
|
||||
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 &&
|
||||
this.logo.getOrUndefined()?.toPrimitive() === props.logo.getOrUndefined()?.toPrimitive()
|
||||
) {
|
||||
if (this.name.equals(props.name) && this.active === props.active) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(new Brand(props, this.id));
|
||||
|
||||
@ -1,19 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
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 builtinLogos from "../infrastructure/persistence/builtin-brand-logos.json";
|
||||
|
||||
import { BUILTIN_BRANDS, Brand } from "./brand.aggregate";
|
||||
import { BRAND_LOGO_MAX_BYTES, BrandLogo, BrandLogoSchema } from "./value-objects/brand-logo.vo";
|
||||
|
||||
const logoSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#005CAB" d="M1 1h20v20H1z"/></svg>';
|
||||
import { Brand } from "./brand.aggregate";
|
||||
|
||||
function brand(name = "LG", builtin = true) {
|
||||
const props = mapBrandInput({ name, active: true }, builtin);
|
||||
@ -29,20 +22,10 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
|
||||
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,
|
||||
logo: original.logo,
|
||||
});
|
||||
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,
|
||||
logo: original.logo,
|
||||
});
|
||||
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);
|
||||
@ -57,92 +40,6 @@ test("Marcas: nombre normalizado, UUID v7, protección de serie, estado e idempo
|
||||
const custom = brand("Propia", false);
|
||||
assert.ok(custom.update({ ...renamed.data, builtin: false }).isSuccess);
|
||||
});
|
||||
|
||||
test("Marcas: SVG estáticos con transparencia, límites y contenido activo rechazado", () => {
|
||||
assert.ok(BrandLogoSchema.safeParse(logoSvg).success);
|
||||
const prefix = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">';
|
||||
for (const svg of [
|
||||
"",
|
||||
"<svg>",
|
||||
"<html/>",
|
||||
'<svg xmlns="http://example.test" viewBox="0 0 24 24"/>',
|
||||
logoSvg.replace("0 0 24 24", "0 0 0 24"),
|
||||
logoSvg.replace("0 0 24 24", "0 0 NaN 24"),
|
||||
logoSvg.replace("viewBox", "invalidBox"),
|
||||
logoSvg.replace("<path", '<path onload="alert(1)"'),
|
||||
`${prefix}<script>alert(1)</script></svg>`,
|
||||
`${prefix}<foreignObject><div/></foreignObject></svg>`,
|
||||
`${prefix}<image href="https://example.test/image.png"/></svg>`,
|
||||
`${prefix}<animate attributeName="fill"/></svg>`,
|
||||
`${prefix}<style>@import url(https://example.test/style.css);</style></svg>`,
|
||||
`${prefix}<path style="fill:url(https://example.test/logo.svg)"/></svg>`,
|
||||
`${prefix}<path fill="url(https://example.test/logo.svg)"/></svg>`,
|
||||
`<!DOCTYPE svg [<!ENTITY logo SYSTEM "file:///etc/passwd">]>${prefix}<title>&logo;</title></svg>`,
|
||||
`<?xml-stylesheet href="https://example.test/style.css"?>${logoSvg}`,
|
||||
`${prefix}${"<g>".repeat(32)}${"</g>".repeat(32)}</svg>`,
|
||||
`${prefix}${"<path/>".repeat(1000)}</svg>`,
|
||||
`${prefix}<title>${"x".repeat(BRAND_LOGO_MAX_BYTES)}</title></svg>`,
|
||||
`${prefix}<title>${"ñ".repeat(BRAND_LOGO_MAX_BYTES / 2)}</title></svg>`,
|
||||
]) {
|
||||
assert.equal(BrandLogoSchema.safeParse(svg).success, false);
|
||||
assert.equal(BrandLogo.create(svg).isSuccess, false);
|
||||
}
|
||||
const padding = BRAND_LOGO_MAX_BYTES - new TextEncoder().encode(logoSvg).length - 7;
|
||||
assert.ok(
|
||||
BrandLogoSchema.safeParse(logoSvg.replace("</svg>", `<!--${"x".repeat(padding)}--></svg>`))
|
||||
.success
|
||||
);
|
||||
assert.ok(
|
||||
BrandLogoSchema.safeParse(
|
||||
`${prefix}<defs><linearGradient id="a"><stop offset="0" stop-color="#005CAB"/></linearGradient></defs><path fill="url(#a)" d="M1 1h2v2z"/></svg>`
|
||||
).success
|
||||
);
|
||||
});
|
||||
|
||||
test("Marcas: los 23 fabricantes tienen SVG local validado; Clónico no representa una marca", () => {
|
||||
assert.equal(builtinLogos.length, BUILTIN_BRANDS.length - 1);
|
||||
assert.equal(new Set(builtinLogos.map((item) => item.name)).size, builtinLogos.length);
|
||||
for (const name of BUILTIN_BRANDS.filter((name) => name !== "Clónico")) {
|
||||
const logo = builtinLogos.find((item) => item.name === name);
|
||||
assert.ok(logo, name);
|
||||
assert.ok(BrandLogoSchema.safeParse(logo.svg).success, name);
|
||||
assert.ok(logo.source.startsWith("https://"));
|
||||
}
|
||||
});
|
||||
|
||||
test("Marcas: añadir, sustituir y quitar SVG conserva identidad, origen y actualización idempotente", () => {
|
||||
const original = brand();
|
||||
const withLogo = mapBrandInput({ name: "LG", active: true, logoSvg }, true);
|
||||
assert.ok(withLogo.isSuccess);
|
||||
const updated = original.update(withLogo.data);
|
||||
assert.ok(updated.isSuccess);
|
||||
assert.ok(updated.data.id.equals(original.id));
|
||||
assert.equal(updated.data.logo.getOrUndefined()?.toPrimitive(), logoSvg);
|
||||
const same = updated.data.update(withLogo.data);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, updated.data);
|
||||
const mapper = new BrandPersistenceMapper();
|
||||
assert.equal(
|
||||
mapper.toDomain(mapper.toPersistence(updated.data)).logo.getOrUndefined()?.toPrimitive(),
|
||||
logoSvg
|
||||
);
|
||||
assert.throws(() => mapper.toDomain({ ...mapper.toPersistence(updated.data), logoSvg: "<svg>" }));
|
||||
const replacement = mapBrandInput(
|
||||
{ name: "LG", active: true, logoSvg: logoSvg.replace("#005CAB", "#000000") },
|
||||
true
|
||||
);
|
||||
assert.ok(replacement.isSuccess);
|
||||
const replaced = updated.data.update(replacement.data);
|
||||
assert.ok(replaced.isSuccess);
|
||||
assert.equal(
|
||||
replaced.data.logo.getOrUndefined()?.toPrimitive(),
|
||||
logoSvg.replace("#005CAB", "#000000")
|
||||
);
|
||||
const removed = replaced.data.update({ ...withLogo.data, logo: original.logo });
|
||||
assert.ok(removed.isSuccess);
|
||||
assert.equal(removed.data.logo.getOrUndefined(), undefined);
|
||||
assert.ok(removed.data.id.equals(original.id));
|
||||
});
|
||||
test("Marcas: mapper bidireccional conserva identidad y rechaza persistencia inválida", () => {
|
||||
const mapper = new BrandPersistenceMapper();
|
||||
const original = brand();
|
||||
@ -154,17 +51,10 @@ test("Marcas: mapper bidireccional conserva identidad y rechaza persistencia inv
|
||||
assert.throws(() => mapper.toDomain({ ...row, id: "invalid" }));
|
||||
assert.throws(() => mapper.toDomain({ ...row, name: " " }));
|
||||
});
|
||||
test("Marcas: unicidad, edición y eliminación de personalizadas, con protección de serie", async () => {
|
||||
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,
|
||||
remove: async (action) => {
|
||||
const result = action(brands);
|
||||
if (result.isSuccess) {
|
||||
brands = brands.filter((item) => !item.id.equals(result.data.id));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
save: async (action) => {
|
||||
const result = action(brands);
|
||||
if (result.isSuccess) {
|
||||
@ -191,29 +81,4 @@ test("Marcas: unicidad, edición y eliminación de personalizadas, con protecci
|
||||
assert.equal(renamed.data.id.toPrimitive(), existing.id.toPrimitive());
|
||||
const invalid = await useCases.save("invalid", { name: "Nueva", active: true });
|
||||
assert.equal(invalid.isSuccess, false);
|
||||
const builtin = brands.find((item) => item.builtin);
|
||||
assert.ok(builtin);
|
||||
for (const [id, code] of [
|
||||
["invalid", "INVALID_REQUEST"],
|
||||
[UniqueID.generateNewID().toPrimitive(), "BRAND_NOT_FOUND"],
|
||||
[builtin.id.toPrimitive(), "BUILTIN_PROTECTED"],
|
||||
]) {
|
||||
assert.ok(id);
|
||||
const result = await useCases.remove(id);
|
||||
assert.equal(result.isSuccess, false);
|
||||
if (!result.isSuccess) {
|
||||
assert.equal(result.error.code, code);
|
||||
}
|
||||
assert.equal(brands.length, 2);
|
||||
}
|
||||
const removed = await useCases.remove(existing.id.toPrimitive().toUpperCase());
|
||||
assert.ok(removed.isSuccess);
|
||||
assert.ok(removed.data.id.equals(existing.id));
|
||||
assert.deepEqual(brands, [builtin]);
|
||||
const repeated = await useCases.remove(existing.id.toPrimitive());
|
||||
assert.equal(repeated.isSuccess, false);
|
||||
if (!repeated.isSuccess) {
|
||||
assert.equal(repeated.error.code, "BRAND_NOT_FOUND");
|
||||
}
|
||||
assert.deepEqual(brands, [builtin]);
|
||||
});
|
||||
|
||||
@ -1,208 +0,0 @@
|
||||
import { ValueObject } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { SaxesAttributeNS, SaxesTagNS } from "saxes";
|
||||
import { SaxesParser } from "saxes";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const BRAND_LOGO_MAX_BYTES = 32 * 1024;
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const ELEMENTS = new Set([
|
||||
"svg",
|
||||
"g",
|
||||
"path",
|
||||
"rect",
|
||||
"circle",
|
||||
"ellipse",
|
||||
"line",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"defs",
|
||||
"linearGradient",
|
||||
"radialGradient",
|
||||
"stop",
|
||||
"clipPath",
|
||||
"title",
|
||||
"desc",
|
||||
]);
|
||||
const ATTRIBUTES = new Set([
|
||||
"id",
|
||||
"version",
|
||||
"viewBox",
|
||||
"width",
|
||||
"height",
|
||||
"preserveAspectRatio",
|
||||
"fill",
|
||||
"fill-rule",
|
||||
"fill-opacity",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
"stroke-miterlimit",
|
||||
"stroke-opacity",
|
||||
"stroke-dasharray",
|
||||
"stroke-dashoffset",
|
||||
"opacity",
|
||||
"transform",
|
||||
"d",
|
||||
"x",
|
||||
"y",
|
||||
"x1",
|
||||
"x2",
|
||||
"y1",
|
||||
"y2",
|
||||
"cx",
|
||||
"cy",
|
||||
"r",
|
||||
"rx",
|
||||
"ry",
|
||||
"points",
|
||||
"clip-path",
|
||||
"clip-rule",
|
||||
"clipPathUnits",
|
||||
"gradientUnits",
|
||||
"gradientTransform",
|
||||
"offset",
|
||||
"stop-color",
|
||||
"stop-opacity",
|
||||
"spreadMethod",
|
||||
"fx",
|
||||
"fy",
|
||||
"fr",
|
||||
"role",
|
||||
"aria-label",
|
||||
]);
|
||||
const STYLE_PROPERTIES = new Set([
|
||||
"fill",
|
||||
"fill-rule",
|
||||
"fill-opacity",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
"stroke-miterlimit",
|
||||
"stroke-opacity",
|
||||
"stroke-dasharray",
|
||||
"stroke-dashoffset",
|
||||
"opacity",
|
||||
"clip-path",
|
||||
"clip-rule",
|
||||
"stop-color",
|
||||
"stop-opacity",
|
||||
]);
|
||||
|
||||
function safeValue(value: string): boolean {
|
||||
// Solo referencias locales de degradados/recortes, nunca URLs, escapes CSS ni código.
|
||||
return (
|
||||
/^[\w\s.,%#()+-]*$/.test(value) &&
|
||||
(!/url/i.test(value) || /^url\(#[a-zA-Z_][\w-]*\)$/.test(value))
|
||||
);
|
||||
}
|
||||
|
||||
function safeStyle(style: string): boolean {
|
||||
return style.split(";").every((declaration) => {
|
||||
if (!declaration.trim()) {
|
||||
return true;
|
||||
}
|
||||
const [property, value, extra] = declaration.split(":");
|
||||
return (
|
||||
property !== undefined &&
|
||||
value !== undefined &&
|
||||
extra === undefined &&
|
||||
STYLE_PROPERTIES.has(property.trim()) &&
|
||||
safeValue(value.trim())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function safeAttribute(attribute: SaxesAttributeNS): boolean {
|
||||
if (attribute.name === "xmlns") {
|
||||
return attribute.value === SVG_NAMESPACE;
|
||||
}
|
||||
if (attribute.name === "style") {
|
||||
return safeStyle(attribute.value);
|
||||
}
|
||||
return !attribute.prefix && ATTRIBUTES.has(attribute.name) && safeValue(attribute.value);
|
||||
}
|
||||
|
||||
function validRoot(tag: SaxesTagNS): boolean {
|
||||
const viewBox = tag.attributes.viewBox?.value
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return (
|
||||
tag.local === "svg" &&
|
||||
viewBox !== undefined &&
|
||||
viewBox.length === 4 &&
|
||||
viewBox.every(Number.isFinite) &&
|
||||
(viewBox[2] ?? 0) > 0 &&
|
||||
(viewBox[3] ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isSafeBrandLogo(input: string): boolean {
|
||||
if (new TextEncoder().encode(input).length > BRAND_LOGO_MAX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
let valid = true;
|
||||
let depth = 0;
|
||||
let elements = 0;
|
||||
let hasRoot = false;
|
||||
const parser = new SaxesParser({ xmlns: true });
|
||||
parser.on("error", () => {
|
||||
valid = false;
|
||||
});
|
||||
parser.on("doctype", () => {
|
||||
valid = false;
|
||||
});
|
||||
parser.on("processinginstruction", () => {
|
||||
valid = false;
|
||||
});
|
||||
parser.on("opentag", (tag) => {
|
||||
depth++;
|
||||
elements++;
|
||||
if (
|
||||
depth > 32 ||
|
||||
elements > 1000 ||
|
||||
tag.uri !== SVG_NAMESPACE ||
|
||||
tag.prefix ||
|
||||
!ELEMENTS.has(tag.local) ||
|
||||
(tag.local === "svg" && depth !== 1)
|
||||
) {
|
||||
valid = false;
|
||||
}
|
||||
if (depth === 1) {
|
||||
hasRoot = validRoot(tag);
|
||||
}
|
||||
if (!Object.values(tag.attributes).every(safeAttribute)) {
|
||||
valid = false;
|
||||
}
|
||||
});
|
||||
parser.on("closetag", () => {
|
||||
depth--;
|
||||
});
|
||||
try {
|
||||
parser.write(input).close();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return valid && hasRoot;
|
||||
}
|
||||
|
||||
export const BrandLogoSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(BRAND_LOGO_MAX_BYTES)
|
||||
.refine(isSafeBrandLogo);
|
||||
|
||||
/** SVG estático validado; se presenta como imagen, nunca como HTML de la página. */
|
||||
export class BrandLogo extends ValueObject<string> {
|
||||
static create(input: unknown): Result<BrandLogo, Error> {
|
||||
const parsed = BrandLogoSchema.safeParse(input);
|
||||
return parsed.success
|
||||
? Result.ok(new BrandLogo(parsed.data))
|
||||
: Result.fail(new Error("Logotipo SVG inválido."));
|
||||
}
|
||||
}
|
||||
@ -28,29 +28,6 @@ export function registerBrandRoutes(
|
||||
.then((brands) => response.json({ brands: brands.map(buildBrand) }))
|
||||
.catch(next);
|
||||
});
|
||||
router.post("/:id/delete", (request, response, next) => {
|
||||
const params = z.strictObject({ id: z.uuid() }).safeParse(request.params);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
Empty.safeParse(request.body).success &&
|
||||
Empty.safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.remove(params.data.id)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({ brand: buildBrand(result.data) });
|
||||
})
|
||||
.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(
|
||||
|
||||
@ -5,21 +5,6 @@ import { z } from "zod/v4";
|
||||
|
||||
import { mapBrandInput } from "../../application/brand-input.mapper";
|
||||
import { BUILTIN_BRANDS } from "../../domain/brand.aggregate";
|
||||
import { BrandLogoSchema } from "../../domain/value-objects/brand-logo.vo";
|
||||
|
||||
import builtinLogos from "./builtin-brand-logos.json";
|
||||
|
||||
const BUILTIN_BRAND_LOGOS = z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string(),
|
||||
svg: BrandLogoSchema,
|
||||
source: z.url(),
|
||||
brandSource: z.url(),
|
||||
})
|
||||
)
|
||||
.parse(builtinLogos);
|
||||
|
||||
export async function migrateBrands(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
@ -32,62 +17,26 @@ export async function migrateBrands(database: Sequelize) {
|
||||
name: { type: DataTypes.STRING(100), allowNull: false, unique: true },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
builtin: { type: DataTypes.BOOLEAN, allowNull: false },
|
||||
logoSvg: { type: DataTypes.TEXT, allowNull: true },
|
||||
});
|
||||
}
|
||||
const brandColumns = await query.describeTable("asset_brands");
|
||||
if (!brandColumns.logoSvg) {
|
||||
await query.addColumn("asset_brands", "logoSvg", { type: DataTypes.TEXT, allowNull: true });
|
||||
}
|
||||
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 },
|
||||
logosImported: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
|
||||
});
|
||||
}
|
||||
const lockColumns = await query.describeTable("brand_catalog_lock");
|
||||
if (!lockColumns.logosImported) {
|
||||
await query.addColumn("brand_catalog_lock", "logosImported", {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
await database.query("INSERT IGNORE INTO brand_catalog_lock (id,legacyImported) VALUES (1,0)");
|
||||
await database.transaction(async (transaction) => {
|
||||
const locks = await database.query<{ logosImported: number }>(
|
||||
"SELECT logosImported FROM brand_catalog_lock WHERE id=1 FOR UPDATE",
|
||||
{
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
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,logoSvg) SELECT :id,:name,1,1,:logoSvg WHERE NOT EXISTS (SELECT 1 FROM asset_brands WHERE name=:name)",
|
||||
{
|
||||
replacements: {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
name,
|
||||
logoSvg: BUILTIN_BRAND_LOGOS.find((logo) => logo.name === name)?.svg ?? null,
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
"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 }
|
||||
);
|
||||
}
|
||||
if (!locks[0]?.logosImported) {
|
||||
for (const logo of BUILTIN_BRAND_LOGOS) {
|
||||
await database.query(
|
||||
"UPDATE asset_brands SET logoSvg=:logoSvg WHERE name=:name AND logoSvg IS NULL",
|
||||
{ replacements: { name: logo.name, logoSvg: logo.svg }, transaction }
|
||||
);
|
||||
}
|
||||
// Importación única: quitar o sustituir un logotipo no se deshace al reiniciar.
|
||||
await database.query("UPDATE brand_catalog_lock SET logosImported=1 WHERE id=1", {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Importación única: nunca reescribe activos ni revisiones ni vuelve a crear nombres renombrados.
|
||||
|
||||
@ -1,33 +1,24 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { Brand } from "../../domain/brand.aggregate";
|
||||
import { BrandLogo, BrandLogoSchema } from "../../domain/value-objects/brand-logo.vo";
|
||||
|
||||
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)]),
|
||||
logoSvg: BrandLogoSchema.nullable(),
|
||||
});
|
||||
export class BrandPersistenceMapper {
|
||||
toDomain(input: unknown) {
|
||||
const row = Row.parse(input);
|
||||
const id = UniqueID.create(row.id);
|
||||
const name = Name.create(row.name, 100);
|
||||
const logo = row.logoSvg === null ? undefined : BrandLogo.create(row.logoSvg);
|
||||
if (!(id.isSuccess && name.isSuccess) || (logo && !logo.isSuccess)) {
|
||||
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),
|
||||
logo: logo?.isSuccess ? Maybe.some(logo.data) : Maybe.none<BrandLogo>(),
|
||||
},
|
||||
{ name: name.data, active: Boolean(row.active), builtin: Boolean(row.builtin) },
|
||||
id.data
|
||||
);
|
||||
}
|
||||
@ -37,7 +28,6 @@ export class BrandPersistenceMapper {
|
||||
name: brand.name.toPrimitive(),
|
||||
active: brand.active,
|
||||
builtin: brand.builtin,
|
||||
logoSvg: brand.logo.getOrUndefined()?.toPrimitive() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,7 +3,6 @@ import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import type { IBrandRepository } from "../../application/brand.repository";
|
||||
import { BrandError } from "../../domain/brand.aggregate";
|
||||
|
||||
import { BrandPersistenceMapper } from "./brand-persistence.mapper";
|
||||
export async function readBrands(database: Sequelize, transaction?: Transaction, lock = false) {
|
||||
@ -14,7 +13,7 @@ export async function readBrands(database: Sequelize, transaction?: Transaction,
|
||||
});
|
||||
}
|
||||
const rows = await database.query<Record<string, unknown>>(
|
||||
`SELECT id,name,active,builtin,logoSvg FROM asset_brands ORDER BY name${lock ? " FOR UPDATE" : ""}`,
|
||||
`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));
|
||||
@ -24,34 +23,6 @@ export class SequelizeBrandRepository implements IBrandRepository {
|
||||
list() {
|
||||
return readBrands(this.database);
|
||||
}
|
||||
remove(action: Parameters<IBrandRepository["remove"]>[0]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
// El catálogo se bloquea antes que las marcas, igual que al escribir el inventario.
|
||||
// Evita que una lectura iniciada antes del borrado mezcle instantáneas de MariaDB.
|
||||
await this.database.query(
|
||||
"SELECT id FROM asset_catalogs WHERE catalogKey='global' FOR UPDATE",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const result = action(await readBrands(this.database, transaction, true));
|
||||
if (!result.isSuccess) {
|
||||
return result;
|
||||
}
|
||||
const replacements = { id: result.data.id.toPrimitive() };
|
||||
// El bloqueo compartido con el inventario impide asociar la marca mientras se elimina.
|
||||
const references = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM assets WHERE brandId=:id UNION ALL SELECT id FROM asset_revisions WHERE brandId=:id LIMIT 1",
|
||||
{ replacements, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (references.length > 0) {
|
||||
return Result.fail<BrandError>(new BrandError("BRAND_IN_USE"));
|
||||
}
|
||||
await this.database.query("DELETE FROM asset_brands WHERE id=:id", {
|
||||
replacements,
|
||||
transaction,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
save(action: Parameters<IBrandRepository["save"]>[0]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const brands = await readBrands(this.database, transaction, true);
|
||||
@ -63,8 +34,8 @@ export class SequelizeBrandRepository implements IBrandRepository {
|
||||
if (previous !== result.data) {
|
||||
await this.database.query(
|
||||
previous
|
||||
? "UPDATE asset_brands SET name=:name,active=:active,logoSvg=:logoSvg WHERE id=:id"
|
||||
: "INSERT INTO asset_brands (id,name,active,builtin,logoSvg) VALUES (:id,:name,:active,:builtin,:logoSvg)",
|
||||
? "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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { BrandInputSchema } from "../api/domain/brand.aggregate";
|
||||
import { BrandLogoSchema } from "../api/domain/value-objects/brand-logo.vo";
|
||||
|
||||
export type { BrandInput } from "../api/domain/brand.aggregate";
|
||||
export { BRAND_LOGO_MAX_BYTES, BrandLogoSchema } from "../api/domain/value-objects/brand-logo.vo";
|
||||
|
||||
export { BrandInputSchema };
|
||||
export const BrandSchema = z.strictObject({
|
||||
@ -12,7 +10,6 @@ export const BrandSchema = z.strictObject({
|
||||
name: z.string().min(1).max(100),
|
||||
active: z.boolean(),
|
||||
builtin: z.boolean(),
|
||||
logoSvg: BrandLogoSchema.nullable(),
|
||||
});
|
||||
export const BrandsResponseSchema = z.strictObject({ brands: z.array(BrandSchema) });
|
||||
export const BrandResponseSchema = z.strictObject({ brand: BrandSchema });
|
||||
|
||||
@ -5,22 +5,9 @@
|
||||
"entityLabel": "brands",
|
||||
"new": "Add brand",
|
||||
"edit": "Edit brand",
|
||||
"delete": "Delete brand",
|
||||
"deleting": "Deleting…",
|
||||
"deleteConfirm": "Delete the brand “{{name}}”? This action cannot be undone.",
|
||||
"deleteGuidance": "Only custom brands with no references in assets or their history can be deleted. If the brand is in use, you can deactivate it through Edit.",
|
||||
"deleteError": "The brand could not be deleted.",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"name": "Brand",
|
||||
"logo": {
|
||||
"label": "Logo",
|
||||
"none": "No logo",
|
||||
"help": "Optional: transparent vector SVG, up to 32 KiB. Export as plain SVG with a viewBox, without scripts, animations, embedded images or external references.",
|
||||
"remove": "Remove logo",
|
||||
"invalid": "The file is not a supported static SVG or exceeds 32 KiB.",
|
||||
"readError": "The file could not be read. Select it again."
|
||||
},
|
||||
"all": "All",
|
||||
"search": "Search by brand...",
|
||||
"loading": "Loading brands…",
|
||||
@ -35,10 +22,9 @@
|
||||
"errors": {
|
||||
"INVALID_REQUEST": "Enter a valid brand name.",
|
||||
"NAME_EXISTS": "A brand already has this name, even if inactive.",
|
||||
"BUILTIN_PROTECTED": "Built-in brands cannot be deleted or renamed.",
|
||||
"BRAND_IN_USE": "This brand is referenced by assets or their history and cannot be deleted. You can deactivate it through Edit.",
|
||||
"BUILTIN_PROTECTED": "The name of a built-in brand is protected.",
|
||||
"BRAND_NOT_FOUND": "This brand is no longer available.",
|
||||
"INTERNAL_ERROR": "The brand operation could not be completed.",
|
||||
"INTERNAL_ERROR": "The brand could not be saved.",
|
||||
"FORBIDDEN": "Your session does not allow this operation. Sign in again.",
|
||||
"UNAUTHENTICATED": "Sign in again."
|
||||
}
|
||||
|
||||
@ -5,22 +5,9 @@
|
||||
"entityLabel": "marcas",
|
||||
"new": "Añadir marca",
|
||||
"edit": "Editar marca",
|
||||
"delete": "Eliminar marca",
|
||||
"deleting": "Eliminando…",
|
||||
"deleteConfirm": "¿Quieres eliminar la marca «{{name}}»? Esta acción no se puede deshacer.",
|
||||
"deleteGuidance": "Solo se pueden eliminar marcas personalizadas sin referencias en activos ni en su historial. Si está en uso, puedes desactivarla desde Editar.",
|
||||
"deleteError": "No se ha podido eliminar la marca.",
|
||||
"active": "Activa",
|
||||
"inactive": "Inactiva",
|
||||
"name": "Marca",
|
||||
"logo": {
|
||||
"label": "Logotipo",
|
||||
"none": "Sin logotipo",
|
||||
"help": "Opcional: SVG vectorial sin fondo, hasta 32 KiB. Exporta como SVG simple, con viewBox, sin scripts, animaciones, imágenes incrustadas ni referencias externas.",
|
||||
"remove": "Quitar logotipo",
|
||||
"invalid": "El archivo no es un SVG estático compatible o supera los 32 KiB.",
|
||||
"readError": "No se ha podido leer el archivo. Selecciónalo de nuevo."
|
||||
},
|
||||
"all": "Todas",
|
||||
"search": "Buscar por marca...",
|
||||
"loading": "Cargando marcas…",
|
||||
@ -35,10 +22,9 @@
|
||||
"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": "Las marcas de serie no se pueden eliminar ni renombrar.",
|
||||
"BRAND_IN_USE": "Esta marca está vinculada a activos o a su historial y no se puede eliminar. Puedes desactivarla desde Editar.",
|
||||
"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 completar la operación con la marca.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormActions, FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { RelationshipForm } from "./relationship-form";
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { 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 } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogError } from "../../../catalog";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetCharacteristic, AssetInput } from "../../../../common/inventory";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
export { useDeleteBrandController } from "./use-delete-brand-controller";
|
||||
@ -1,21 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { BrandsResponseSchema } from "../../../../common/brands";
|
||||
import { brandApi, brandKeys } from "../../shared/api";
|
||||
import type { DeleteBrandDialogProps } from "../types";
|
||||
|
||||
export function useDeleteBrandController({ brand, csrfToken }: DeleteBrandDialogProps) {
|
||||
const client = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => brandApi.remove(brand.id, csrfToken),
|
||||
onSuccess: async (response) => {
|
||||
client.setQueryData<z.infer<typeof BrandsResponseSchema>>(brandKeys.all, (previous) =>
|
||||
previous
|
||||
? { brands: previous.brands.filter((item) => item.id !== response.brand.id) }
|
||||
: previous
|
||||
);
|
||||
await client.invalidateQueries({ queryKey: brandKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { DeleteBrandDialog } from "./ui";
|
||||
@ -1,7 +0,0 @@
|
||||
import type { BrandDTO } from "../../../../common/brands";
|
||||
|
||||
export type DeleteBrandDialogProps = {
|
||||
brand: BrandDTO;
|
||||
csrfToken: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export type { DeleteBrandDialogProps } from "./delete-brand-dialog-props";
|
||||
@ -1,54 +0,0 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { DialogFooter } from "@repo/shadcn-ui/components/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BrandApiError } from "../../shared/api";
|
||||
import { useDeleteBrandController } from "../controllers";
|
||||
import type { DeleteBrandDialogProps } from "../types";
|
||||
|
||||
export function DeleteBrandDialog(props: DeleteBrandDialogProps) {
|
||||
const { brand, onClose } = props;
|
||||
const mutation = useDeleteBrandController(props);
|
||||
const { t } = useTranslation("assets");
|
||||
const errorCode =
|
||||
mutation.error instanceof BrandApiError ? mutation.error.code : "INTERNAL_ERROR";
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!(open || mutation.isPending)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("brands.delete")}
|
||||
>
|
||||
<p>{t("brands.deleteConfirm", { name: brand.name })}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("brands.deleteGuidance")}</p>
|
||||
{mutation.isError && (
|
||||
<p role="alert">
|
||||
{t(`brands.errors.${errorCode}`, { defaultValue: t("brands.deleteError") })}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button disabled={mutation.isPending} onClick={onClose} type="button" variant="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={mutation.isPending || brand.builtin}
|
||||
onClick={() => {
|
||||
if (!mutation.isPending) {
|
||||
mutation.mutate(undefined, { onSuccess: onClose });
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
{t(mutation.isPending ? "brands.deleting" : "brands.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { DeleteBrandDialog } from "./delete-brand-dialog";
|
||||
@ -8,10 +8,9 @@ export function useBrandCatalogPageController() {
|
||||
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
|
||||
const [edit, setEdit] = useState<BrandDTO | undefined>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<BrandDTO>();
|
||||
const [status, setStatus] = useState("");
|
||||
const items = (query.data?.brands ?? []).filter(
|
||||
(brand) => !status || brand.active === (status === "active")
|
||||
);
|
||||
return { query, edit, setEdit, open, setOpen, deleting, setDeleting, status, setStatus, items };
|
||||
return { query, edit, setEdit, open, setOpen, status, setStatus, items };
|
||||
}
|
||||
|
||||
@ -1,24 +1,22 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeleteBrandDialog } from "../../delete";
|
||||
import { BrandEditorDialog } from "../../shared";
|
||||
import { BrandLogo } from "../../shared/ui";
|
||||
import { useBrandCatalogPageController } from "../controllers";
|
||||
import type { BrandCatalogPageProps } from "../types";
|
||||
|
||||
export function BrandCatalogPage(props: BrandCatalogPageProps) {
|
||||
const { csrfToken } = props;
|
||||
const { query, edit, setEdit, open, setOpen, deleting, setDeleting, status, setStatus, items } =
|
||||
const { query, edit, setEdit, open, setOpen, status, setStatus, items } =
|
||||
useBrandCatalogPageController();
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const id = useId();
|
||||
@ -54,16 +52,6 @@ export function BrandCatalogPage(props: BrandCatalogPageProps) {
|
||||
)}
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "logo",
|
||||
label: t("brands.logo.label"),
|
||||
render: (brand) =>
|
||||
brand.logoSvg ? (
|
||||
<BrandLogo logoSvg={brand.logoSvg} name={brand.name} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t("brands.logo.none")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
@ -101,11 +89,6 @@ export function BrandCatalogPage(props: BrandCatalogPageProps) {
|
||||
>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
{!brand.builtin && (
|
||||
<EntityAction onClick={() => setDeleting(brand)}>
|
||||
{t("brands.delete")}
|
||||
</EntityAction>
|
||||
)}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
@ -142,13 +125,6 @@ export function BrandCatalogPage(props: BrandCatalogPageProps) {
|
||||
{...(edit ? { brand: edit } : {})}
|
||||
onSaved={() => setOpen(false)}
|
||||
/>
|
||||
{deleting && (
|
||||
<DeleteBrandDialog
|
||||
brand={deleting}
|
||||
csrfToken={csrfToken}
|
||||
onClose={() => setDeleting(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,17 +8,15 @@ export class BrandApiError extends Error {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
path = "",
|
||||
body?: { values: BrandInput } | Record<string, never>,
|
||||
token?: string
|
||||
) {
|
||||
async function request(path = "", values?: BrandInput, token?: string) {
|
||||
const response = await fetch(`/api/asset-brands${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
method: values === undefined ? "GET" : "POST",
|
||||
credentials: "same-origin",
|
||||
headers:
|
||||
body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
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) {
|
||||
@ -32,9 +30,6 @@ export const brandApi = {
|
||||
return BrandsResponseSchema.parse(await request());
|
||||
},
|
||||
async save(values: BrandInput, token: string, id?: string) {
|
||||
return BrandResponseSchema.parse(await request(id ? `/${id}/update` : "", { values }, token));
|
||||
},
|
||||
async remove(id: string, token: string) {
|
||||
return BrandResponseSchema.parse(await request(`/${id}/delete`, {}, token));
|
||||
return BrandResponseSchema.parse(await request(id ? `/${id}/update` : "", values, token));
|
||||
},
|
||||
};
|
||||
|
||||
@ -16,7 +16,7 @@ import { AssetForm } from "../../assets";
|
||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||
import { BrandCatalogPage } from "../list";
|
||||
|
||||
import { BrandApiError, brandApi, brandKeys } from "./api";
|
||||
import { brandKeys } from "./api";
|
||||
import { BrandForm, BrandSelect } from "./ui";
|
||||
|
||||
const brand = {
|
||||
@ -24,8 +24,6 @@ const brand = {
|
||||
name: "LG",
|
||||
active: true,
|
||||
builtin: true,
|
||||
logoSvg:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#a50034" d="M0 0h24v24H0z"/></svg>',
|
||||
};
|
||||
async function render(language: string, node: ReactNode, active = true) {
|
||||
const i18n = createInstance();
|
||||
@ -54,8 +52,6 @@ test("Marcas: listado común traducido, filtros y alta modal sin formulario junt
|
||||
html.includes(language === "es" ? "Mostrando 1-1 de 1 marcas" : "Showing 1-1 of 1 brands")
|
||||
);
|
||||
assert.ok(html.includes("LG"));
|
||||
assert.ok(html.includes('data-slot="brand-logo"'));
|
||||
assert.ok(html.includes("data:image/svg+xml"));
|
||||
}
|
||||
});
|
||||
test("Marcas: nombre de serie protegido y ayuda inferior; alta rápida siempre activa", async () => {
|
||||
@ -64,8 +60,6 @@ test("Marcas: nombre de serie protegido y ayuda inferior; alta rápida siempre a
|
||||
<BrandForm brand={brand} csrfToken="test" onSaved={() => undefined} />
|
||||
);
|
||||
assert.ok(html.includes('readOnly=""'));
|
||||
assert.ok(html.includes('accept="image/svg+xml,.svg"'));
|
||||
assert.ok(html.includes("Quitar logotipo"));
|
||||
assert.ok(html.indexOf("Una marca identifica") < html.lastIndexOf("Guardar"));
|
||||
const quick = await render(
|
||||
"en",
|
||||
@ -74,39 +68,6 @@ test("Marcas: nombre de serie protegido y ayuda inferior; alta rápida siempre a
|
||||
assert.equal(quick.includes('type="checkbox"'), false);
|
||||
assert.ok(quick.includes("Global scope"));
|
||||
});
|
||||
test("Marcas: eliminar envía CSRF y cuerpo vacío, valida la respuesta y propaga conflictos", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let status = 200;
|
||||
let response: unknown = { brand: { ...brand, builtin: false } };
|
||||
globalThis.fetch = async (input, options) => {
|
||||
assert.equal(input, `/api/asset-brands/${brand.id}/delete`);
|
||||
assert.equal(options?.method, "POST");
|
||||
assert.equal(options?.credentials, "same-origin");
|
||||
assert.equal(options?.body, "{}");
|
||||
const headers = new Headers(options?.headers);
|
||||
assert.equal(headers.get("x-csrf-token"), "test");
|
||||
assert.equal(headers.get("content-type"), "application/json");
|
||||
return new Response(JSON.stringify(response), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(await brandApi.remove(brand.id, "test"), response);
|
||||
response = { brand: { id: "invalid" } };
|
||||
await assert.rejects(() => brandApi.remove(brand.id, "test"));
|
||||
for (const code of ["BRAND_IN_USE", "BUILTIN_PROTECTED", "BRAND_NOT_FOUND"]) {
|
||||
status = code === "BRAND_NOT_FOUND" ? 404 : 409;
|
||||
response = { code };
|
||||
await assert.rejects(
|
||||
() => brandApi.remove(brand.id, "test"),
|
||||
(error: unknown) => error instanceof BrandApiError && error.code === code
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
test("Marcas: selector mantiene valores antiguos e inactivos y respeta el límite de alta anidada", async () => {
|
||||
const props = {
|
||||
id: "brand",
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { BrandDTO, BrandInput } from "../../../../common/brands";
|
||||
import { BRAND_LOGO_MAX_BYTES, BrandInputSchema, BrandLogoSchema } from "../../../../common/brands";
|
||||
import { BrandInputSchema } from "../../../../common/brands";
|
||||
import { brandApi, brandKeys } from "../api";
|
||||
import type { BrandFormProps } from "../types";
|
||||
|
||||
@ -17,53 +17,10 @@ export function useBrandFormController({ csrfToken, brand, onSaved }: BrandFormP
|
||||
};
|
||||
}, []);
|
||||
const client = useQueryClient();
|
||||
const [isReadingLogo, setIsReadingLogo] = useState(false);
|
||||
const [logoError, setLogoError] = useState<string>();
|
||||
const form = useForm<BrandInput>({
|
||||
resolver: zodResolver(BrandInputSchema),
|
||||
defaultValues: brand
|
||||
? { name: brand.name, active: brand.active, logoSvg: brand.logoSvg }
|
||||
: { name: "", active: true, logoSvg: null },
|
||||
defaultValues: brand ? { name: brand.name, active: brand.active } : { name: "", active: true },
|
||||
});
|
||||
const logoSvg = useWatch({ control: form.control, name: "logoSvg" }) ?? null;
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
async function readLogo(file: File) {
|
||||
if (file.size > BRAND_LOGO_MAX_BYTES || file.size === 0) {
|
||||
setLogoError("INVALID_LOGO");
|
||||
return;
|
||||
}
|
||||
setIsReadingLogo(true);
|
||||
try {
|
||||
const content = await file.text();
|
||||
if (!mounted.current) {
|
||||
return;
|
||||
}
|
||||
const parsed = BrandLogoSchema.safeParse(content);
|
||||
if (!parsed.success) {
|
||||
setLogoError("INVALID_LOGO");
|
||||
return;
|
||||
}
|
||||
form.setValue("logoSvg", parsed.data, { shouldDirty: true, shouldValidate: true });
|
||||
form.clearErrors("logoSvg");
|
||||
setLogoError(undefined);
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setIsReadingLogo(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
const selectLogo = (file: File) => {
|
||||
readLogo(file).catch(() => {
|
||||
if (mounted.current) {
|
||||
setLogoError("READ_FAILED");
|
||||
}
|
||||
});
|
||||
};
|
||||
const removeLogo = () => {
|
||||
form.setValue("logoSvg", null, { shouldDirty: true, shouldValidate: true });
|
||||
form.clearErrors("logoSvg");
|
||||
setLogoError(undefined);
|
||||
};
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: BrandInput) => brandApi.save(values, csrfToken, brand?.id),
|
||||
onSuccess: async (response) => {
|
||||
@ -82,20 +39,6 @@ export function useBrandFormController({ csrfToken, brand, onSaved }: BrandFormP
|
||||
}
|
||||
},
|
||||
});
|
||||
const submit = form.handleSubmit((values) => {
|
||||
if (!(isReadingLogo || logoError || mutation.isPending)) {
|
||||
mutation.mutate(values);
|
||||
}
|
||||
});
|
||||
return {
|
||||
form,
|
||||
mutation,
|
||||
submit,
|
||||
logoSvg,
|
||||
name,
|
||||
isReadingLogo,
|
||||
logoError,
|
||||
selectLogo,
|
||||
removeLogo,
|
||||
};
|
||||
const submit = form.handleSubmit((values) => mutation.mutate(values));
|
||||
return { form, mutation, submit };
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
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 { useId } from "react";
|
||||
@ -10,26 +10,14 @@ import { BrandApiError } from "../api";
|
||||
import { useBrandFormController } from "../controllers";
|
||||
import type { BrandFormProps } from "../types";
|
||||
|
||||
import { BrandLogoField } from "./brand-logo-field";
|
||||
|
||||
export function BrandForm(props: BrandFormProps) {
|
||||
const { brand, quickCreate = false } = props;
|
||||
const {
|
||||
form,
|
||||
mutation,
|
||||
submit,
|
||||
logoSvg,
|
||||
name,
|
||||
isReadingLogo,
|
||||
logoError,
|
||||
selectLogo,
|
||||
removeLogo,
|
||||
} = useBrandFormController(props);
|
||||
const { form, mutation, submit } = useBrandFormController(props);
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={submit}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending || isReadingLogo}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<Input
|
||||
@ -40,13 +28,6 @@ export function BrandForm(props: BrandFormProps) {
|
||||
{...form.register("name")}
|
||||
/>
|
||||
</div>
|
||||
<BrandLogoField
|
||||
error={logoError ?? form.formState.errors.logoSvg?.message}
|
||||
logoSvg={logoSvg}
|
||||
name={name}
|
||||
onRemove={removeLogo}
|
||||
onSelect={selectLogo}
|
||||
/>
|
||||
{!quickCreate && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" {...form.register("active")} />
|
||||
@ -54,7 +35,7 @@ export function BrandForm(props: BrandFormProps) {
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
{(mutation.error || form.formState.errors.name || form.formState.errors.active) && (
|
||||
{(mutation.error || Object.keys(form.formState.errors).length > 0) && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(
|
||||
"brands.errors." +
|
||||
@ -75,7 +56,7 @@ export function BrandForm(props: BrandFormProps) {
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending || isReadingLogo} type="submit">
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BrandLogo } from "./brand-logo";
|
||||
|
||||
export function BrandLogoField({
|
||||
logoSvg,
|
||||
name,
|
||||
error,
|
||||
onSelect,
|
||||
onRemove,
|
||||
}: {
|
||||
logoSvg: string | null;
|
||||
name: string;
|
||||
error: string | undefined;
|
||||
onSelect: (file: File) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("brands.logo.label")}</Label>
|
||||
<BrandLogo logoSvg={logoSvg} name={name} />
|
||||
<Input
|
||||
accept="image/svg+xml,.svg"
|
||||
aria-describedby={`${id}-help${error ? ` ${id}-error` : ""}`}
|
||||
aria-invalid={Boolean(error)}
|
||||
id={id}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) {
|
||||
onSelect(file);
|
||||
}
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground" id={`${id}-help`}>
|
||||
{t("brands.logo.help")}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" id={`${id}-error`} role="alert">
|
||||
{t(error === "READ_FAILED" ? "brands.logo.readError" : "brands.logo.invalid")}
|
||||
</p>
|
||||
)}
|
||||
{logoSvg && (
|
||||
<Button onClick={onRemove} size="sm" type="button" variant="outline">
|
||||
{t("brands.logo.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function BrandLogo({ logoSvg, name }: { logoSvg: string | null; name: string }) {
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
const src = logoSvg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(logoSvg)}` : null;
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-10 w-20 shrink-0 items-center justify-center"
|
||||
data-slot="brand-logo"
|
||||
>
|
||||
{src && failedSource !== src ? (
|
||||
// biome-ignore lint/performance/noImgElement: SVG local validado, sin descargas externas ni framework de imágenes.
|
||||
<img
|
||||
alt={name}
|
||||
className="size-full object-contain"
|
||||
decoding="async"
|
||||
height={40}
|
||||
loading="lazy"
|
||||
onError={() => setFailedSource(src)}
|
||||
src={src}
|
||||
width={80}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" className="text-sm text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useBrandSelectController } from "../controllers";
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export { BrandEditorDialog } from "./brand-editor-dialog";
|
||||
export { BrandForm } from "./brand-form";
|
||||
export { BrandLogo } from "./brand-logo";
|
||||
export { BrandSelect } from "./brand-select";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogFieldForm } from "../../shared";
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogRelationForm } from "../../shared";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
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 { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EntityRowActions, EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { EntityRowActions, EntityStatus } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CatalogDefinitionDialog } from "../../detail";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EntityAction } from "@repo/rdx-ui/components/entity-list";
|
||||
import { EntityAction } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
export function CatalogHelp({
|
||||
kind,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SupportConnectionDTO, SupportToolDTO } from "../../../../common/support";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SupportHelp({ tool = false }: { tool?: boolean }) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SupportTools } from "./support-tools";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SupportToolEditorDialog } from "../../shared";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
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 { useId } from "react";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationDTO } from "../../../../common";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
"@metis/assets": "workspace:*",
|
||||
"@metis/resources": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-ui": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { PersonForm } from "../../../people";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormActions, FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
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 { useId } from "react";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccessKind } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { HelpKind } from "../../shared/i18n";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccessItemDTO, PersonDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EntityAction, EntityRowActions } from "@repo/rdx-ui/components/entity-list";
|
||||
import { EntityAction, EntityRowActions } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../../../common";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { EntityListHeader, EntityTable } from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AccessHelpDialog } from "../../help";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { EntityStatus } from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccessFieldsDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { SelectAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { SelectAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentType } from "react";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ResourceEditor } from "@metis/resources/client";
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { ApplicationConfigurationDTO } from "../../../../common";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ApplicationHistoryDialog } from "../../history";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
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 { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ResourceEditor } from "@metis/resources/client";
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CertificateConfigurationDTO } from "../../../../common";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
EntityAction,
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
} from "@repo/shadcn-ui/components/entity-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CertificateHistoryDialog } from "../../history";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user