Añadir icono a las plantillas de activos
This commit is contained in:
parent
8c5b7b4df0
commit
108e810c3b
@ -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,
|
||||
{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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 },
|
||||
|
||||
24
modules/assets/src/common/asset-template-icons.ts
Normal file
24
modules/assets/src/common/asset-template-icons.ts
Normal 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>;
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>}
|
||||
|
||||
41
packages/rdx-ui/src/components/icon-picker.tsx
Normal file
41
packages/rdx-ui/src/components/icon-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user