diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts index 03df5fc..eb765da 100644 --- a/apps/server/src/assets.integration.ts +++ b/apps/server/src/assets.integration.ts @@ -71,10 +71,18 @@ export async function verifyAssets( 403 ); assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200); + // Reproduce una BD existente anterior a la columna de icono. + await database.query("ALTER TABLE asset_definitions DROP COLUMN icon"); + await createAssetModule(database, (_request, _response, next) => next()); await createAssetModule(database, (_request, _response, next) => next()); const restarted = await read(global); assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false); + assert.ok( + restarted.items + .filter((item) => item.values.kind === "template") + .every((item) => item.values.kind === "template" && item.values.icon) + ); assert.deepEqual( restarted.items.map((item) => item.id).sort(), initial.items.map((item) => item.id).sort() @@ -123,6 +131,7 @@ export async function verifyAssets( const templateValues = { kind: "template", name: "Equipo especial", + icon: "laptop", sections: [section], description: "", fields: [ @@ -138,12 +147,32 @@ export async function verifyAssets( assert.ok(saved && saved.values.kind === "template"); assert.deepEqual(saved.values.sections, [section]); + assert.equal(saved.values.icon, "laptop"); const edited = await save(`${company}/${saved.id}/update`, { ...templateValues, name: "Equipo especial actualizado", + icon: "printer", }); assert.equal(edited.savedId, saved.id); + const storedIcon = (await read(company)).items.find((item) => item.id === saved.id); + + assert.ok(storedIcon?.values.kind === "template"); + assert.equal(storedIcon.values.icon, "printer"); + const { icon: removedIcon, ...withoutIcon } = templateValues; + + assert.equal(removedIcon, "laptop"); + await save(`${company}/${saved.id}/update`, withoutIcon); + const clearedIcon = (await read(company)).items.find((item) => item.id === saved.id); + + assert.ok(clearedIcon?.values.kind === "template"); + assert.equal(clearedIcon.values.icon, undefined); + await error( + company, + { values: { ...templateValues, icon: "unknown-icon" } }, + "INVALID_REQUEST", + 400 + ); assert.deepEqual(edited.items.find((item) => item.id === saved.id)?.bindings, saved.bindings); await error( company, @@ -236,6 +265,7 @@ export async function verifyAssets( const groupedGlobal = await save(global, { kind: "template", name: "Plantilla global agrupada assets", + icon: "server", description: "", sections: [globalSection], fields: [{ fieldId: stock.id, required: false, sectionId: globalSection.id }], @@ -246,6 +276,7 @@ export async function verifyAssets( assert.ok(groupedStored?.values.kind === "template"); assert.deepEqual(groupedStored.values.sections, [globalSection]); + assert.equal(groupedStored.values.icon, "server"); await error( global, { diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md index 32c1bde..bc7e26a 100644 --- a/docs/campos-y-plantillas-de-activos.md +++ b/docs/campos-y-plantillas-de-activos.md @@ -48,3 +48,13 @@ El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. E ## Organización de campos Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes. + +## Iconos de plantilla + +Cada plantilla global o propia de compañía admite un icono opcional de la biblioteca Lucide existente. El formulario incluye un selector visual con nombres traducidos, selección marcada y opción «Sin icono». Las copias conservan el icono; las plantillas personalizadas permiten cambiarlo y retirarlo. + +Las doce plantillas de serie incluyen iconos adecuados a cada equipo. El arranque completa los iconos ausentes de instalaciones existentes de forma idempotente, conservando identidades, campos, secciones y disponibilidad. Se mantiene la protección de las definiciones de serie. + +El catálogo, su consulta de detalle, la columna Plantilla del inventario y la cabecera del panel del activo muestran el icono. Los activos toman el icono actual de su plantilla: es metadato de presentación y no modifica características ni historial técnico. + +Se persiste la clave validada del icono en la columna nullable `asset_definitions.icon`, sin JSON ni archivos de imagen. La migración añade la columna en instalaciones existentes. Los campos y las relaciones no admiten iconos en este contrato. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 7f8303b..de6b36c 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -328,3 +328,7 @@ Los listados comparten la preferencia entity-page-size en localStorage: el núme `ConfirmDeleteDialog` de `@repo/rdx-ui` presenta una confirmación modal con descripción de consecuencias, Cancelar y Eliminar. El consumidor aporta los textos traducidos y una operación asíncrona. Mientras se ejecuta, evita envíos repetidos y el cierre; si falla, mantiene el diálogo abierto y muestra un error. Solo se cierra tras completar la operación. Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de los menús de fila para que la confirmación permanezca montada cuando se cierre el menú. La operación invalida la consulta de la entidad y las revisiones de procesos de la compañía. + +## IconPicker + +`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. El catálogo de plantillas de activos aporta los iconos Lucide y valida sus claves; el componente admite teclado y distribución responsive. diff --git a/docs/normalizacion-de-activos.md b/docs/normalizacion-de-activos.md index dac9a77..4e49cd4 100644 --- a/docs/normalizacion-de-activos.md +++ b/docs/normalizacion-de-activos.md @@ -10,7 +10,7 @@ La persistencia de activos no utiliza JSON para definiciones, características n - un campo usa `fieldType` y `unit`; - una relación usa `inverseName` y `symmetric`; -- una plantilla se compone mediante sus tablas relacionadas. +- una plantilla admite una clave de icono opcional en la columna nullable `icon` y se compone mediante sus tablas relacionadas. Las opciones de un campo de lista se guardan en `asset_field_choices`. Las secciones de una plantilla se guardan en `asset_template_sections`. `asset_template_fields` une plantilla y campo, e incluye obligatoriedad, posición y sección opcional. diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts index e8910aa..396e9da 100644 --- a/modules/assets/src/api/domain/asset-catalog-seed.ts +++ b/modules/assets/src/api/domain/asset-catalog-seed.ts @@ -1,5 +1,7 @@ import { UniqueID } from "@repo/rdx-ddd"; +import type { AssetTemplateIconKey } from "../../common/asset-template-icons"; + import type { AssetCatalog } from "./asset-catalog.aggregate"; import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; @@ -74,6 +76,21 @@ const TEMPLATES: readonly [string, string, readonly string[]][] = [ ["smartphone", "Smartphone", ["os", "storage", "screen_size", "imei", "phone", "carrier"]], ["ups", "SAI", ["power", "battery", "connections"]], ]; +const TEMPLATE_ICONS: Readonly> = { + laptop: "laptop", + desktop: "desktop", + monitor: "monitor", + printer: "printer", + router: "router", + firewall: "shield", + switch: "network", + access_point: "wifi", + nas: "hard-drive", + server: "server", + smartphone: "smartphone", + ups: "battery", +}; + const SECTION_FIELDS: readonly [string, readonly string[]][] = [ ["Identificación", ["brand", "model", "serial"]], ["Hardware", ["processor", "memory", "storage", "capacity", "disks", "raid"]], @@ -131,6 +148,7 @@ export function seedAssetCatalog(catalog: AssetCatalog) { add(`template:${key}`, { kind: "template", + icon: TEMPLATE_ICONS[key], name, description: "", fields: references, diff --git a/modules/assets/src/api/domain/asset-catalog.aggregate.ts b/modules/assets/src/api/domain/asset-catalog.aggregate.ts index f951fb0..ed31334 100644 --- a/modules/assets/src/api/domain/asset-catalog.aggregate.ts +++ b/modules/assets/src/api/domain/asset-catalog.aggregate.ts @@ -141,7 +141,7 @@ export class AssetCatalog extends AggregateRoot { const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); if (previous) { - const upgraded = previous.initializeSections(values); + const upgraded = previous.initializeSections(values).initializeIcon(values); if (upgraded === previous) { return Result.ok(this); diff --git a/modules/assets/src/api/domain/asset-catalog.test.ts b/modules/assets/src/api/domain/asset-catalog.test.ts index 71e4a32..310e208 100644 --- a/modules/assets/src/api/domain/asset-catalog.test.ts +++ b/modules/assets/src/api/domain/asset-catalog.test.ts @@ -7,6 +7,8 @@ import { Maybe } from "@repo/rdx-utils"; import { AssetCatalog } from "./asset-catalog.aggregate"; import { seedAssetCatalog } from "./asset-catalog-seed"; import type { AssetDefinitionSpec } from "./asset-definition"; +import { AssetDefinitionSchema } from "./asset-definition"; +import { AssetDefinition } from "./asset-definition.entity"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; const global = Maybe.none(); @@ -160,3 +162,111 @@ test("los campos validan listas, igualdad semántica, duplicados y tipo fijo", ( ).isSuccess ); }); + +test("iconos opcionales: cambio y retirada conservan identidad y asociaciones", () => { + let catalog = seedAssetCatalog(AssetCatalog.create()); + const fieldId = catalog.definitions[0]?.id.toPrimitive(); + + assert.ok(fieldId); + const base = { + kind: "template", + name: "Equipo con icono", + description: "", + fields: [{ fieldId, required: false }], + } satisfies AssetDefinitionSpec; + const company = Maybe.some(UniqueID.generateNewID()); + + for (const scope of [global, company]) { + const input = { + ...base, + name: scope === global ? "Plantilla global con icono" : "Plantilla local con icono", + }; + + catalog = changed(catalog.save(scope, Maybe.none(), values(input))); + const original = catalog.definitions.at(-1); + + assert.ok(original); + catalog = changed( + catalog.save(scope, Maybe.some(original.id), values({ ...input, icon: "laptop" })) + ); + const updated = catalog.definitions.find((item) => item.id.equals(original.id)); + + assert.ok(updated?.values.data.kind === "template"); + assert.equal(updated.values.data.icon, "laptop"); + assert.ok(updated.bindings[0]?.id.equals(original.bindings[0]?.id)); + assert.equal(changed(catalog.save(scope, Maybe.some(original.id), updated.values)), catalog); + catalog = changed(catalog.save(scope, Maybe.some(original.id), values(input))); + const cleared = catalog.definitions.find((item) => item.id.equals(original.id)); + + assert.ok(cleared?.values.data.kind === "template"); + assert.equal(cleared.values.data.icon, undefined); + } +}); + +test("el arranque completa iconos de serie sin cambiar UUID, campos o disponibilidad", () => { + const initial = seedAssetCatalog(AssetCatalog.create()); + const template = initial.definitions.find( + (item) => item.stockKey.getOrUndefined() === "template:laptop" + ); + + assert.ok(template?.values.data.kind === "template"); + assert.equal(template.values.data.icon, "laptop"); + const { icon, ...withoutIcon } = template.values.data; + + assert.ok(icon); + const previous = AssetDefinition.rehydrate( + { + companyId: template.companyId, + stockKey: template.stockKey, + values: values(withoutIcon), + active: false, + bindings: template.bindings, + }, + template.id + ); + const upgraded = seedAssetCatalog( + AssetCatalog.rehydrate( + { + definitions: initial.definitions.map((item) => + item.id.equals(template.id) ? previous : item + ), + availability: initial.availability, + }, + initial.id + ) + ); + const saved = upgraded.definitions.find((item) => item.id.equals(template.id)); + + assert.ok(saved?.values.data.kind === "template"); + assert.equal(saved.values.data.icon, "laptop"); + assert.equal(saved.active, false); + assert.deepEqual(saved.values.data.fields, template.values.data.fields); + assert.deepEqual(saved.bindings, template.bindings); + assert.equal(seedAssetCatalog(upgraded), upgraded); +}); + +test("los iconos desconocidos o asociados a campos se rechazan", () => { + const template = { + kind: "template", + name: "Equipo", + description: "", + fields: [{ fieldId: UniqueID.generateNewID().toPrimitive(), required: false }], + }; + + assert.equal( + AssetDefinitionSchema.safeParse({ ...template, icon: "unknown-icon" }).success, + false + ); + assert.equal( + AssetDefinitionSchema.safeParse({ + kind: "field", + name: "Campo", + description: "", + type: "text", + unit: "", + choices: [], + icon: "laptop", + }).success, + false + ); +}); diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts index 8bf4414..2f10fa4 100644 --- a/modules/assets/src/api/domain/asset-definition.entity.ts +++ b/modules/assets/src/api/domain/asset-definition.entity.ts @@ -154,6 +154,29 @@ export class AssetDefinition extends DomainEntity { return new AssetDefinition({ ...this.props, values: values.data }, this.id); } + /** Completa el icono de serie sin sobrescribir configuración existente. */ + initializeIcon(source: AssetDefinitionValues) { + const previous = this.values.data; + const next = source.data; + + if ( + !this.stockKey.getOrUndefined() || + previous.kind !== "template" || + next.kind !== "template" || + previous.icon !== undefined || + !next.icon + ) { + return this; + } + const values = AssetDefinitionValues.create({ ...previous, icon: next.icon }); + + if (!values.isSuccess) { + throw new Error("Icono inicial de plantilla inconsistente."); + } + + return new AssetDefinition({ ...this.props, values: values.data }, this.id); + } + setActive(active: boolean) { return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); } diff --git a/modules/assets/src/api/domain/asset-definition.ts b/modules/assets/src/api/domain/asset-definition.ts index 2c55777..e6b920f 100644 --- a/modules/assets/src/api/domain/asset-definition.ts +++ b/modules/assets/src/api/domain/asset-definition.ts @@ -1,5 +1,7 @@ import { z } from "zod/v4"; +import { AssetTemplateIconSchema } from "../../common/asset-template-icons"; + import { validTemplateSections } from "./template-sections"; const title = z.string().min(1).max(100); @@ -30,6 +32,7 @@ export const AssetDefinitionSchema = z.discriminatedUnion("kind", [ z .strictObject({ kind: z.literal("template"), + icon: AssetTemplateIconSchema.optional(), name: title, description: z.string().max(1000), fields: z.array(TemplateFieldSchema).min(1).max(100), diff --git a/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts b/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts index 2addf45..2c41855 100644 --- a/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts +++ b/modules/assets/src/api/domain/value-objects/asset-definition-values.vo.ts @@ -128,6 +128,7 @@ export class AssetDefinitionValues { return ( a.kind === "template" && b.kind === "template" && + a.icon === b.icon && sameTemplateSections(a.sections ?? [], b.sections ?? []) && a.fields.length === b.fields.length && a.fields.every( diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts index ba5925a..193fbef 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog-relational.ts @@ -19,6 +19,7 @@ type RelationalDefinition = Omit & { kind: string; name: string; description: string; + icon: string | null; fieldType: string | null; unit: string | null; inverseName: string | null; @@ -101,6 +102,7 @@ export async function readDefinitions( break; case "template": values = { + ...(row.icon ? { icon: row.icon } : {}), ...common, kind: row.kind, fields: bindings @@ -155,6 +157,7 @@ export async function writeDefinition( kind: values.kind, name: values.name, description: values.description, + icon: values.kind === "template" ? (values.icon ?? null) : null, fieldType: values.kind === "field" ? values.type : null, unit: values.kind === "field" ? values.unit : null, inverseName: values.kind === "relation" ? values.inverseName : null, diff --git a/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts index ef78a04..27a5527 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-catalog.model.ts @@ -85,6 +85,11 @@ export async function migrateAssetCatalog(database: Sequelize) { await create("asset_catalogs", catalog); await create("asset_definitions", definition); + const columns = await query.describeTable("asset_definitions"); + + if (!columns.icon) { + await query.addColumn("asset_definitions", "icon", definitionColumns.icon); + } await create("asset_template_fields", binding); await create("asset_availability", availability); for (const [table, fields, name] of [ diff --git a/modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts b/modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts index e6b4357..0edfe36 100644 --- a/modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts +++ b/modules/assets/src/api/infrastructure/persistence/asset-relational-schema.ts @@ -15,6 +15,7 @@ export const definitionColumns = { kind: { type: DataTypes.STRING(12), allowNull: false }, name: title, description: { type: DataTypes.TEXT, allowNull: false }, + icon: { type: DataTypes.STRING(30), allowNull: true }, fieldType: { type: DataTypes.STRING(12), allowNull: true }, unit: { type: DataTypes.STRING(30), allowNull: true }, inverseName: { ...title, allowNull: true }, diff --git a/modules/assets/src/common/asset-template-icons.ts b/modules/assets/src/common/asset-template-icons.ts new file mode 100644 index 0000000..c6a50c3 --- /dev/null +++ b/modules/assets/src/common/asset-template-icons.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +export const AssetTemplateIconSchema = z.enum([ + "laptop", + "desktop", + "monitor", + "printer", + "router", + "shield", + "network", + "wifi", + "hard-drive", + "server", + "smartphone", + "battery", + "tablet", + "keyboard", + "mouse", + "headphones", + "camera", + "box", +]); + +export type AssetTemplateIconKey = z.infer; diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 0e4cc7f..952d913 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -301,5 +301,30 @@ "description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.", "pending": "Deleting…", "error": "The record could not be deleted. Please try again." + }, + "templateIcon": { + "label": "Icon (optional)", + "none": "No icon", + "help": "Visually identifies the template and its assets. You can change or remove it on custom templates.", + "options": { + "laptop": "Laptop", + "desktop": "Desktop computer", + "monitor": "Monitor", + "printer": "Printer", + "router": "Router", + "shield": "Security / firewall", + "network": "Switch / network", + "wifi": "Wi-Fi", + "hard-drive": "NAS / storage", + "server": "Server", + "smartphone": "Smartphone", + "battery": "UPS / battery", + "tablet": "Tablet", + "keyboard": "Keyboard", + "mouse": "Mouse", + "headphones": "Headphones", + "camera": "Camera", + "box": "Other asset" + } } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 870ff11..ab8e843 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -301,5 +301,30 @@ "description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.", "pending": "Eliminando…", "error": "No se ha podido eliminar el registro. Inténtalo de nuevo." + }, + "templateIcon": { + "label": "Icono (opcional)", + "none": "Sin icono", + "help": "Identifica visualmente la plantilla y sus activos. Puedes cambiarlo o quitarlo en las plantillas personalizadas.", + "options": { + "laptop": "Portátil", + "desktop": "Ordenador de sobremesa", + "monitor": "Monitor", + "printer": "Impresora", + "router": "Router", + "shield": "Seguridad / firewall", + "network": "Switch / red", + "wifi": "Wi-Fi", + "hard-drive": "NAS / almacenamiento", + "server": "Servidor", + "smartphone": "Smartphone", + "battery": "SAI / batería", + "tablet": "Tablet", + "keyboard": "Teclado", + "mouse": "Ratón", + "headphones": "Auriculares", + "camera": "Cámara", + "box": "Otro activo" + } } } diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index d576e9e..ecafc73 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -3,8 +3,10 @@ import { XIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; +import type { CatalogItemDTO } from "../../../../common"; import type { AssetDTO } from "../../../../common/inventory"; import { AssetRelationships } from "../../../asset-relationships"; +import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon"; import { AssetSupport } from "../../../support-connections"; import type { InventoryLookups } from "../../shared/types"; @@ -16,6 +18,7 @@ export function AssetSelectionPanel({ open, lookups, canEdit, + template, onClose, onView, onEdit, @@ -25,6 +28,7 @@ export function AssetSelectionPanel({ open: boolean; lookups: InventoryLookups; canEdit: boolean; + template?: CatalogItemDTO | undefined; onClose: () => void; onView: (id: string) => void; onEdit: (id: string) => void; @@ -41,7 +45,13 @@ export function AssetSelectionPanel({ className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid" >
-

{asset.values.name}

+

+ + {asset.values.name} +

diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 3ce04e6..87e1299 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -10,6 +10,7 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; import { CatalogError } from "../../../catalog"; +import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon"; import { InventoryFeedback } from "../../../shared"; import { AssetDetailDialog, AssetSelectionPanel } from "../../detail"; import { AssetEditorDialog } from "../../shared"; @@ -115,7 +116,22 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { id: "template", label: t("template"), value: (asset) => asset.specification.templateName, - render: (asset) => <>{asset.specification.templateName}, + render: (asset) => { + const template = catalog.data?.items.find( + (item) => item.id === asset.values.templateId + ); + + return ( + + + {asset.specification.templateName} + + ); + }, }, { id: "state", @@ -182,6 +198,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { onEdit={setEditor} onView={setViewId} open={detailOpen} + template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)} /> {editor && catalog.data && ( ; + +export function AssetTemplateIcon({ + icon, + className = "size-4 shrink-0", +}: { + icon: AssetTemplateIconKey | undefined; + className?: string; +}) { + if (!icon) { + return null; + } + const Icon = ICONS[icon]; + + return