Añadir icono a las plantillas de activos

This commit is contained in:
David Arranz 2026-10-10 18:34:25 +02:00
parent 8c5b7b4df0
commit 108e810c3b
26 changed files with 499 additions and 5 deletions

View File

@ -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,
{

View File

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

View File

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

View File

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

View File

@ -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<Record<string, AssetTemplateIconKey>> = {
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,

View File

@ -141,7 +141,7 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
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);

View File

@ -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<UniqueID>();
@ -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
);
});

View File

@ -154,6 +154,29 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
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);
}

View File

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

View File

@ -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(

View File

@ -19,6 +19,7 @@ type RelationalDefinition = Omit<DefinitionRow, "values"> & {
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,

View File

@ -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 [

View File

@ -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 },

View File

@ -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<typeof AssetTemplateIconSchema>;

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
>
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.values.name}
</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
<XIcon aria-hidden="true" className="size-4" />
</Button>

View File

@ -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 (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={
template?.values.kind === "template" ? template.values.icon : undefined
}
/>
{asset.specification.templateName}
</span>
);
},
},
{
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 && (
<AssetEditorDialog

View File

@ -29,6 +29,7 @@ export function useCatalogTemplateFormController({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
icon: current?.icon,
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: layout.fields,

View File

@ -0,0 +1,58 @@
import {
BatteryChargingIcon,
BoxIcon,
CameraIcon,
HardDriveIcon,
HeadphonesIcon,
KeyboardIcon,
LaptopIcon,
MonitorCogIcon,
MonitorIcon,
MouseIcon,
NetworkIcon,
PrinterIcon,
RouterIcon,
ServerIcon,
ShieldCheckIcon,
SmartphoneIcon,
TabletIcon,
WifiIcon,
} from "@repo/rdx-ui/lib/icons";
import type { AssetTemplateIconKey } from "../../../../common/asset-template-icons";
const ICONS = {
laptop: LaptopIcon,
desktop: MonitorCogIcon,
monitor: MonitorIcon,
printer: PrinterIcon,
router: RouterIcon,
shield: ShieldCheckIcon,
network: NetworkIcon,
wifi: WifiIcon,
"hard-drive": HardDriveIcon,
server: ServerIcon,
smartphone: SmartphoneIcon,
battery: BatteryChargingIcon,
tablet: TabletIcon,
keyboard: KeyboardIcon,
mouse: MouseIcon,
headphones: HeadphonesIcon,
camera: CameraIcon,
box: BoxIcon,
} satisfies Record<AssetTemplateIconKey, typeof LaptopIcon>;
export function AssetTemplateIcon({
icon,
className = "size-4 shrink-0",
}: {
icon: AssetTemplateIconKey | undefined;
className?: string;
}) {
if (!icon) {
return null;
}
const Icon = ICONS[icon];
return <Icon aria-hidden="true" className={className} />;
}

View File

@ -1,8 +1,10 @@
import { UniqueID } from "@repo/rdx-ddd";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
@ -10,12 +12,15 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import { AssetTemplateIconSchema } from "../../../../common/asset-template-icons";
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
import { useCatalogTemplateFormController } from "../controllers";
import type { CatalogTemplateFormProps } from "../types";
import { AssetTemplateIcon } from "./asset-template-icon";
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
const { scope, allowQuickCreate = true } = props;
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
@ -43,6 +48,29 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
/>
</div>
</div>
<IconPicker
disabled={mutation.isPending}
label={t("templateIcon.label")}
onChange={(value) => {
const icon = AssetTemplateIconSchema.safeParse(value);
form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true });
}}
options={[
{
value: "",
label: t("templateIcon.none"),
icon: <XIcon aria-hidden="true" className="size-4" />,
},
...AssetTemplateIconSchema.options.map((icon) => ({
value: icon,
label: t(`templateIcon.options.${icon}`),
icon: <AssetTemplateIcon icon={icon} />,
})),
]}
value={form.watch("icon") ?? ""}
/>
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
</FormSection>
<div className="space-y-2">
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>

View File

@ -3,6 +3,7 @@ import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { CatalogHelp } from "../../shared";
export function CatalogDefinitionDialog({
@ -34,6 +35,12 @@ export function CatalogDefinitionDialog({
wide={values.kind === "template"}
>
<div className="space-y-4">
{values.kind === "template" && values.icon && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AssetTemplateIcon icon={values.icon} />
{t(`templateIcon.options.${values.icon}`)}
</div>
)}
{values.description && (
<p className="text-sm text-muted-foreground">{values.description}</p>
)}

View File

@ -72,3 +72,35 @@ test("los formularios tienen ayuda inferior y selector de campo con alta integra
assert.ok(template.includes("Todos son opcionales inicialmente"));
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
});
test("las plantillas ofrecen selección visual traducida y conservan el icono al copiar", async () => {
const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
const laptop = catalog.items.find(
(item) => item.values.kind === "template" && item.values.icon === "laptop"
);
assert.ok(laptop);
for (const [language, label, none] of [
["es", "Icono (opcional)", "Sin icono"],
["en", "Icon (optional)", "No icon"],
]) {
assert.ok(language && label && none);
const html = await render(
language,
<CatalogTemplateForm
copy={laptop}
items={catalog.items}
onSaved={() => undefined}
scope="global"
/>
);
assert.ok(html.includes(label));
assert.ok(html.includes(none));
assert.match(
html,
/aria-label="Laptop" aria-pressed="true"|aria-label="Portátil" aria-pressed="true"/
);
assert.ok(html.includes("lucide-laptop"));
}
});

View File

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
export function CatalogName({
item,
@ -22,10 +23,11 @@ export function CatalogName({
return (
<>
<button
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
className="inline-flex items-center gap-2 font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
onClick={onView}
type="button"
>
{values.kind === "template" && <AssetTemplateIcon icon={values.icon} />}
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}

View File

@ -0,0 +1,41 @@
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
export function IconPicker({
label,
options,
value,
onChange,
disabled = false,
}: {
label: string;
options: readonly { value: string; label: string; icon: ReactNode }[];
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
return (
<fieldset className="space-y-2" disabled={disabled}>
<legend className="text-sm font-medium">{label}</legend>
<div className="flex flex-wrap gap-2">
{options.map((option) => (
<Button
aria-label={option.label}
aria-pressed={value === option.value}
className={
value === option.value ? "border-primary text-primary ring-1 ring-primary" : ""
}
key={option.value}
onClick={() => onChange(option.value)}
size="icon"
title={option.label}
type="button"
variant={value === option.value ? "secondary" : "outline"}
>
{option.icon}
</Button>
))}
</div>
</fieldset>
);
}

View File

@ -1,29 +1,44 @@
export {
BatteryChargingIcon,
BellIcon,
BoxIcon,
BriefcaseIcon,
Building2Icon,
CameraIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
DatabaseIcon,
FileKeyIcon,
HardDriveIcon,
HeadphonesIcon,
HomeIcon,
KeyRoundIcon,
KeyboardIcon,
LaptopIcon,
LayersIcon,
LayoutTemplateIcon,
MailIcon,
MapPinIcon,
MonitorCogIcon,
MonitorIcon,
MouseIcon,
NetworkIcon,
PhoneIcon,
PrinterIcon,
RouterIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
SmartphoneIcon,
TabletIcon,
TicketIcon,
TriangleAlertIcon,
UserRoundXIcon,
UsersIcon,
WifiIcon,
WrenchIcon,
XIcon,
} from "lucide-react";