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.
|
||||
|
||||
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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<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">
|
||||
<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}
|
||||
</h2>
|
||||
<Button
|
||||
aria-label={t("closeAssetPanel")}
|
||||
onClick={onClose}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
</>}
|
||||
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>
|
||||
</>}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
@ -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 { 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("&", "&")}"`
|
||||
)
|
||||
);
|
||||
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" }
|
||||
),
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 ? (
|
||||
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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
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";
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
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
|
||||
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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user