feat: mejorar la interacción y visualización del inventario de activos con resúmenes de características
This commit is contained in:
parent
cba0ed964c
commit
0f1ad8511a
@ -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.
|
`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.
|
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
|
## Componentes archivados
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -43,41 +43,24 @@ export function AssetSelectionPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<SelectionPanel
|
||||||
aria-label={t("assetPanel")}
|
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"
|
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}>
|
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||||
<DetailSection title={t("relations")}>
|
<DetailSection title={t("relations")}>
|
||||||
<AssetRelationships
|
<AssetRelationships
|
||||||
@ -96,6 +79,6 @@ export function AssetSelectionPanel({
|
|||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</AssetDetail>
|
</AssetDetail>
|
||||||
</aside>
|
</SelectionPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@ -8,7 +14,7 @@ import { removeAsset } from "../../shared/api";
|
|||||||
import { getAssetInventory } from "../api";
|
import { getAssetInventory } from "../api";
|
||||||
import type { AssetInventoryPageProps } from "../types";
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
import { useCompactInventory } from "./use-compact-inventory";
|
|
||||||
|
|
||||||
export function useAssetInventoryPageController({
|
export function useAssetInventoryPageController({
|
||||||
companyId,
|
companyId,
|
||||||
@ -65,19 +71,38 @@ export function useAssetInventoryPageController({
|
|||||||
setDeleteId(undefined);
|
setDeleteId(undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const compact = useCompactInventory();
|
const compact = useCompactDetail();
|
||||||
const [localViewId, setLocalViewId] = useState<string | undefined>();
|
const [localViewId, setLocalViewId] = useState<string | undefined>();
|
||||||
const {
|
const {
|
||||||
selectedId,
|
selectedId,
|
||||||
selectEntity: setSelectedId,
|
selectEntity: setSelectedId,
|
||||||
entityHref,
|
entityHref: baseEntityHref,
|
||||||
routed,
|
routed,
|
||||||
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
|
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(true);
|
||||||
const viewId = compact && routed ? selectedId : localViewId;
|
const viewId = compact && routed ? selectedId : localViewId;
|
||||||
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
|
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 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 edited = assets.find((asset) => asset.id === editor);
|
||||||
const viewed = assets.find((asset) => asset.id === viewId);
|
const viewed = assets.find((asset) => asset.id === viewId);
|
||||||
const deleted = assets.find((asset) => asset.id === deleteId);
|
const deleted = assets.find((asset) => asset.id === deleteId);
|
||||||
@ -85,6 +110,7 @@ export function useAssetInventoryPageController({
|
|||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
entityHref,
|
entityHref,
|
||||||
|
selectAsset,
|
||||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||||
catalog,
|
catalog,
|
||||||
templates,
|
templates,
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
viewed,
|
viewed,
|
||||||
missingView,
|
missingView,
|
||||||
entityHref,
|
entityHref,
|
||||||
|
selectAsset,
|
||||||
} = useAssetInventoryPageController(props);
|
} = useAssetInventoryPageController(props);
|
||||||
const { t, i18n } = useTranslation("assets");
|
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">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{!detailOpen && (
|
{!detailOpen && selected && (
|
||||||
<Button
|
<Button
|
||||||
className="hidden inventory:inline-flex"
|
className="hidden inventory:inline-flex"
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
@ -131,12 +132,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
|
onRowClick={(asset) => selectAsset(asset.id)}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
onTemplateChange={setTemplateId}
|
onTemplateChange={setTemplateId}
|
||||||
renderName={(asset) => (
|
renderName={(asset) => (
|
||||||
<BrowserLink
|
<BrowserLink
|
||||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
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)}
|
href={entityHref(asset.id)}
|
||||||
onClick={() => setDetailOpen(true)}
|
onClick={() => setDetailOpen(true)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ import { WorkstationForm, WorkstationsPage } from "../../workstations";
|
|||||||
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
|
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
|
||||||
import { AssetInventoryPage } from "../list";
|
import { AssetInventoryPage } from "../list";
|
||||||
|
|
||||||
|
import { assetCharacteristicsSummary } from "./helpers/asset-characteristics-summary";
|
||||||
import { AssetForm } from "./ui";
|
import { AssetForm } from "./ui";
|
||||||
import { AssetCharacteristicControl } from "./ui/asset-characteristic-control";
|
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];
|
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||||
|
|
||||||
assert.ok(table);
|
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) {
|
for (const asset of assets) {
|
||||||
assert.equal(table.includes(asset.values.name), expected.includes(asset.values.name));
|
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("&", "&")}"`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.ok(table.includes("group/entity-row cursor-pointer"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
|
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
|
||||||
assert.ok(html.includes(empty.values.name));
|
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, /24 RJ45\n4 SFP\+/);
|
||||||
assert.match(markup, /Puertos y velocidades admitidas/);
|
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" }
|
||||||
|
),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { CatalogItemDTO } from "../../../../common";
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
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. */
|
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
|
||||||
export function AssetList({
|
export function AssetList({
|
||||||
@ -21,6 +22,7 @@ export function AssetList({
|
|||||||
renderName,
|
renderName,
|
||||||
extraColumns = [],
|
extraColumns = [],
|
||||||
rowClassName = () => "",
|
rowClassName = () => "",
|
||||||
|
onRowClick,
|
||||||
}: {
|
}: {
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
catalog: readonly CatalogItemDTO[];
|
catalog: readonly CatalogItemDTO[];
|
||||||
@ -33,6 +35,7 @@ export function AssetList({
|
|||||||
renderName?: ((asset: AssetDTO) => ReactNode) | undefined;
|
renderName?: ((asset: AssetDTO) => ReactNode) | undefined;
|
||||||
extraColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
|
extraColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
|
||||||
rowClassName?: ((asset: AssetDTO) => string) | undefined;
|
rowClassName?: ((asset: AssetDTO) => string) | undefined;
|
||||||
|
onRowClick?: ((asset: AssetDTO) => void) | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const templates = new Map(
|
const templates = new Map(
|
||||||
@ -83,12 +86,24 @@ export function AssetList({
|
|||||||
label: t("name"),
|
label: t("name"),
|
||||||
value: (asset) => asset.values.name,
|
value: (asset) => asset.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (asset) =>
|
render: (asset) => {
|
||||||
renderName ? (
|
const summary = assetCharacteristicsSummary(asset, { yes: t("yes"), no: t("no") });
|
||||||
renderName(asset)
|
|
||||||
) : (
|
return (
|
||||||
<span className="font-medium">{asset.values.name}</span>
|
<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",
|
id: "template",
|
||||||
@ -126,6 +141,7 @@ export function AssetList({
|
|||||||
items={items}
|
items={items}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
onClearFilters={onClearFilters}
|
onClearFilters={onClearFilters}
|
||||||
|
onRowClick={onRowClick}
|
||||||
onSearchChange={onSearchChange}
|
onSearchChange={onSearchChange}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
search={search}
|
search={search}
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
unavailable = false,
|
unavailable = false,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
|
onRowClick,
|
||||||
}: {
|
}: {
|
||||||
items: readonly T[];
|
items: readonly T[];
|
||||||
columns: readonly EntityColumn<T>[];
|
columns: readonly EntityColumn<T>[];
|
||||||
@ -80,6 +81,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
unavailable?: boolean;
|
unavailable?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
rowClassName?: (item: T) => string;
|
rowClassName?: (item: T) => string;
|
||||||
|
onRowClick?: ((item: T) => void) | undefined;
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const labels = entityListLabels(locale);
|
const labels = entityListLabels(locale);
|
||||||
@ -147,6 +149,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
message={message}
|
message={message}
|
||||||
|
onRowClick={onRowClick}
|
||||||
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
table={table}
|
table={table}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
unavailable,
|
unavailable,
|
||||||
message,
|
message,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
|
onRowClick,
|
||||||
onSort,
|
onSort,
|
||||||
}: {
|
}: {
|
||||||
table: TableModel<T>;
|
table: TableModel<T>;
|
||||||
@ -27,6 +28,7 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
unavailable: boolean;
|
unavailable: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
rowClassName: ((item: T) => string) | undefined;
|
rowClassName: ((item: T) => string) | undefined;
|
||||||
|
onRowClick: ((item: T) => void) | undefined;
|
||||||
onSort: () => void;
|
onSort: () => void;
|
||||||
}) {
|
}) {
|
||||||
const visibleColumns = [
|
const visibleColumns = [
|
||||||
@ -88,8 +90,25 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
className={`group/entity-row ${rowClassName?.(row.original) ?? ""}`}
|
className={`group/entity-row ${onRowClick ? "cursor-pointer" : ""} ${rowClassName?.(row.original) ?? ""}`}
|
||||||
key={row.id}
|
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) => (
|
{visibleColumns.map((column) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
@ -119,7 +138,7 @@ function entityColumnClass(pinned: boolean, header: boolean) {
|
|||||||
if (pinned) {
|
if (pinned) {
|
||||||
return header
|
return header
|
||||||
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
|
? "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
|
return header
|
||||||
|
|||||||
42
packages/rdx-ui/src/components/selection-panel.tsx
Normal file
42
packages/rdx-ui/src/components/selection-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,19 +1,19 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
const COMPACT_INVENTORY = "(max-width: 1099px)";
|
const COMPACT_DETAIL = "(max-width: 1099px)";
|
||||||
|
|
||||||
function subscribe(listener: () => void) {
|
function subscribe(listener: () => void) {
|
||||||
const media = window.matchMedia(COMPACT_INVENTORY);
|
const media = window.matchMedia(COMPACT_DETAIL);
|
||||||
|
|
||||||
media.addEventListener("change", listener);
|
media.addEventListener("change", listener);
|
||||||
|
|
||||||
return () => media.removeEventListener("change", listener);
|
return () => media.removeEventListener("change", listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompactInventory() {
|
export function useCompactDetail() {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
subscribe,
|
subscribe,
|
||||||
() => window.matchMedia(COMPACT_INVENTORY).matches,
|
() => window.matchMedia(COMPACT_DETAIL).matches,
|
||||||
() => false
|
() => false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
21
packages/rdx-ui/src/lib/characteristics-summary.ts
Normal file
21
packages/rdx-ui/src/lib/characteristics-summary.ts
Normal 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(" · ");
|
||||||
|
}
|
||||||
@ -54,7 +54,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user