feat: mejorar la interacción y visualización del inventario de activos con resúmenes de características

This commit is contained in:
David Arranz 2026-10-15 12:12:53 +02:00
parent cba0ed964c
commit 0f1ad8511a
13 changed files with 278 additions and 57 deletions

View File

@ -439,6 +439,19 @@ El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y
`AssetList`, en assets/shared/ui, concentra las pestañas por UUID de plantilla (incluidas vacías/inactivas y especificaciones históricas), búsqueda rápida por nombre/plantilla/estado/sede y las columnas Nombre, Plantilla, Estado y Sede. Tanto inventario como selector lo componen con `EntityTable`. El inventario aporta enlaces, acciones y resaltado; el selector aporta checkboxes o radios de shadcn/ui en la columna fija de acciones, accesible también al desplazar horizontalmente la tabla. Se reutilizan ordenación, columnas configurables, rangos y paginación existentes. Este componente conoce activos y permanece en su módulo; `EntityTable`, `FormDialog`, `FormBody`, `FormActions` y `ButtonGroup` siguen siendo los patrones genéricos independientes del dominio.
El inventario no selecciona ningún activo al entrar en el listado. Pulsar el nombre o una zona
sin controles de la fila abre su ficha lateral (en móvil, el diálogo existente), conservando
la pestaña y la búsqueda en la URL. El nombre mantiene su enlace para navegación por teclado;
los menús y demás controles conservan su acción propia. Una URL de activo concreto abre su ficha.
`EntityTable.onRowClick` permite esta interacción desde el consumidor sin conocer rutas o entidades.
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
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.
## Componentes archivados

View File

@ -1,5 +1,5 @@
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -43,41 +43,24 @@ export function AssetSelectionPanel({
}
return (
<aside
aria-label={t("assetPanel")}
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"
<SelectionPanel
label={t("assetPanel")}
closeLabel={t("closeAssetPanel")}
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>
</>}
>
<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">
<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>
</div>
<AssetPanelSummary asset={asset} lookups={lookups} />
</header>
<div className="flex flex-wrap gap-2">
<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>
</div>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>
<AssetRelationships
@ -96,6 +79,6 @@ export function AssetSelectionPanel({
/>
</DetailSection>
</AssetDetail>
</aside>
</SelectionPanel>
);
}

View File

@ -1,4 +1,10 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
import {
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
useListQuery,
} from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
@ -8,7 +14,7 @@ import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types";
import { useCompactInventory } from "./use-compact-inventory";
export function useAssetInventoryPageController({
companyId,
@ -65,19 +71,38 @@ export function useAssetInventoryPageController({
setDeleteId(undefined);
},
});
const compact = useCompactInventory();
const compact = useCompactDetail();
const [localViewId, setLocalViewId] = useState<string | undefined>();
const {
selectedId,
selectEntity: setSelectedId,
entityHref,
entityHref: baseEntityHref,
routed,
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
const [detailOpen, setDetailOpen] = useState(true);
const viewId = compact && routed ? selectedId : localViewId;
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
const [detailOpen, setDetailOpen] = useState(true);
const entityHref = (id: string) => {
const search = searchParams.toString();
return `${baseEntityHref(id)}${search ? `?${search}` : ""}`;
};
const selectAsset = (id: string) => {
setDetailOpen(true);
if (routed) {
navigateTo(entityHref(id));
} else {
setSelectedId(id);
if (compact) {
setLocalViewId(id);
}
}
};
const assets = query.data?.assets ?? [];
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0];
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : undefined;
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const deleted = assets.find((asset) => asset.id === deleteId);
@ -85,6 +110,7 @@ export function useAssetInventoryPageController({
return {
query,
entityHref,
selectAsset,
missingView: Boolean(selectedId && query.isSuccess && !selected),
catalog,
templates,

View File

@ -46,6 +46,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
viewed,
missingView,
entityHref,
selectAsset,
} = useAssetInventoryPageController(props);
const { t, i18n } = useTranslation("assets");
@ -65,7 +66,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
<>
{" "}
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && (
{!detailOpen && selected && (
<Button
className="hidden inventory:inline-flex"
onClick={() => setDetailOpen(true)}
@ -131,12 +132,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
},
]}
onClearFilters={clearFilters}
onRowClick={(asset) => selectAsset(asset.id)}
onSearchChange={setSearch}
onTemplateChange={setTemplateId}
renderName={(asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
className="cursor-pointer text-left font-medium wrap-anywhere app-link text-primary hover:text-primary focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)}
>

View File

@ -0,0 +1,11 @@
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import type { AssetDTO } from "../../../../common/inventory";
/** La instantánea de cada plantilla determina los campos y su orden, también para tipos personalizados. */
export function assetCharacteristicsSummary(
asset: AssetDTO,
labels: { yes: string; no: string }
): string {
return characteristicsSummary(asset.specification.fields, asset.values.characteristics, labels);
}

View File

@ -30,6 +30,7 @@ import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list";
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
import { AssetForm } from "./ui";
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
@ -177,8 +178,19 @@ test("Las pestañas incluyen plantillas vacías e inactivas y combinan identidad
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
assert.ok(table);
assert.ok(html.includes('data-detail-open="false"'));
assert.equal(html.includes('aria-label="Ficha del activo"'), false);
assert.equal(html.includes("Mostrar panel"), false);
for (const asset of assets) {
assert.equal(table.includes(asset.values.name), expected.includes(asset.values.name));
if (expected.includes(asset.values.name)) {
assert.ok(
table.includes(
`href="/companies/${companyId}/assets/${asset.id}${search.replaceAll("&", "&amp;")}"`
)
);
assert.ok(table.includes("group/entity-row cursor-pointer"));
}
}
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
assert.ok(html.includes(empty.values.name));
@ -498,3 +510,76 @@ test("texto largo: área editable con ayuda accesible", async () => {
assert.match(markup, /24 RJ45\n4 SFP\+/);
assert.match(markup, /Puertos y velocidades admitidas/);
});
test("el resumen usa los campos propios de la plantilla, omite vacíos y texto largo y limita a cuatro", async () => {
const fields = [
{ name: "Modelo", type: "text", unit: "", value: " ThinkCentre " },
{ name: "Sin valor", type: "text", unit: "", value: " " },
{ name: "Notas largas", type: "multiline", unit: "", value: "Texto que no debe mostrarse" },
{ name: "Memoria", type: "number", unit: "GB", value: 0 },
{ name: "Dúplex", type: "boolean", unit: "", value: false },
{ name: "Color", type: "boolean", unit: "", value: true },
{ name: "Quinto", type: "text", unit: "", value: "Omitido por límite" },
].map((field) => ({
...field,
fieldId: UniqueID.generateNewID().toPrimitive(),
required: false,
choices: [],
}));
const templateId = UniqueID.generateNewID().toPrimitive();
const values = {
name: "Equipo de prueba",
templateId,
siteId: null,
status: "operational",
notes: "",
characteristics: Object.fromEntries(fields.map((field) => [field.fieldId, field.value])),
};
const asset = AssetSchema.parse({
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: {
templateId,
templateName: "Plantilla personalizada",
fields: fields.map(({ value, ...field }) => field),
},
values,
displayStatus: "available",
assigned: false,
history: [
{
id: UniqueID.generateNewID().toPrimitive(),
values,
actorId: companyId,
actorName: "Técnico",
recordedAt: "2026-10-14T10:00:00.000Z",
},
],
});
assert.equal(
assetCharacteristicsSummary(asset, { yes: "Sí", no: "No" }),
"Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí"
);
assert.equal(
assetCharacteristicsSummary(asset, { yes: "Yes", no: "No" }),
"Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Yes"
);
const html = await render(
"es",
<AssetInventoryPage companyId={companyId} lookups={lookups} />,
[],
[],
{ assets: [asset], catalog: { items: [] } }
);
assert.ok(html.includes("Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí"));
assert.equal(html.includes("Texto que no debe mostrarse"), false);
assert.equal(
assetCharacteristicsSummary(
{ ...asset, values: { ...asset.values, characteristics: {} } },
{ yes: "Sí", no: "No" }
),
""
);
});

View File

@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
export function AssetList({
@ -21,6 +22,7 @@ export function AssetList({
renderName,
extraColumns = [],
rowClassName = () => "",
onRowClick,
}: {
assets: readonly AssetDTO[];
catalog: readonly CatalogItemDTO[];
@ -33,6 +35,7 @@ export function AssetList({
renderName?: ((asset: AssetDTO) => ReactNode) | undefined;
extraColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
rowClassName?: ((asset: AssetDTO) => string) | undefined;
onRowClick?: ((asset: AssetDTO) => void) | undefined;
}) {
const { t, i18n } = useTranslation("assets");
const templates = new Map(
@ -83,12 +86,24 @@ export function AssetList({
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) =>
renderName ? (
renderName(asset)
) : (
<span className="font-medium">{asset.values.name}</span>
),
render: (asset) => {
const summary = assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") });
return (
<div className="space-y-1">
{renderName ? (
renderName(asset)
) : (
<span className="font-medium">{asset.values.name}</span>
)}
{summary && (
<p className="line-clamp-2 text-xs text-muted-foreground" title={summary}>
{summary}
</p>
)}
</div>
);
},
},
{
id: "template",
@ -126,6 +141,7 @@ export function AssetList({
items={items}
locale={i18n.language}
onClearFilters={onClearFilters}
onRowClick={onRowClick}
onSearchChange={onSearchChange}
rowClassName={rowClassName}
search={search}

View File

@ -63,6 +63,7 @@ export function EntityTable<T extends { id: string }>({
unavailable = false,
emptyMessage,
rowClassName,
onRowClick,
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
@ -80,6 +81,7 @@ export function EntityTable<T extends { id: string }>({
unavailable?: boolean;
emptyMessage?: string;
rowClassName?: (item: T) => string;
onRowClick?: ((item: T) => void) | undefined;
}) {
const id = useId();
const labels = entityListLabels(locale);
@ -147,6 +149,7 @@ export function EntityTable<T extends { id: string }>({
columns={columns}
loading={loading}
message={message}
onRowClick={onRowClick}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
rowClassName={rowClassName}
table={table}

View File

@ -18,6 +18,7 @@ export function EntityTableContent<T extends { id: string }>({
unavailable,
message,
rowClassName,
onRowClick,
onSort,
}: {
table: TableModel<T>;
@ -27,6 +28,7 @@ export function EntityTableContent<T extends { id: string }>({
unavailable: boolean;
message: string;
rowClassName: ((item: T) => string) | undefined;
onRowClick: ((item: T) => void) | undefined;
onSort: () => void;
}) {
const visibleColumns = [
@ -88,8 +90,25 @@ export function EntityTableContent<T extends { id: string }>({
) : (
table.getRowModel().rows.map((row) => (
<TableRow
className={`group/entity-row ${rowClassName?.(row.original) ?? ""}`}
className={`group/entity-row ${onRowClick ? "cursor-pointer" : ""} ${rowClassName?.(row.original) ?? ""}`}
key={row.id}
onClick={
onRowClick
? (event) => {
// Los controles de la fila conservan su interacción propia.
if (
event.defaultPrevented ||
!(event.target instanceof Element) ||
event.target.closest(
'a, button, input, select, textarea, label, [role="button"], [role="menuitem"], [role="checkbox"], [role="radio"]'
)
) {
return;
}
onRowClick(row.original);
}
: undefined
}
>
{visibleColumns.map((column) => (
<TableCell
@ -119,7 +138,7 @@ function entityColumnClass(pinned: boolean, header: boolean) {
if (pinned) {
return header
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))]";
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] ";
}
return header

View File

@ -0,0 +1,42 @@
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({
label,
title,
closeLabel,
onClose,
summary,
actions,
children,
}: {
label: string;
title: ReactNode;
closeLabel: string;
onClose: () => void;
summary?: ReactNode;
actions: ReactNode;
children: ReactNode;
}) {
return (
<aside
aria-label={label}
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="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>
<Button aria-label={closeLabel} onClick={onClose} size="icon-sm" variant="ghost">
<XIcon aria-hidden="true" className="size-4" />
</Button>
</div>
{summary}
</header>
<div className="flex flex-wrap gap-2">{actions}</div>
{children}
</aside>
);
}

View File

@ -1,19 +1,19 @@
import { useSyncExternalStore } from "react";
const COMPACT_INVENTORY = "(max-width: 1099px)";
const COMPACT_DETAIL = "(max-width: 1099px)";
function subscribe(listener: () => void) {
const media = window.matchMedia(COMPACT_INVENTORY);
const media = window.matchMedia(COMPACT_DETAIL);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
export function useCompactInventory() {
export function useCompactDetail() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(COMPACT_INVENTORY).matches,
() => window.matchMedia(COMPACT_DETAIL).matches,
() => false
);
}

View File

@ -0,0 +1,21 @@
/** Resume valores en el orden de los campos sin conocer entidades ni catálogos. */
export function characteristicsSummary(
fields: readonly { fieldId: string; type: string; unit: string }[],
values: Readonly<Record<string, string | number | boolean>>,
labels: { yes: string; no: string }
): string {
return fields
.filter((field) => field.type !== "multiline")
.flatMap((field) => {
const value = values[field.fieldId];
if (value === undefined || (typeof value === "string" && !value.trim())) {
return [];
}
const text = typeof value === "boolean" ? (value ? labels.yes : labels.no) : String(value).trim();
return [`${text}${field.unit ? ` ${field.unit}` : ""}`];
})
.slice(0, 4)
.join(" · ");
}

View File

@ -54,7 +54,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/5 data-[state=selected]:bg-muted",
className
)}
{...props}