feat: enhance resource management UI with template icons and selection panel

- Added ResourceTemplateIcon component to display icons for resource templates in CatalogDefinitionDialog and CatalogName.
- Updated CatalogDefinitionDialog to conditionally render template icons in the title.
- Enhanced ResourcesPage to include ResourceSelectionPanel for detailed resource views and actions.
- Implemented compact view options in ResourceDetail for a cleaner interface.
- Introduced new resource template icons and updated related tests to ensure proper functionality.
- Added PersonPhotoField component for managing user photos with preview functionality.
- Improved characteristics summary display in resources list.
This commit is contained in:
David Arranz 2026-10-15 12:32:37 +02:00
parent 0f1ad8511a
commit ece38c741e
40 changed files with 1145 additions and 245 deletions

View File

@ -72,6 +72,24 @@ export async function verifyResources(
assert.ok(licenseTemplate && licenseTemplate.values.kind === "template");
assert.equal(licenseTemplate.values.icon, "file-key");
const changedIcon = await save(`${global}/${licenseTemplate.id}/update`, {
...licenseTemplate.values,
icon: "box",
});
const withIcon = changedIcon.items.find((item) => item.id === licenseTemplate.id);
assert.ok(withIcon?.values.kind === "template");
assert.equal(withIcon.values.icon, "box");
assert.deepEqual(withIcon.bindings, licenseTemplate.bindings);
const iconRows = await database.query<{ icon: string | null }>(
"SELECT icon FROM resource_definitions WHERE id=:id",
{ replacements: { id: licenseTemplate.id }, type: QueryTypes.SELECT }
);
assert.equal(iconRows[0]?.icon, "box");
await save(`${global}/${licenseTemplate.id}/update`, licenseTemplate.values);
const licenseFieldId = (key: string) => {
const field = initial.items.find((item) => item.builtinKey === `field:${key}`);
@ -172,7 +190,10 @@ export async function verifyResources(
const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo");
assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template");
const editedValues = { ...stockTemplate.values, description: "Plantilla global editada" };
const { icon: previousIcon, ...templateWithoutIcon } = stockTemplate.values;
assert.equal(previousIcon, "mail");
const editedValues = { ...templateWithoutIcon, description: "Plantilla global editada" };
const edited = await save(`${global}/${stockTemplate.id}/update`, editedValues);
assert.equal(edited.savedId, stockTemplate.id);

View File

@ -815,7 +815,6 @@
"activePerson": "Active person",
"inactivePerson": "Inactive person",
"isContact": "Company contact",
"personHelp": "This person cannot access Metis Desk. Their IT accounts will be managed separately.",
"deactivationHelp": "Assignments and history will be retained. Review any open memberships.",
"inactiveMembershipWarning": "This person is inactive and has department assignments that need review.",
"contactsOnly": "Contacts only",
@ -1212,4 +1211,4 @@
"INTERNAL_ERROR": "The action could not be completed."
}
}
}
}

View File

@ -815,7 +815,6 @@
"activePerson": "Persona activa",
"inactivePerson": "Persona inactiva",
"isContact": "Contacto de la compañía",
"personHelp": "Esta persona no tiene acceso a Metis Desk. Sus cuentas informáticas se gestionarán por separado.",
"deactivationHelp": "Se conservarán sus asignaciones e historial. Revisa las pertenencias que continúen abiertas.",
"inactiveMembershipWarning": "Esta persona está inactiva y conserva asignaciones a departamentos pendientes de revisar.",
"contactsOnly": "Solo contactos",
@ -1212,4 +1211,4 @@
"INTERNAL_ERROR": "No se ha podido completar la operación."
}
}
}
}

View File

@ -343,7 +343,7 @@ Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de
## 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.
`@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. Los catálogos de plantillas de activos y recursos aportan sus iconos Lucide y validan sus claves; el componente admite teclado y distribución responsive.
## Consulta compacta de plantillas
@ -447,13 +447,46 @@ los menús y demás controles conservan su acción propia. Una URL de activo con
Bajo el nombre, inventario y selector muestran un resumen automático: los cuatro primeros
campos con valor de la especificación capturada del activo, en el orden de su plantilla.
Incluye etiqueta y unidad, conserva cero y valores booleanos traducidos y omite vacíos y texto
Incluye los valores y sus unidades, conserva cero y valores booleanos traducidos y omite vacíos y texto
largo. Cada tipo utiliza así sus propios campos, incluidos los personalizados, sin reglas
basadas en nombres ni cambios de persistencia. Si no hay características, se muestra solo el
nombre. El resumen ocupa como máximo dos líneas y su texto completo está disponible en `title`.
El selector se aplica a activos del puesto (adición múltiple), ordenador principal (único entre miembros), extremo de relaciones, impresora por perfil, origen/destino de conexiones remotas y activos afectados de tickets (adición múltiple). Cada caso conserva exclusiones, referencias fijas y altas rápidas. Añadir varios activos a un puesto es una sola actualización de composición; solo se propone automáticamente el ordenador cuando la composición resultante contiene un único activo.
## Panel lateral y resumen de recursos
Recursos aplica el mismo patrón de selección que activos: el listado comienza sin ficha,
el nombre y las zonas sin controles de cada fila abren el recurso y conservan la pestaña
y la búsqueda de la URL. La selección se resalta mientras el panel está abierto. El cierre
oculta el panel y ofrece recuperarlo desde la cabecera. Las URLs de un recurso concreto
siguen abriendo su ficha; en pantallas pequeñas se conserva el diálogo con historial.
`SelectionPanel` de `@repo/rdx-ui/components/selection-panel` comparte la estructura de
activos y recursos: título, cierre accesible, resumen, acciones y contenido por composición.
El consumidor aporta textos traducidos, datos y callbacks. `useCompactDetail` comparte el
umbral de 1100 px con la variante `inventory` de Tailwind. Las fichas completas conservan
su diálogo; el panel de recursos muestra características, integración, notas y contenido
relacionado, sin repetir el historial completo.
`characteristicsSummary` de `@repo/rdx-ui/lib/characteristics-summary` recibe campos,
valores y textos de Sí/No sin depender de DTOs. Activos y recursos muestran los primeros
cuatro valores no vacíos en el orden de su especificación, con unidades, cero y booleanos
traducidos. El resumen ocupa dos líneas bajo el nombre y ofrece el texto completo en
`title`. No se añaden reglas basadas en nombres de plantillas ni cambios de persistencia.
## Componentes archivados
Las referencias históricas a formularios y listados de tickets, intervenciones y sus plantillas pertenecen al [archivo de funcionalidades](funcionalidades-archivadas.md). No forman parte de la aplicación activa.
## Formulario de personas
El alta y la edición comparten `PersonForm` y `PersonEditorDialog`. Los switches de
persona activa y contacto de la compañía aparecen al comienzo, en una franja adaptable.
Los datos personales y de contacto se agrupan con `FormSection variant="plain"` y
rejillas de una o dos columnas. La ayuda contextual permanece al final del cuerpo.
`PersonPhotoField` encapsula el selector accesible de foto y su vista previa circular,
utilizando Avatar y Button de shadcn/ui. Sin imagen se muestra un placeholder de cámara;
al editar se presenta la foto vigente. La selección es provisional hasta guardar y las
URLs de vista previa se liberan al cambiar de archivo o desmontar el componente.

View File

@ -62,7 +62,7 @@ La aplicación navega por espacios y secciones mediante rutas URL independientes
| Usuarios internos | [UserManagement](../modules/identity/src/web/users/list/ui/user-management.tsx) | Sí |
| Activos | [AssetInventoryPage](../modules/assets/src/web/assets/list/ui/inventory-page.tsx) | Sí; conserva panel lateral |
| Puestos de trabajo | [WorkstationsPage](../modules/assets/src/web/workstations/list/ui/workstations-page.tsx) | Sí |
| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí; pestañas por plantilla y «Todo» |
| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí; pestañas por plantilla y «Todo», panel lateral y ficha modal móvil |
| Campos y plantillas de recursos, global/compañía | [ResourceCatalogPage](../modules/resources/src/web/catalog/list/ui/catalog-page.tsx) | Sí; dos secciones |
| Campos, plantillas y relaciones de activos, global/compañía | [AssetCatalogPage](../modules/assets/src/web/catalog/list/ui/catalog-page.tsx) | Sí; tres secciones |
| Cuentas y accesos / catálogos informáticos | [AccessWorkspacePage](../modules/companies/src/web/access/list/ui/access-workspace.tsx) | Sí; cuenta, titularidad, grupo, pertenencia, autorización, necesidad, cobertura, entorno, sistema, tipo, permiso y plantilla |

View File

@ -118,3 +118,22 @@ características ni las notas.
La edición y las altas especializadas integradas en otros formularios mantienen su
presentación completa. No cambia el contrato HTTP, la persistencia ni la validación de dominio.
## Iconos de plantillas
Las ocho plantillas de serie incluyen un icono Lucide: correo para buzones, llave para
accesos, carpeta para carpetas compartidas, auriculares para asistencia, monitor para
sistemas operativos, capas para aplicaciones, escudo para certificados y archivo con
llave para licencias. Las plantillas globales y propias de compañía permiten elegir,
cambiar o retirar un icono mediante el selector visual común; las copias conservan la selección.
El catálogo, su consulta de detalle, la columna Plantilla del listado de recursos y la
cabecera del panel lateral muestran el icono vigente de la plantilla. No forma parte de
la especificación ni modifica el historial del recurso.
La clave se valida con un conjunto cerrado y se guarda en `resource_definitions.icon`,
columna nullable, sin JSON ni imágenes. Al añadirla a una instalación existente se
asignan una sola vez los iconos de serie por `stockKey`. Los siguientes arranques no
reponen iconos retirados ni cambian las elecciones del usuario. Las plantillas nuevas
reciben los valores de serie desde su carga inicial; los campos no admiten iconos.

View File

@ -44,22 +44,32 @@ export function AssetSelectionPanel({
return (
<SelectionPanel
label={t("assetPanel")}
actions={
<>
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">
{t("viewAsset")}
</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
{t("edit")}
</Button>
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">
{t("deleteAsset")}
</Button>
</>
}
closeLabel={t("closeAssetPanel")}
label={t("assetPanel")}
onClose={onClose}
title={<>
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.values.name}
</>}
summary={<AssetPanelSummary asset={asset} lookups={lookups} />}
actions={<>
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">{t("viewAsset")}</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">{t("edit")}</Button>
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">{t("deleteAsset")}</Button>
</>}
title={
<>
<AssetTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.values.name}
</>
}
>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>

View File

@ -1,10 +1,10 @@
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@ -14,8 +14,6 @@ import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types";
export function useAssetInventoryPageController({
companyId,
initialId,

View File

@ -559,11 +559,11 @@ test("el resumen usa los campos propios de la plantilla, omite vacíos y texto l
assert.equal(
assetCharacteristicsSummary(asset, { yes: "Sí", no: "No" }),
"Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí"
"ThinkCentre · 0 GB · No · Sí"
);
assert.equal(
assetCharacteristicsSummary(asset, { yes: "Yes", no: "No" }),
"Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Yes"
"ThinkCentre · 0 GB · No · Yes"
);
const html = await render(
"es",
@ -573,7 +573,7 @@ test("el resumen usa los campos propios de la plantilla, omite vacíos y texto l
{ assets: [asset], catalog: { items: [] } }
);
assert.ok(html.includes("Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí"));
assert.ok(html.includes("ThinkCentre · 0 GB · No · Sí"));
assert.equal(html.includes("Texto que no debe mostrarse"), false);
assert.equal(
assetCharacteristicsSummary(

View File

@ -599,7 +599,6 @@
"activePerson": "Active person",
"inactivePerson": "Inactive person",
"isContact": "Company contact",
"personHelp": "This person cannot access Metis Desk. Their IT accounts will be managed separately.",
"deactivationHelp": "Assignments and history will be retained. Review any open memberships.",
"inactiveMembershipWarning": "This person is inactive and has department assignments that need review.",
"contactsOnly": "Contacts only",
@ -715,6 +714,13 @@
"deleteAssignmentConfirm": "Delete the assignment to {{name}}?",
"deleteAssignmentError": "The assignment could not be deleted.",
"endDateColumn": "End date",
"personForm": {
"identity": "Personal details",
"contact": "Contact details",
"choosePhoto": "Add photo",
"changePhoto": "Change photo",
"photoHelp": "PNG, JPEG or WebP. The photo is applied when you save."
},
"photoOptional": "Photo (optional)",
"deleteRecord": {
"title": "Delete record",
@ -995,4 +1001,4 @@
"INTERNAL_ERROR": "The action could not be completed."
}
}
}
}

View File

@ -599,7 +599,6 @@
"activePerson": "Persona activa",
"inactivePerson": "Persona inactiva",
"isContact": "Contacto de la compañía",
"personHelp": "Esta persona no tiene acceso a Metis Desk. Sus cuentas informáticas se gestionarán por separado.",
"deactivationHelp": "Se conservarán sus asignaciones e historial. Revisa las pertenencias que continúen abiertas.",
"inactiveMembershipWarning": "Esta persona está inactiva y conserva asignaciones a departamentos pendientes de revisar.",
"contactsOnly": "Solo contactos",
@ -715,6 +714,13 @@
"deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?",
"deleteAssignmentError": "No se puede eliminar la asignación.",
"endDateColumn": "Fecha de fin",
"personForm": {
"identity": "Datos personales",
"contact": "Datos de contacto",
"choosePhoto": "Añadir foto",
"changePhoto": "Cambiar foto",
"photoHelp": "PNG, JPEG o WebP. La foto se aplica al guardar."
},
"photoOptional": "Foto (opcional)",
"deleteRecord": {
"title": "Eliminar registro",
@ -995,4 +1001,4 @@
"INTERNAL_ERROR": "No se ha podido completar la operación."
}
}
}
}

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -21,7 +22,12 @@ export function PersonEditorDialog({
onOpenChange={onOpenChange}
open={open}
size="medium"
title={t(formProps.person ? "editPerson" : "newPerson")}
title={
<span className="flex items-center gap-2">
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t(formProps.person ? "editPerson" : "newPerson")}
</span>
}
>
<PersonForm {...formProps} key={formProps.person?.id ?? "new"} />
</FormDialog>

View File

@ -1,4 +1,5 @@
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
@ -11,65 +12,75 @@ import { CompanyFeedback } from "../../../companies/shared";
import { usePersonFormController } from "../controllers";
import type { PersonFormProps } from "../types";
import { PersonPhotoField } from "./person-photo-field";
export function PersonForm(props: PersonFormProps) {
const { person, requireActive = false } = props;
const { form, mutation, submit, setPhotoFile } = usePersonFormController(props);
const { person, companyId, requireActive = false } = props;
const { form, mutation, submit, photoFile, setPhotoFile } = usePersonFormController(props);
const { t } = useTranslation("companies");
const id = useId();
return (
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-photo`}>{t("photoOptional")}</Label>
<Input
id={`${id}-photo`}
accept="image/png,image/jpeg,image/webp"
type="file"
onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
<SwitchField
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
label={t("activePerson")}
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
/>
<SwitchField
checked={form.watch("isContact")}
id={`${id}-contact`}
label={t("isContact")}
onCheckedChange={(isContact) =>
form.setValue("isContact", isContact, { shouldDirty: true })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
</div>
{person?.active && !form.watch("active") && (
<p className="text-sm text-muted-foreground">{t("deactivationHelp")}</p>
)}
</div>
<SwitchField
checked={form.watch("active")}
disabled={requireActive}
id={`${id}-active`}
label={t("activePerson")}
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
/>
<SwitchField
checked={form.watch("isContact")}
id={`${id}-contact`}
label={t("isContact")}
onCheckedChange={(isContact) =>
form.setValue("isContact", isContact, { shouldDirty: true })
}
/>
{person?.active && !form.watch("active") && (
<p className="text-sm text-muted-foreground">{t("deactivationHelp")}</p>
)}
<FormSection title={t("personForm.identity")} variant="plain">
<PersonPhotoField
companyId={companyId}
disabled={mutation.isPending}
file={photoFile}
onChange={setPhotoFile}
person={person}
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
</div>
</div>
</FormSection>
<FormSection title={t("personForm.contact")} variant="plain">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
</div>
</div>
</FormSection>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
<EntityHelp kind="person" />
</FormBody>
<FormActions>

View File

@ -0,0 +1,82 @@
import { CameraIcon } from "@repo/rdx-ui/lib/icons";
import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar";
import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PersonDTO } from "../../../../common";
export function PersonPhotoField({
companyId,
person,
file,
onChange,
disabled,
}: {
companyId: string;
person?: PersonDTO | undefined;
file: File | null;
onChange: (file: File | null) => void;
disabled: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setPreview(undefined);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const currentPhoto = person?.photoVersion
? `/api/companies/${companyId}/people/${person.id}/photo?version=${person.photoVersion}`
: undefined;
return (
<div className="flex items-center gap-4 pb-1">
<Avatar aria-hidden="true" className="size-20 shrink-0">
<AvatarImage alt="" src={preview ?? currentPhoto} />
<AvatarFallback className="border border-dashed bg-muted/40">
<CameraIcon className="size-7 text-muted-foreground/70" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-2">
<Label htmlFor={id}>{t("photoOptional")}</Label>
<input
accept="image/png,image/jpeg,image/webp"
aria-describedby={`${id}-help`}
className="sr-only"
disabled={disabled}
id={id}
onChange={(event) => onChange(event.target.files?.[0] ?? null)}
ref={inputRef}
tabIndex={-1}
type="file"
/>
<Button
aria-describedby={`${id}-help`}
disabled={disabled}
onClick={() => inputRef.current?.click()}
size="sm"
type="button"
variant="outline"
>
{t(file || currentPhoto ? "personForm.changePhoto" : "personForm.choosePhoto")}
</Button>
<p className="break-words text-xs text-muted-foreground" id={`${id}-help`}>
{file ? file.name : t("personForm.photoHelp")}
</p>
</div>
</div>
);
}

View File

@ -1,5 +1,7 @@
import { UniqueID } from "@repo/rdx-ddd";
import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../common/resource-template-icons";
import type { ResourceCatalog } from "./resource-catalog.aggregate";
import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo";
@ -123,7 +125,11 @@ const FIELDS: readonly [string, string, ResourceFieldType, string, (readonly str
"Marca si la renovación se realiza automáticamente. Ejemplo: Sí si el proveedor carga el siguiente periodo.",
],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [
const TEMPLATES: readonly [
keyof typeof BUILTIN_RESOURCE_TEMPLATE_ICONS,
string,
readonly string[],
][] = [
["mailbox", "Buzón de correo", ["email", "provider", "context"]],
["access", "Recurso de acceso", ["reference"]],
["shared_folder", "Carpeta compartida", ["unc", "server", "description"]],
@ -210,6 +216,7 @@ export function seedResourceCatalog(catalog: ResourceCatalog) {
add(`template:${key}`, {
kind: "template",
icon: BUILTIN_RESOURCE_TEMPLATE_ICONS[key],
...(key === "software_license"
? { usage: "license" as const }
: key === "support" || key === "os"

View File

@ -4,9 +4,12 @@ import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../common/resource-template-icons";
import { ResourceCatalog } from "./resource-catalog.aggregate";
import { seedResourceCatalog } from "./resource-catalog-seed";
import type { ResourceDefinitionSpec } from "./resource-definition";
import { ResourceDefinitionSchema } from "./resource-definition";
import { ResourceDefinition } from "./resource-definition.entity";
import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo";
@ -354,3 +357,95 @@ test("sistema operativo: plantilla con uso explícito, copia y ampliación sin s
assert.equal(template.values.data.fields.length, 3);
assert.equal(seedResourceCatalog(catalog), catalog);
});
test("iconos de recursos: todas las plantillas de serie tienen su icono y el arranque no repone los retirados", () => {
const initial = seedResourceCatalog(ResourceCatalog.create());
for (const [key, icon] of Object.entries(BUILTIN_RESOURCE_TEMPLATE_ICONS)) {
const template = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === `template:${key}`
);
assert.ok(template?.values.data.kind === "template");
assert.equal(template.values.data.icon, icon);
}
const template = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:mailbox"
);
assert.ok(template?.values.data.kind === "template");
const { icon, ...withoutIcon } = template.values.data;
assert.equal(icon, "mail");
const removed = initial.save(Maybe.none(), Maybe.some(template.id), values(withoutIcon));
assert.ok(removed.isSuccess);
const restarted = seedResourceCatalog(removed.data);
const saved = restarted.definitions.find((item) => item.id.equals(template.id));
assert.ok(saved?.values.data.kind === "template");
assert.equal(saved.values.data.icon, undefined);
assert.deepEqual(saved.values.data.fields, template.values.data.fields);
assert.deepEqual(saved.bindings, template.bindings);
assert.equal(seedResourceCatalog(restarted), restarted);
});
test("iconos de recursos: cambiar y retirar el icono conserva identidad y asociaciones, con guardado idempotente", () => {
let catalog = seedResourceCatalog(ResourceCatalog.create());
const fieldId = catalog.definitions[0]?.id.toPrimitive();
assert.ok(fieldId);
for (const scope of [Maybe.none<UniqueID>(), Maybe.some(UniqueID.generateNewID())]) {
const input = {
kind: "template",
name: scope.getOrUndefined() ? "Recurso local con icono" : "Recurso global con icono",
description: "",
fields: [{ fieldId, required: false }],
} satisfies ResourceDefinitionSpec;
const created = catalog.save(scope, Maybe.none(), values(input));
assert.ok(created.isSuccess);
const template = created.data.definitions.at(-1);
assert.ok(template);
const changed = created.data.save(
scope,
Maybe.some(template.id),
values({ ...input, icon: "folder" })
);
assert.ok(changed.isSuccess);
const saved = changed.data.definitions.find((item) => item.id.equals(template.id));
assert.ok(saved?.values.data.kind === "template");
assert.equal(saved.values.data.icon, "folder");
assert.ok(saved.bindings[0]?.id.equals(template.bindings[0]?.id));
const repeated = changed.data.save(scope, Maybe.some(saved.id), saved.values);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, changed.data);
const removed = changed.data.save(scope, Maybe.some(saved.id), values(input));
assert.ok(removed.isSuccess);
const cleared = removed.data.definitions.find((item) => item.id.equals(template.id));
assert.ok(cleared?.values.data.kind === "template");
assert.equal(cleared.values.data.icon, undefined);
catalog = removed.data;
}
});
test("iconos de recursos: claves desconocidas, cadenas vacías e iconos de campos se rechazan", () => {
const template = {
kind: "template",
name: "Plantilla",
description: "",
fields: [{ fieldId: UniqueID.generateNewID().toPrimitive(), required: false }],
};
for (const icon of ["unknown-icon", "", null]) {
assert.equal(ResourceDefinitionSchema.safeParse({ ...template, icon }).success, false);
}
assert.equal(ResourceDefinitionSchema.safeParse({ ...field, icon: "mail" }).success, false);
assert.ok(ResourceDefinitionSchema.safeParse(template).success);
});

View File

@ -1,5 +1,7 @@
import { z } from "zod/v4";
import { ResourceTemplateIconSchema } from "../../common/resource-template-icons";
import { validTemplateSections } from "./template-sections";
const title = z.string().min(1).max(100);
@ -30,6 +32,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [
z
.strictObject({
kind: z.literal("template"),
icon: ResourceTemplateIconSchema.optional(),
name: title,
description: z.string().max(1000),
fields: z.array(TemplateFieldSchema).min(1).max(100),

View File

@ -104,6 +104,7 @@ export class ResourceDefinitionValues {
a.kind === "template" &&
b.kind === "template" &&
a.usage === b.usage &&
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;
usage: string | null;
@ -94,6 +95,7 @@ export async function readDefinitions(
values = {
...common,
kind: row.kind,
...(row.icon ? { icon: row.icon } : {}),
...(row.usage ? { usage: row.usage } : {}),
fields: bindings
.filter((binding) => binding.templateId === row.id)
@ -147,6 +149,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,
usage: values.kind === "template" ? (values.usage ?? null) : null,

View File

@ -1,6 +1,8 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../../common/resource-template-icons";
import { definitionColumns, initializeResourceCatalogSchema } from "./resource-relational-schema";
export class ResourceCatalogModel extends Model<
@ -105,6 +107,20 @@ export async function migrateResourceCatalog(database: Sequelize) {
await query.addIndex(table, [...fields], { unique: true, name });
}
}
const columns = await query.describeTable("resource_definitions");
if (!columns.icon) {
await query.addColumn("resource_definitions", "icon", definitionColumns.icon);
// La asignación inicial solo acompaña a la nueva columna; no repone iconos retirados.
await database.transaction(async (transaction) => {
for (const [key, icon] of Object.entries(BUILTIN_RESOURCE_TEMPLATE_ICONS)) {
await database.query(
"UPDATE resource_definitions SET icon=:icon WHERE stockKey=:stockKey AND kind='template' AND icon IS NULL",
{ replacements: { icon, stockKey: `template:${key}` }, transaction }
);
}
});
}
await initializeResourceCatalogSchema(database);
// Clasificación explícita de la plantilla de serie existente, sin alterar copias ni campos.
await database.query(

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 },
usage: { type: DataTypes.STRING(15), allowNull: true },

View File

@ -1,4 +1,23 @@
{
"templateIcon": {
"label": "Icon (optional)",
"none": "No icon",
"help": "Shown in the catalog and in resources using this template.",
"options": {
"mail": "Mail",
"key": "Access",
"folder": "Folder",
"headphones": "Support",
"monitor": "Operating system",
"layers": "Application",
"shield": "Certificate",
"file-key": "License",
"box": "Resource"
}
},
"resourcePanel": "Resource details",
"closeResourcePanel": "Close resource panel",
"showResourcePanel": "Show resource panel",
"allResources": "All",
"filterByTemplate": "Filter by template",
"inventory": "Resources",

View File

@ -1,4 +1,23 @@
{
"templateIcon": {
"label": "Icono (opcional)",
"none": "Sin icono",
"help": "Se muestra en el catálogo y en los recursos de esta plantilla.",
"options": {
"mail": "Correo",
"key": "Acceso",
"folder": "Carpeta",
"headphones": "Asistencia",
"monitor": "Sistema operativo",
"layers": "Aplicación",
"shield": "Certificado",
"file-key": "Licencia",
"box": "Recurso"
}
},
"resourcePanel": "Ficha del recurso",
"closeResourcePanel": "Cerrar panel del recurso",
"showResourcePanel": "Mostrar panel del recurso",
"allResources": "Todo",
"filterByTemplate": "Filtrar por plantilla",
"inventory": "Recursos",

View File

@ -0,0 +1,26 @@
import { z } from "zod/v4";
export const ResourceTemplateIconSchema = z.enum([
"mail",
"key",
"folder",
"headphones",
"monitor",
"layers",
"shield",
"file-key",
"box",
]);
export type ResourceTemplateIconKey = z.infer<typeof ResourceTemplateIconSchema>;
export const BUILTIN_RESOURCE_TEMPLATE_ICONS = {
mailbox: "mail",
access: "key",
shared_folder: "folder",
support: "headphones",
os: "monitor",
application: "layers",
certificate: "shield",
software_license: "file-key",
} as const satisfies Record<string, ResourceTemplateIconKey>;

View File

@ -30,6 +30,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 ?? "",
usage: templateUsage(source),

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 { ResourceTemplateIconSchema } from "../../../../common/resource-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 { ResourceTemplateIcon } from "./resource-template-icon";
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
const { scope, item, 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 = ResourceTemplateIconSchema.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" />,
},
...ResourceTemplateIconSchema.options.map((icon) => ({
value: icon,
label: t(`templateIcon.options.${icon}`),
icon: <ResourceTemplateIcon icon={icon} />,
})),
]}
value={form.watch("icon") ?? ""}
/>
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
<div className="space-y-2">
<Label htmlFor={`${id}-usage`}>{t("templateUsage")}</Label>
<SelectWithAction

View File

@ -0,0 +1,40 @@
import {
BoxIcon,
FileKeyIcon,
FolderIcon,
HeadphonesIcon,
KeyRoundIcon,
LayersIcon,
MailIcon,
MonitorIcon,
ShieldCheckIcon,
} from "@repo/rdx-ui/lib/icons";
import type { ResourceTemplateIconKey } from "../../../../common/resource-template-icons";
const ICONS = {
mail: MailIcon,
key: KeyRoundIcon,
folder: FolderIcon,
headphones: HeadphonesIcon,
monitor: MonitorIcon,
layers: LayersIcon,
shield: ShieldCheckIcon,
"file-key": FileKeyIcon,
box: BoxIcon,
} satisfies Record<ResourceTemplateIconKey, typeof MailIcon>;
export function ResourceTemplateIcon({
icon,
className = "size-4 shrink-0 text-muted-foreground",
}: {
icon: ResourceTemplateIconKey | undefined;
className?: string;
}) {
if (!icon) {
return null;
}
const Icon = ICONS[icon];
return <Icon aria-hidden="true" className={className} />;
}

View File

@ -4,6 +4,7 @@ import { TemplateFieldSummary } from "@repo/rdx-ui/components/template-field-sum
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
import { CatalogHelp } from "../../shared";
export function CatalogDefinitionDialog({
@ -31,7 +32,12 @@ export function CatalogDefinitionDialog({
}
}}
open
title={t("viewDefinition", { name: values.name })}
title={
<span className="flex items-center gap-2">
{values.kind === "template" && <ResourceTemplateIcon icon={values.icon} />}
{t("viewDefinition", { name: values.name })}
</span>
}
wide={values.kind === "template"}
>
<div className="space-y-4">

View File

@ -72,3 +72,46 @@ 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("iconos de recursos: catálogo y selector traducido conservan la selección al editar y copiar", async () => {
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
const mailbox = catalog.items.find((item) => item.builtinKey === "template:mailbox");
assert.ok(mailbox);
for (const [language, label, none, mail] of [
["es", "Icono (opcional)", "Sin icono", "Correo"],
["en", "Icon (optional)", "No icon", "Mail"],
]) {
assert.ok(language && label && none && mail);
const list = await render(language, <ResourceCatalogPage scope="global" />);
for (const icon of [
"mail",
"key-round",
"folder",
"headphones",
"monitor",
"layers",
"shield-check",
"file-key",
]) {
assert.ok(list.includes(`lucide-${icon}`));
}
for (const source of [{ item: mailbox }, { copy: mailbox }]) {
const html = await render(
language,
<CatalogTemplateForm
{...source}
items={catalog.items}
onSaved={() => undefined}
scope="global"
/>
);
assert.ok(html.includes(label));
assert.ok(html.includes(none));
assert.ok(html.includes(`aria-label="${mail}" aria-pressed="true"`));
assert.ok(html.includes("lucide-mail"));
}
}
});

View File

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-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" && <ResourceTemplateIcon icon={values.icon} />}
{values.name}
</button>
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}

View File

@ -1 +1 @@
export { ResourceDetailDialog } from "./ui";
export { ResourceDetailDialog, ResourceSelectionPanel } from "./ui";

View File

@ -1 +1,2 @@
export { ResourceDetailDialog } from "./resource-detail-dialog";
export { ResourceSelectionPanel } from "./resource-selection-panel";

View File

@ -9,8 +9,10 @@ import { IntegrationSummary } from "./integration-summary";
export function ResourceDetail({
resource,
references = [],
compact = false,
}: {
resource: ResourceDTO;
compact?: boolean;
references?: readonly ResourceReferenceDTO[];
}) {
const { t, i18n } = useTranslation("resources");
@ -24,19 +26,22 @@ export function ResourceDetail({
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{resource.specification.templateName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
</div>
</dl>
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
{!compact && (
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{resource.specification.templateName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
</div>
</dl>
)}
<FieldSections
items={resource.specification.fields}
layout={compact ? "rows" : "fields"}
renderItem={(field) => (
<DetailField
key={field.fieldId}
@ -58,43 +63,50 @@ export function ResourceDetail({
{resource.values.notes && (
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
)}
<h3 className="font-medium">
{t("history")} ({resource.history.length})
</h3>
<ol className="space-y-4">
{[...resource.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {t("recordedBy", { name: revision.actorName })}
</p>
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<FieldSections
items={resource.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd className="whitespace-pre-wrap break-words">
{display(revision.values.characteristics[field.fieldId])}
</dd>
</div>
)}
sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
<IntegrationSummary integration={revision.values.integration} references={references} />
{revision.values.notes && (
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}
</li>
))}
</ol>
{!compact && (
<>
<h3 className="font-medium">
{t("history")} ({resource.history.length})
</h3>
<ol className="space-y-4">
{[...resource.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(revision.recordedAt))}{" "}
· {t("recordedBy", { name: revision.actorName })}
</p>
<p>
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
</p>
<FieldSections
items={resource.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-muted-foreground">{field.name}</dt>
<dd className="whitespace-pre-wrap break-words">
{display(revision.values.characteristics[field.fieldId])}
</dd>
</div>
)}
sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
<IntegrationSummary
integration={revision.values.integration}
references={references}
/>
{revision.values.notes && (
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
)}
</li>
))}
</ol>
</>
)}
</div>
);
}

View File

@ -0,0 +1,71 @@
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { ResourceDTO, ResourceReferenceDTO } from "../../../../common/inventory";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
import { ResourceDetail } from "./resource-detail";
export function ResourceSelectionPanel({
resource,
template,
references,
relatedContent,
onClose,
onView,
onEdit,
canEdit,
}: {
resource: ResourceDTO;
template?: CatalogItemDTO | undefined;
references: readonly ResourceReferenceDTO[];
relatedContent: ((resource: ResourceDTO) => ReactNode) | undefined;
onClose: () => void;
onView: () => void;
onEdit: () => void;
canEdit: boolean;
}) {
const { t } = useTranslation("resources");
return (
<SelectionPanel
actions={
<>
<Button onClick={onView} size="sm" variant="outline">
{t("viewResource")}
</Button>
<Button disabled={!canEdit} onClick={onEdit} size="sm" variant="outline">
{t("edit")}
</Button>
</>
}
closeLabel={t("closeResourcePanel")}
label={t("resourcePanel")}
onClose={onClose}
summary={
<div className="flex flex-wrap items-center gap-2 text-sm">
<span>{resource.specification.templateName}</span>
<EntityStatus active={resource.values.active}>
{t(resource.values.active ? "active" : "inactive")}
</EntityStatus>
</div>
}
title={
<>
<ResourceTemplateIcon
className="size-5 shrink-0 text-muted-foreground"
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{resource.values.name}
</>
}
>
<ResourceDetail compact references={references} resource={resource} />
{relatedContent?.(resource)}
</SelectionPanel>
);
}

View File

@ -1,4 +1,10 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import { useState } from "react";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
@ -28,10 +34,39 @@ export function useResourcesPageController({
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
const {
selectedId: viewId,
selectEntity: setViewId,
entityHref,
selectedId,
selectEntity,
entityHref: baseEntityHref,
routed,
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
const compact = useCompactDetail();
const [localViewId, setLocalViewId] = useState<string | undefined>();
const [detailOpen, setDetailOpen] = useState(true);
const entityHref = (id: string) => {
const search = searchParams.toString();
return `${baseEntityHref(id)}${search ? `?${search}` : ""}`;
};
const setSelectedId = (id: string | undefined) => {
if (routed && id) {
navigateTo(entityHref(id));
} else {
selectEntity(id);
}
};
const selectResource = (id: string) => {
setDetailOpen(true);
setSelectedId(id);
if (compact && !routed) {
setLocalViewId(id);
}
};
const viewId = compact && routed ? selectedId : localViewId;
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
@ -62,6 +97,7 @@ export function useResourcesPageController({
});
const edited = query.data?.resources.find((item) => item.id === editor);
const selected = query.data?.resources.find((item) => item.id === selectedId);
const viewed = query.data?.resources.find((item) => item.id === viewId);
const visible =
query.data?.resources.filter((item) => {
@ -89,7 +125,13 @@ export function useResourcesPageController({
edited,
viewed,
entityHref,
missingView: Boolean(viewId && query.isSuccess && !viewed),
selected,
selectResource,
detailOpen,
setDetailOpen,
missingView: Boolean(selectedId && query.isSuccess && !selected),
visible,
loading: query.isPending || catalog.isPending,
unavailable: (!query.data && query.isError) || (!catalog.data && catalog.isError),
};
}

View File

@ -6,12 +6,14 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
import { useTranslation } from "react-i18next";
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
import { InventoryFeedback } from "../../../shared";
import { ResourceDetailDialog } from "../../detail";
import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail";
import { ResourceEditorDialog } from "../../shared";
import { useResourcesPageController } from "../controllers";
import type { ResourcesPageProps } from "../types";
@ -35,6 +37,12 @@ export function ResourcesPage(props: ResourcesPageProps) {
entityHref,
missingView,
visible,
loading,
unavailable,
selected,
selectResource,
detailOpen,
setDetailOpen,
} = useResourcesPageController(props);
const { t, i18n } = useTranslation("resources");
const tabValue = templateId ?? "all";
@ -44,111 +52,177 @@ export function ResourcesPage(props: ResourcesPageProps) {
}
return (
<div className="min-w-0 space-y-6">
<EntityListHeader
actions={
<>
{" "}
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
{t("newResource")}
</Button>{" "}
</>
}
description={t("resourceScope")}
locale={i18n.language}
title={t("inventory")}
/>
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_clamp(320px,24vw,400px)] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
data-detail-open={detailOpen && Boolean(selected)}
data-slot="inventory-layout"
>
<div className="min-w-0 space-y-6">
<EntityListHeader
actions={
<>
{" "}
{!detailOpen && selected && (
<Button
className="hidden inventory:inline-flex"
onClick={() => setDetailOpen(true)}
variant="outline"
>
{t("showResourcePanel")}
</Button>
)}
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
{t("newResource")}
</Button>{" "}
</>
}
description={t("resourceScope")}
locale={i18n.language}
title={t("inventory")}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
{loading && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
<Tabs onValueChange={setTemplateId} value={tabValue}>
<div className="min-w-0 overflow-x-auto">
<TabsList aria-label={t("filterByTemplate")} variant="line">
<TabsTrigger value="all">{t("allResources")}</TabsTrigger>
{templates.map((template) => (
<TabsTrigger key={template.id} value={template.id}>
{template.values.name}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent keepMounted value={tabValue}>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => {
const summary = characteristicsSummary(
item.specification.fields,
item.values.characteristics,
{ yes: t("yes"), no: t("no") }
);
return (
<div className="space-y-1">
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(item.id)}
onClick={() => setDetailOpen(true)}
>
{item.values.name}
</BrowserLink>
{summary && (
<p className="line-clamp-2 text-xs text-muted-foreground" title={summary}>
{summary}
</p>
)}
</div>
);
},
},
{
id: "template",
label: t("template"),
value: (item) => item.specification.templateName,
render: (item) => {
const template = catalog.data?.items.find(
(template) => template.id === item.values.templateId
);
return (
<span className="inline-flex items-center gap-2">
<ResourceTemplateIcon
icon={
template?.values.kind === "template" ? template.values.icon : undefined
}
/>
{item.specification.templateName}
</span>
);
},
},
{
id: "state",
label: t("status"),
value: (item) => t(item.values.active ? "active" : "inactive"),
render: (item) => (
<EntityStatus active={item.values.active}>
{t(item.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setViewId(item.id)}>
{t("viewResource")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("empty")}
filterKey={tabValue}
filtersApplied={templateId !== null}
items={visible}
loading={loading}
locale={i18n.language}
onClearFilters={clearFilters}
onRowClick={(item) => selectResource(item.id)}
onSearchChange={setSearch}
rowClassName={(item) =>
item.id === selected?.id && detailOpen
? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent"
: ""
}
search={search}
searchPlaceholder={t("search")}
title={t("inventory")}
unavailable={unavailable}
/>
</TabsContent>
</Tabs>
</div>
{selected && detailOpen && (
<ResourceSelectionPanel
canEdit={catalog.isSuccess}
onClose={() => setDetailOpen(false)}
onEdit={() => setEditor(selected.id)}
onView={() => setViewId(selected.id)}
references={query.data?.references ?? []}
relatedContent={relatedContent}
resource={selected}
template={catalog.data?.items.find(
(template) => template.id === selected.values.templateId
)}
/>
)}
<Tabs onValueChange={setTemplateId} value={tabValue}>
<div className="min-w-0 overflow-x-auto">
<TabsList aria-label={t("filterByTemplate")} variant="line">
<TabsTrigger value="all">{t("allResources")}</TabsTrigger>
{templates.map((template) => (
<TabsTrigger key={template.id} value={template.id}>
{template.values.name}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent keepMounted value={tabValue}>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(item.id)}
>
{item.values.name}
</BrowserLink>
),
},
{
id: "template",
label: t("template"),
value: (item) => item.specification.templateName,
render: (item) => <>{item.specification.templateName}</>,
},
{
id: "state",
label: t("status"),
value: (item) => t(item.values.active ? "active" : "inactive"),
render: (item) => (
<EntityStatus active={item.values.active}>
{t(item.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction onClick={() => setViewId(item.id)}>
{t("viewResource")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("empty")}
filterKey={tabValue}
filtersApplied={templateId !== null}
items={visible}
loading={query.isPending || catalog.isPending}
locale={i18n.language}
onClearFilters={clearFilters}
onSearchChange={setSearch}
search={search}
searchPlaceholder={t("search")}
title={t("inventory")}
unavailable={(!query.data && query.isError) || (!catalog.data && catalog.isError)}
/>
</TabsContent>
</Tabs>
{editor && catalog.data && (
<ResourceEditorDialog
companyId={companyId}
@ -156,7 +230,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
onClose={() => setEditor(undefined)}
onSaved={(id) => {
setEditor(undefined);
setViewId(id);
selectResource(id);
}}
resource={edited}
/>

View File

@ -18,6 +18,8 @@ import { ResourceSchema } from "../../../common/inventory";
import { catalogKeys } from "../../catalog/shared/api";
import { resourcesEnglish, resourcesSpanish } from "../../shared";
import { ResourceCreateProgress } from "../create/ui/resource-create-progress";
import { ResourceDetail } from "../detail/ui/resource-detail";
import { ResourceSelectionPanel } from "../detail/ui/resource-selection-panel";
import { ResourcesPage } from "../list";
import { inventoryKeys } from "./api";
@ -74,6 +76,26 @@ test("recursos: listado con alta modal y sin formulario visible, en español e i
assert.equal(html.includes("<form"), false);
}
});
function assertResourceRows(
table: string,
resources: readonly ResourceDTO[],
expected: readonly string[],
search: string
) {
for (const resource of resources) {
assert.equal(table.includes(resource.values.name), expected.includes(resource.values.name));
if (expected.includes(resource.values.name)) {
assert.ok(
table.includes(
`href="/companies/${companyId}/resources/${resource.id}${search.replaceAll("&", "&amp;")}"`
)
);
assert.ok(table.includes("group/entity-row cursor-pointer"));
}
}
}
test("Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan identidad y búsqueda", async () => {
const catalog = structuredClone(
buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none())
@ -147,13 +169,36 @@ test("Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
assert.ok(table);
for (const resource of resources) {
assert.equal(table.includes(resource.values.name), expected.includes(resource.values.name));
}
assert.ok(html.includes('data-detail-open="false"'));
assert.equal(html.includes('aria-label="Ficha del recurso"'), false);
assert.equal(html.includes('aria-label="Cerrar panel del recurso"'), false);
assertResourceRows(table, resources, expected, search);
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
assert.ok(html.includes(empty.values.name));
if (expected.length === 0) {
assert.ok(html.includes("No hay resultados para estos criterios."));
} else {
const selected = resources.find((resource) => resource.values.name === expected[0]);
assert.ok(selected);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { pathname: `/companies/${companyId}/resources/${selected.id}`, search },
},
});
const detailHtml = await render("es", <ResourcesPage companyId={companyId} />, {
resources,
catalog,
});
const detailTable = detailHtml.match(/<table[\s\S]*?<\/table>/)?.[0];
assert.ok(detailTable);
assert.ok(detailHtml.includes('data-detail-open="true"'));
assert.ok(detailHtml.includes('aria-label="Ficha del recurso"'));
assert.ok(detailHtml.includes('aria-label="Cerrar panel del recurso"'));
assert.equal(detailHtml.includes('role="dialog"'), false);
assertResourceRows(detailTable, resources, expected, search);
}
}
} finally {
@ -326,3 +371,154 @@ test("indicador de alta: conserva la plantilla elegida y explica el paso actual
}
}
});
function summaryResource() {
const fields = [
{ name: "Producto", type: "text", unit: "", value: " Microsoft 365 " },
{ name: "Vacío", type: "text", unit: "", value: " " },
{ name: "Cantidad", type: "number", unit: "plazas", value: 0 },
{ name: "Renovación", type: "boolean", unit: "", value: false },
{ name: "Compartido", type: "boolean", unit: "", value: true },
{ name: "Otro campo", type: "text", unit: "", value: "Quinto valor omitido" },
].map((field) => ({
...field,
fieldId: UniqueID.generateNewID().toPrimitive(),
required: false,
choices: [],
}));
const templateId = UniqueID.generateNewID().toPrimitive();
const values = {
name: "Suscripción de prueba",
templateId,
active: false,
notes: "Observaciones del recurso",
characteristics: Object.fromEntries(fields.map((field) => [field.fieldId, field.value])),
};
return ResourceSchema.parse({
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: {
templateId,
templateName: "Licencia personalizada",
fields: fields.map(({ value, ...field }) => field),
},
values,
history: [
{
id: UniqueID.generateNewID().toPrimitive(),
values,
actorId: companyId,
actorName: "Técnico de prueba",
recordedAt: "2026-10-14T10:00:00.000Z",
},
],
});
}
test("recursos: resumen de plantilla con unidades, booleanos traducidos, cero y límite de cuatro", async () => {
const resource = summaryResource();
for (const [language, yes] of [
["es", "Sí"],
["en", "Yes"],
]) {
assert.ok(language && yes);
const html = await render(language, <ResourcesPage companyId={companyId} />, {
resources: [resource],
catalog: { items: [] },
});
assert.ok(html.includes(`Microsoft 365 · 0 plazas · No · ${yes}`));
assert.equal(html.includes("Quinto valor omitido"), false);
assert.ok(html.includes('data-detail-open="false"'));
assert.equal(html.includes('aria-label="Cerrar panel del recurso"'), false);
}
const empty = { ...resource, values: { ...resource.values, characteristics: {} } };
const html = await render("es", <ResourcesPage companyId={companyId} />, {
resources: [empty],
catalog: { items: [] },
});
assert.ok(html.includes(empty.values.name));
assert.equal(html.includes("line-clamp-2"), false);
});
test("recursos: ficha lateral compacta traducida con cierre, acciones y contenido relacionado", async () => {
const resource = summaryResource();
for (const [language, panel, close, status] of [
["es", "Ficha del recurso", "Cerrar panel del recurso", "Inactivo"],
["en", "Resource details", "Close resource panel", "Inactive"],
]) {
assert.ok(language && panel && close && status);
const html = await render(
language,
<ResourceSelectionPanel
canEdit={false}
onClose={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
references={[]}
relatedContent={() => <p>Contenido relacionado de prueba</p>}
resource={resource}
/>
);
assert.ok(html.includes(`aria-label="${panel}"`));
assert.ok(html.includes(`aria-label="${close}"`));
assert.ok(html.includes(resource.specification.templateName));
assert.ok(html.includes(status));
assert.ok(html.includes("Observaciones del recurso"));
assert.ok(html.includes("Contenido relacionado de prueba"));
assert.ok(html.includes("Quinto valor omitido"));
assert.equal(html.includes("Técnico de prueba"), false);
assert.match(html, /<button[^>]*disabled=""[^>]*>/);
const detail = await render(language, <ResourceDetail resource={resource} />);
assert.ok(detail.includes("Técnico de prueba"));
}
});
test("recursos: inventario y panel usan el icono vigente de la plantilla sin cambiar la especificación", async () => {
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
const template = catalog.items.find((item) => item.builtinKey === "template:mailbox");
assert.ok(template?.values.kind === "template");
const source = summaryResource();
const resource = {
...source,
values: { ...source.values, templateId: template.id },
specification: { ...source.specification, templateId: template.id },
};
const list = await render("es", <ResourcesPage companyId={companyId} />, {
resources: [resource],
catalog,
});
assert.ok(list.includes("lucide-mail"));
const panel = await render(
"es",
<ResourceSelectionPanel
canEdit
onClose={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
references={[]}
relatedContent={undefined}
resource={resource}
template={template}
/>
);
assert.ok(panel.includes("lucide-mail"));
const withoutIcon = { ...template, values: { ...template.values, icon: undefined } };
const cleared = await render("es", <ResourcesPage companyId={companyId} />, {
resources: [resource],
catalog: { items: [withoutIcon] },
});
assert.equal(cleared.includes("lucide-mail"), false);
assert.equal(resource.specification.templateName, source.specification.templateName);
assert.deepEqual(resource.history, source.history);
});

View File

@ -1,7 +1,7 @@
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import { XIcon } from "../lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
/** Estructura común de las fichas laterales; el consumidor aporta datos, textos y acciones. */
export function SelectionPanel({
@ -28,7 +28,9 @@ export function SelectionPanel({
>
<header className="space-y-2">
<div className="flex items-start justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">{title}</h2>
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
{title}
</h2>
<Button aria-label={closeLabel} onClick={onClose} size="icon-sm" variant="ghost">
<XIcon aria-hidden="true" className="size-4" />
</Button>

View File

@ -12,7 +12,8 @@ export function characteristicsSummary(
if (value === undefined || (typeof value === "string" && !value.trim())) {
return [];
}
const text = typeof value === "boolean" ? (value ? labels.yes : labels.no) : String(value).trim();
const text =
typeof value === "boolean" ? (value ? labels.yes : labels.no) : String(value).trim();
return [`${text}${field.unit ? ` ${field.unit}` : ""}`];
})

View File

@ -11,6 +11,7 @@ export {
ClockIcon,
DatabaseIcon,
FileKeyIcon,
FolderIcon,
HardDriveIcon,
HeadphonesIcon,
HomeIcon,