Añadir imágenes a las marcas

This commit is contained in:
David Arranz 2026-10-09 12:36:05 +02:00
parent 5cd16ee972
commit cec61688a6
43 changed files with 1110 additions and 110 deletions

View File

@ -45,6 +45,24 @@ 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 } },
@ -71,10 +89,12 @@ export async function verifyBrands(
403
);
const name = `Marca prueba ${randomUUID()}`;
const created = await save({ name: ` ${name} `, active: true });
const created = await save({ name: ` ${name} `, active: true, logoSvg });
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 } }),
@ -118,6 +138,7 @@ export async function verifyBrands(
const second = await asset(otherId, { ...values, templateId: foreignTemplate.id });
assert.equal(second.values.brandId, 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,
@ -174,9 +195,16 @@ 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.equal(after?.active, false);
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((await list()).length, new Set((await list()).map((brand) => brand.id)).size);
await save({ name: builtin.name, active: true }, builtin.id);
await save({ name: builtin.name, active: true, logoSvg: builtin.logoSvg }, builtin.id);
}

View File

@ -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 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).
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).

View File

@ -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 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.
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.
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,6 +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.
- 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.
@ -21,7 +22,9 @@ 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 Nombre, Estado, Origen y Acciones. Altas y edición mediante FormDialog; BrandForm coloca ContextHelp inmediatamente antes de Guardar/Cancelar.
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.
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.
@ -31,7 +34,11 @@ La biblioteca genérica sigue independiente del dominio; consultas, DTO y reglas
## Dominio, persistencia e historial
Brand es una raíz AggregateRoot, con Name como Value Object y UniqueID como identidad. mapBrandInput valida la entrada y construye sus propiedades. BrandUseCases consulta el catálogo y aplica unicidad; el agregado protege origen/nombre y conserva identidad. Actualizaciones idénticas devuelven la misma instancia.
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.
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.
@ -41,6 +48,8 @@ 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.
@ -48,11 +57,13 @@ 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 } }.
- POST /api/asset-brands: alta con { values: { name, active, logoSvg } }.
- POST /api/asset-brands/:id/update: actualización del mismo contrato.
Respuestas BrandsResponseSchema y BrandResponseSchema; parámetros, cuerpo y consultas estrictos. Duplicados y protección de serie devuelven 409, entrada inválida 400 y marca ausente 404. No se incorporan endpoints de eliminación ni nuevos permisos.
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.
## Verificación
Pruebas de dominio y mappers: normalización, límites, UUID v7, origen protegido, estado, idempotencia y persistencia inválida. Pruebas de interfaz: traducción, estructura común, modal, ayuda inferior, nombre de serie protegido, valores anteriores/inactivos y límite de altas anidadadas.

View File

@ -127,6 +127,8 @@ 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).
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).

View File

@ -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. 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.
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.
## Composición y navegación

View File

@ -0,0 +1,35 @@
# 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.

View File

@ -13,29 +13,30 @@
"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-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"sequelize": "6.37.8",
"zod": "4.3.6",
"@tanstack/react-table": "8.21.3",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3"
"saxes": "6.0.0",
"sequelize": "6.37.8",
"zod": "4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "25.6.0",
"@types/express": "4.17.21",
"@types/node": "25.6.0",
"@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",
"i18next": "26.0.4",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
"react-i18next": "17.0.2"
}
}

View File

@ -1,8 +1,9 @@
import { Name } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, 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) {
@ -12,5 +13,14 @@ export function mapBrandInput(input: BrandInput, builtin = false): Result<BrandP
if (!name.isSuccess) {
return Result.fail(new BrandError("INVALID_REQUEST"));
}
return Result.ok({ name: name.data, active: parsed.data.active, builtin });
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>(),
});
}

View File

@ -6,5 +6,6 @@ export function buildBrand(brand: Brand): BrandDTO {
name: brand.name.toPrimitive(),
active: brand.active,
builtin: brand.builtin,
logoSvg: brand.logo.getOrUndefined()?.toPrimitive() ?? null,
};
}

View File

@ -6,4 +6,7 @@ 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>>;
}

View File

@ -11,6 +11,22 @@ 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")));
@ -37,7 +53,12 @@ export class BrandUseCases {
) {
return Result.fail(new BrandError("NAME_EXISTS"));
}
return previous ? previous.update(mapped.data) : Brand.create(mapped.data);
return previous
? previous.update({
...mapped.data,
logo: input.logoSvg === undefined ? previous.logo : mapped.data.logo,
})
: Brand.create(mapped.data);
});
}
}

View File

@ -1,11 +1,16 @@
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 {
@ -13,7 +18,12 @@ export class BrandError extends Error {
super(code);
}
}
export type BrandProps = Readonly<{ name: Name; active: boolean; builtin: boolean }>;
export type BrandProps = Readonly<{
name: Name;
active: boolean;
builtin: boolean;
logo: Maybe<BrandLogo>;
}>;
export class Brand extends AggregateRoot<BrandProps> {
private constructor(props: BrandProps, id?: UniqueID) {
super(Object.freeze({ ...props }), id);
@ -33,11 +43,18 @@ 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) {
if (
this.name.equals(props.name) &&
this.active === props.active &&
this.logo.getOrUndefined()?.toPrimitive() === props.logo.getOrUndefined()?.toPrimitive()
) {
return Result.ok(this);
}
return Result.ok(new Brand(props, this.id));

View File

@ -5,8 +5,13 @@ 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 { Brand } from "./brand.aggregate";
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>';
function brand(name = "LG", builtin = true) {
const props = mapBrandInput({ name, active: true }, builtin);
@ -22,10 +27,20 @@ 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 });
const same = original.update({
name: original.name,
active: true,
builtin: true,
logo: original.logo,
});
assert.ok(same.isSuccess);
assert.equal(same.data, original);
const inactive = original.update({ name: original.name, active: false, builtin: true });
const inactive = original.update({
name: original.name,
active: false,
builtin: true,
logo: original.logo,
});
assert.ok(inactive.isSuccess);
assert.equal(inactive.data.id.toPrimitive(), original.id.toPrimitive());
assert.equal(inactive.data.active, false);
@ -40,6 +55,92 @@ 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(&#104;ttps://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();

View File

@ -0,0 +1,208 @@
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."));
}
}

View File

@ -28,6 +28,25 @@ 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(

View File

@ -5,6 +5,21 @@ 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 }>(
@ -17,26 +32,62 @@ 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) => {
await database.query("SELECT id FROM brand_catalog_lock WHERE id=1 FOR UPDATE", {
const locks = await database.query<{ logosImported: number }>(
"SELECT logosImported FROM brand_catalog_lock WHERE id=1 FOR UPDATE",
{
transaction,
type: QueryTypes.SELECT,
});
}
);
for (const name of BUILTIN_BRANDS) {
await database.query(
"INSERT INTO asset_brands (id,name,active,builtin) SELECT :id,:name,1,1 WHERE NOT EXISTS (SELECT 1 FROM asset_brands WHERE name=:name)",
{ replacements: { id: UniqueID.generateNewID().toPrimitive(), name }, transaction }
"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,
}
);
}
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.

View File

@ -1,24 +1,33 @@
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);
if (!(id.isSuccess && name.isSuccess)) {
const logo = row.logoSvg === null ? undefined : BrandLogo.create(row.logoSvg);
if (!(id.isSuccess && name.isSuccess) || (logo && !logo.isSuccess)) {
throw new Error("Marca persistida inconsistente.");
}
return Brand.rehydrate(
{ name: name.data, active: Boolean(row.active), builtin: Boolean(row.builtin) },
{
name: name.data,
active: Boolean(row.active),
builtin: Boolean(row.builtin),
logo: logo?.isSuccess ? Maybe.some(logo.data) : Maybe.none<BrandLogo>(),
},
id.data
);
}
@ -28,6 +37,7 @@ 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

View File

@ -3,6 +3,7 @@ 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) {
@ -13,7 +14,7 @@ export async function readBrands(database: Sequelize, transaction?: Transaction,
});
}
const rows = await database.query<Record<string, unknown>>(
`SELECT id,name,active,builtin FROM asset_brands ORDER BY name${lock ? " FOR UPDATE" : ""}`,
`SELECT id,name,active,builtin,logoSvg FROM asset_brands ORDER BY name${lock ? " FOR UPDATE" : ""}`,
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
);
return rows.map((row) => new BrandPersistenceMapper().toDomain(row));
@ -23,6 +24,28 @@ export class SequelizeBrandRepository implements IBrandRepository {
list() {
return readBrands(this.database);
}
remove(action: Parameters<IBrandRepository["remove"]>[0]) {
return this.database.transaction(async (transaction) => {
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);
@ -34,8 +57,8 @@ export class SequelizeBrandRepository implements IBrandRepository {
if (previous !== result.data) {
await this.database.query(
previous
? "UPDATE asset_brands SET name=:name,active=:active WHERE id=:id"
: "INSERT INTO asset_brands (id,name,active,builtin) VALUES (:id,:name,:active,:builtin)",
? "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)",
{ replacements: new BrandPersistenceMapper().toPersistence(result.data), transaction }
);
}

View File

@ -1,8 +1,10 @@
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({
@ -10,6 +12,7 @@ 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 });

View File

@ -5,9 +5,22 @@
"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…",
@ -22,9 +35,10 @@
"errors": {
"INVALID_REQUEST": "Enter a valid brand name.",
"NAME_EXISTS": "A brand already has this name, even if inactive.",
"BUILTIN_PROTECTED": "The name of a built-in brand is protected.",
"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.",
"BRAND_NOT_FOUND": "This brand is no longer available.",
"INTERNAL_ERROR": "The brand could not be saved.",
"INTERNAL_ERROR": "The brand operation could not be completed.",
"FORBIDDEN": "Your session does not allow this operation. Sign in again.",
"UNAUTHENTICATED": "Sign in again."
}

View File

@ -5,9 +5,22 @@
"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…",
@ -22,9 +35,10 @@
"errors": {
"INVALID_REQUEST": "Indica un nombre de marca válido.",
"NAME_EXISTS": "Ya existe una marca con ese nombre, incluso si está desactivada.",
"BUILTIN_PROTECTED": "El nombre de una marca de serie está protegido.",
"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.",
"BRAND_NOT_FOUND": "La marca ya no está disponible.",
"INTERNAL_ERROR": "No se ha podido guardar la marca.",
"INTERNAL_ERROR": "No se ha podido completar la operación con la marca.",
"FORBIDDEN": "La sesión no permite esta operación. Vuelve a iniciar sesión.",
"UNAUTHENTICATED": "Vuelve a iniciar sesión."
}

View File

@ -0,0 +1 @@
export { useDeleteBrandController } from "./use-delete-brand-controller";

View File

@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { BrandsResponseSchema } from "../../../../common/brands";
import type { z } from "zod/v4";
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 });
},
});
}

View File

@ -0,0 +1 @@
export { DeleteBrandDialog } from "./ui";

View File

@ -0,0 +1,7 @@
import type { BrandDTO } from "../../../../common/brands";
export type DeleteBrandDialogProps = {
brand: BrandDTO;
csrfToken: string;
onClose: () => void;
};

View File

@ -0,0 +1 @@
export type { DeleteBrandDialogProps } from "./delete-brand-dialog-props";

View File

@ -0,0 +1,53 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { DialogFooter } from "@repo/shadcn-ui/components/dialog";
import { FormDialog } from "@repo/shadcn-ui/components/form-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>
);
}

View File

@ -0,0 +1 @@
export { DeleteBrandDialog } from "./delete-brand-dialog";

View File

@ -8,9 +8,10 @@ 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, status, setStatus, items };
return { query, edit, setEdit, open, setOpen, deleting, setDeleting, status, setStatus, items };
}

View File

@ -11,12 +11,14 @@ import { useId } from "react";
import { useTranslation } from "react-i18next";
import { BrandEditorDialog } from "../../shared";
import { DeleteBrandDialog } from "../../delete";
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, status, setStatus, items } =
const { query, edit, setEdit, open, setOpen, deleting, setDeleting, status, setStatus, items } =
useBrandCatalogPageController();
const { t, i18n } = useTranslation("assets");
const id = useId();
@ -52,6 +54,16 @@ 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"),
@ -89,6 +101,11 @@ export function BrandCatalogPage(props: BrandCatalogPageProps) {
>
{t("edit")}
</EntityAction>
{!brand.builtin && (
<EntityAction onClick={() => setDeleting(brand)}>
{t("brands.delete")}
</EntityAction>
)}
</EntityRowActions>
),
},
@ -125,6 +142,13 @@ export function BrandCatalogPage(props: BrandCatalogPageProps) {
{...(edit ? { brand: edit } : {})}
onSaved={() => setOpen(false)}
/>
{deleting && (
<DeleteBrandDialog
brand={deleting}
csrfToken={csrfToken}
onClose={() => setDeleting(undefined)}
/>
)}
</div>
);
}

View File

@ -8,15 +8,15 @@ export class BrandApiError extends Error {
super(code);
}
}
async function request(path = "", values?: BrandInput, token?: string) {
async function request(path = "", body?: { values: BrandInput } | Record<string, never>, token?: string) {
const response = await fetch(`/api/asset-brands${path}`, {
method: values === undefined ? "GET" : "POST",
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
values === undefined
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": token ?? "" },
...(values === undefined ? {} : { body: JSON.stringify({ values }) }),
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
@ -30,6 +30,9 @@ 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));
return BrandResponseSchema.parse(await request(id ? `/${id}/update` : "", { values }, token));
},
async remove(id: string, token: string) {
return BrandResponseSchema.parse(await request(`/${id}/delete`, {}, token));
},
};

View File

@ -24,6 +24,8 @@ 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();
@ -52,6 +54,8 @@ 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 () => {
@ -60,6 +64,8 @@ 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",

View File

@ -1,10 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { useEffect, useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import type { BrandDTO, BrandInput } from "../../../../common/brands";
import { BrandInputSchema } from "../../../../common/brands";
import { BRAND_LOGO_MAX_BYTES, BrandInputSchema, BrandLogoSchema } from "../../../../common/brands";
import { brandApi, brandKeys } from "../api";
import type { BrandFormProps } from "../types";
@ -17,10 +17,53 @@ 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 } : { name: "", active: true },
defaultValues: brand
? { name: brand.name, active: brand.active, logoSvg: brand.logoSvg }
: { name: "", active: true, logoSvg: null },
});
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) => {
@ -39,6 +82,20 @@ export function useBrandFormController({ csrfToken, brand, onSaved }: BrandFormP
}
},
});
const submit = form.handleSubmit((values) => mutation.mutate(values));
return { form, mutation, submit };
const submit = form.handleSubmit((values) => {
if (!(isReadingLogo || logoError || mutation.isPending)) {
mutation.mutate(values);
}
});
return {
form,
mutation,
submit,
logoSvg,
name,
isReadingLogo,
logoError,
selectLogo,
removeLogo,
};
}

View File

@ -10,14 +10,26 @@ 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 } = useBrandFormController(props);
const {
form,
mutation,
submit,
logoSvg,
name,
isReadingLogo,
logoError,
selectLogo,
removeLogo,
} = 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}>
<fieldset className="space-y-4" disabled={mutation.isPending || isReadingLogo}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input
@ -28,6 +40,13 @@ 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")} />
@ -35,7 +54,7 @@ export function BrandForm(props: BrandFormProps) {
</label>
)}
</fieldset>
{(mutation.error || Object.keys(form.formState.errors).length > 0) && (
{(mutation.error || form.formState.errors.name || form.formState.errors.active) && (
<p className="text-sm text-destructive" role="alert">
{t(
"brands.errors." +
@ -56,7 +75,7 @@ export function BrandForm(props: BrandFormProps) {
title={t("helpTitle")}
/>
<FormActions>
<Button disabled={mutation.isPending} type="submit">
<Button disabled={mutation.isPending || isReadingLogo} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>

View File

@ -0,0 +1,57 @@
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>
);
}

View File

@ -0,0 +1,30 @@
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>
);
}

View File

@ -1,3 +1,4 @@
export { BrandEditorDialog } from "./brand-editor-dialog";
export { BrandForm } from "./brand-form";
export { BrandLogo } from "./brand-logo";
export { BrandSelect } from "./brand-select";

View File

@ -8,6 +8,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { AssignmentForm } from "../../assignments";
import { DashboardPage, dashboardKeys } from "../../dashboard";
import { PersonForm } from "../../people";
import { companiesEnglish, companiesSpanish } from "../../shared";
import { SiteForm } from "../../sites";
@ -44,6 +45,35 @@ async function renderPage(
client.clear();
}
}
test("el dashboard no ofrece selector de compañía y conserva las métricas de cada ámbito", async () => {
for (const language of ["es", "en"]) {
for (const scope of [undefined, companyId]) {
const markup = await renderPage(
language,
<DashboardPage
{...(scope ? { companyId: scope } : {})}
csrfToken="test"
onNavigate={() => undefined}
/>,
(client) => {
for (const summaryScope of [undefined, companyId]) {
client.setQueryData(dashboardKeys.block("summary", summaryScope), {
companies: 1,
tickets: summaryScope ? 7 : 31,
interventions: 0,
assets: 0,
resources: 0,
});
}
}
);
assert.equal(markup.includes('role="combobox"'), false);
assert.ok(markup.includes(`>${scope ? 7 : 31}</strong>`));
assert.ok(markup.includes(language === "es" ? "Trabajo pendiente" : "Pending work"));
}
}
});
test("el resumen de compañía es la entrada por defecto y ofrece destinos reales", async () => {
for (const language of ["es", "en"]) {
const markup = await renderPage(

View File

@ -3,42 +3,27 @@ import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { useState } from "react";
import { listCompanies } from "../../../companies/list/api";
import { companyKeys } from "../../../companies/shared/queries";
import type { DashboardQuickCreate } from "../../create";
import type { DashboardDetailsDialog } from "../../detail";
import { dashboardApi, dashboardKeys } from "../../shared/api";
export function useDashboardPageController(fixedCompanyId?: string) {
const { searchParams, setSearchParams } = useBrowserSearchParams();
const companyId = fixedCompanyId ?? searchParams.get("companyId") ?? "";
const setCompanyId = (id: string) =>
setSearchParams((params) => {
if (id) {
params.set("companyId", id);
} else {
params.delete("companyId");
}
});
const scope = companyId || undefined;
const { searchParams } = useBrowserSearchParams();
const scope = (fixedCompanyId ?? searchParams.get("companyId")) || undefined;
const [details, setDetails] =
useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>();
const [quick, setQuick] = useState<ComponentProps<typeof DashboardQuickCreate>["kind"]>();
const companies = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const summary = useQuery({
queryKey: dashboardKeys.block("summary", scope),
queryFn: ({ signal }) => dashboardApi.summary(signal, scope),
staleTime: 0,
});
return {
companyId,
setCompanyId,
scope,
details,
setDetails,
quick,
setQuick,
companies,
summary,
};
}

View File

@ -2,7 +2,6 @@ import { Button } from "@repo/shadcn-ui/components/button";
import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { CompanySelect } from "../../../companies/shared/ui/company-select";
import { DashboardQuickCreate } from "../../create";
import { DashboardDetailsDialog } from "../../detail";
import { DashboardPanel } from "../../shared";
@ -18,17 +17,9 @@ import { DashboardWorkPanel } from "./dashboard-work-panel";
export function DashboardPage(props: DashboardPageProps) {
const { onNavigate, csrfToken } = props;
const {
companyId,
setCompanyId,
scope,
details,
setDetails,
quick,
setQuick,
companies,
summary,
} = useDashboardPageController(props.companyId);
const { scope, details, setDetails, quick, setQuick, summary } = useDashboardPageController(
props.companyId
);
const { t } = useTranslation("companies");
return (
<div className="space-y-6">
@ -41,29 +32,12 @@ export function DashboardPage(props: DashboardPageProps) {
{props.description ?? t("dashboard.description")}
</p>
</div>
<div className="w-full sm:w-64 space-y-2">
{!props.companyId && (
<CompanySelect
id="dashboard-company"
label={t("tickets.company")}
onValueChange={setCompanyId}
options={[
...(companies.data?.companies.map((item) => ({
value: item.id,
label: item.name,
})) ?? []),
]}
placeholder={t("interventions.allCompanies")}
value={companyId}
/>
)}
{summary.isSuccess && (
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span aria-hidden="true" className="size-2 rounded-full bg-primary" />
{t("dashboard.connected")}
</p>
)}
</div>
</header>
<DashboardStatistics
onDetails={(kind) => setDetails({ kind })}

View File

@ -36,7 +36,7 @@ export function FormDialog({
showCloseButton={false}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogTitle className="text-xl">{title}</DialogTitle>
</DialogHeader>
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
{children}

View File

@ -175,6 +175,9 @@ importers:
react-i18next:
specifier: 17.0.2
version: 17.0.2(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.2)
saxes:
specifier: 6.0.0
version: 6.0.0
sequelize:
specifier: 6.37.8
version: 6.37.8(mariadb@3.5.4)
@ -2946,6 +2949,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -3361,6 +3368,9 @@ packages:
resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==}
engines: {node: '>=20'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -5526,6 +5536,10 @@ snapshots:
safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
semver@6.3.1: {}
@ -5963,6 +5977,8 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.2.1
xmlchars@2.2.0: {}
yallist@3.1.1: {}
yocto-spinner@1.2.2: