Añadir secciones de detalle y mejorar la presentación de activos y recursos

This commit is contained in:
David Arranz 2026-10-09 21:18:40 +02:00
parent 5a7960302e
commit d7eb91ee68
16 changed files with 259 additions and 76 deletions

View File

@ -301,3 +301,11 @@ El desplazamiento vertical se conserva cuando el contenido supera la altura disp
La pantalla `CompanyOverview` tiene composición independiente de `DashboardPage` (Inicio). Comparte tarjetas, bloques de trabajo/avisos/actividad y consultas existentes, siempre fijando el ámbito al UUID de la compañía. El encabezado «De un vistazo» precede las acciones rápidas (ticket, intervención, activo y recurso), cuatro indicadores operativos y una franja navegable de personas, departamentos y sedes. No muestra compañías activas ni el resumen por compañías. La variante compacta de `StatisticCard` mejora la jerarquía del valor y la etiqueta sin modificar la presentación de Inicio.
La navegación de compañía empieza por Resumen, Tickets e Intervenciones; después agrupa Personas, Puestos de trabajo y Perfiles de usuario y finalmente las demás secciones. Los grupos tienen separación visual. Las operaciones de usuario permanecen en el menú superior y dejan de repetirse al pie del menú de compañía.
## Fichas de consulta y resumen lateral
Las secciones de consulta evitan recuadros anidados y rellenos laterales: usan títulos compactos y un separador tenue entre grupos. Las agrupaciones de características siguen este mismo criterio en lectura; los formularios conservan su organización propia.
`DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto.
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.

View File

@ -303,5 +303,10 @@
"formLayout": {
"identity": "Identification and assignment",
"template": "Template details"
},
"detailSections": {
"identification": "Identification",
"assignment": "Location and assignment",
"notes": "Notes"
}
}

View File

@ -303,5 +303,10 @@
"formLayout": {
"identity": "Identificación y asignación",
"template": "Datos de la plantilla"
},
"detailSections": {
"identification": "Identificación",
"assignment": "Ubicación y asignación",
"notes": "Observaciones"
}
}

View File

@ -1,4 +1,5 @@
export type AssetRelationshipsProps = {
compact?: boolean;
companyId: string;
csrfToken: string;
assetId: string;

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -8,7 +9,7 @@ import { useAssetRelationshipsController } from "../controllers";
import type { AssetRelationshipsProps } from "../types";
export function AssetRelationships(props: AssetRelationshipsProps) {
const { companyId, csrfToken, assetId } = props;
const { companyId, csrfToken, assetId, compact = false } = props;
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
useAssetRelationshipsController(props);
const { t } = useTranslation("assets");
@ -20,7 +21,12 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
<li className="space-y-2 rounded-md border p-3 text-sm" key={link.id}>
<p className="font-medium">
{source ? link.values.name : link.values.inverseName} ·{" "}
{nameOf(source ? link.values.targetId : link.values.sourceId)}
<BrowserLink
className="cursor-pointer text-primary underline-offset-4 hover:underline"
href={`/companies/${companyId}/assets/${source ? link.values.targetId : link.values.sourceId}`}
>
{nameOf(source ? link.values.targetId : link.values.sourceId)}
</BrowserLink>
</p>
{link.values.notes && (
<p className="whitespace-pre-wrap break-words">{link.values.notes}</p>
@ -49,7 +55,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
return (
<section aria-label={t("relations")} className="space-y-3">
<header className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">{t("relations")}</h3>
{!compact && <h3 className="font-medium">{t("relations")}</h3>}
<Button onClick={() => setAdding(true)} size="sm" variant="outline">
{t("linkAsset")}
</Button>

View File

@ -1,3 +1,6 @@
import { BrowserLink } from "@metis/core/client";
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { useTranslation } from "react-i18next";
@ -22,49 +25,78 @@ export function AssetDetail(props: AssetDetailProps) {
? t(value ? "yes" : "no")
: String(value);
return (
<div className="space-y-5">
<div className="space-y-2">
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
<dl className={compact ? "grid gap-4" : "grid gap-3 sm:grid-cols-2"}>
<div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{asset.specification.templateName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
<dd>{t(asset.values.status)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("site")}</dt>
<dd>{nameOf(asset.values.siteId, lookups.sites)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("person")}</dt>
<dd>{nameOf(asset.values.personId, lookups.people)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt>
<dd>{workstations?.map((item) => item.values.name).join(" · ") || t("noWorkstation")}</dd>
</div>
</dl>
<FieldSections
items={asset.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="whitespace-pre-wrap break-words">
<DetailSection title={t("detailSections.identification")}>
<dl>
<DetailField label={t("template")}>
<BrowserLink href={`/companies/${asset.companyId}/catalogs/assets?tab=templates`}>
{asset.specification.templateName}
</BrowserLink>
</DetailField>
<DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
</dl>
</DetailSection>
<DetailSection title={t("detailSections.assignment")}>
<dl>
<DetailField label={t("site")}>
{asset.values.siteId ? (
<BrowserLink href={`/companies/${asset.companyId}/sites`}>
{nameOf(asset.values.siteId, lookups.sites)}
</BrowserLink>
) : (
t("noAssignment")
)}
</DetailField>
<DetailField label={t("person")}>
{asset.values.personId ? (
<BrowserLink href={`/companies/${asset.companyId}/people/${asset.values.personId}`}>
{nameOf(asset.values.personId, lookups.people)}
</BrowserLink>
) : (
t("noAssignment")
)}
</DetailField>
<DetailField label={t("workstationInAsset")}>
{workstations?.length
? workstations.map((item) => (
<BrowserLink
className="block"
href={`/companies/${asset.companyId}/workstations`}
key={item.id}
>
{item.values.name}
</BrowserLink>
))
: t("noWorkstation")}
</DetailField>
</dl>
</DetailSection>
<DetailSection title={t("characteristics")}>
<FieldSections
items={asset.specification.fields}
renderItem={(field) => (
<DetailField
key={field.fieldId}
label={
<>
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</>
}
>
{display(asset.values.characteristics[field.fieldId])}
</dd>
</div>
)}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
</DetailField>
)}
sections={asset.specification.sections}
ungroupedLabel={t("sections.ungrouped")}
values
/>
</DetailSection>
{asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
<DetailSection title={t("detailSections.notes")}>
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
</DetailSection>
)}
{children}
{!compact && (

View File

@ -1,3 +1,5 @@
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -36,31 +38,41 @@ export function AssetSelectionPanel({
return (
<aside
aria-label={t("assetPanel")}
className="hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-6 rounded-panel border border-border bg-muted p-5 [overflow-wrap:anywhere] inventory:grid"
className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
>
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="sm" variant="ghost">
×
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
<XIcon aria-hidden="true" className="size-4" />
</Button>
</header>
<AssetDetail asset={asset} compact lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
<AssetSupport
assetId={asset.id}
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</AssetDetail>
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline">
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">
{t("viewAsset")}
</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} variant="outline">
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
{t("edit")}
</Button>
</div>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>
<AssetRelationships
assetId={asset.id}
compact
companyId={companyId}
csrfToken={csrfToken}
/>
</DetailSection>
<DetailSection title={t("support.title")}>
<AssetSupport
assetId={asset.id}
compact
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</DetailSection>
</AssetDetail>
</aside>
);
}

View File

@ -47,7 +47,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
}
return (
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
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"
>

View File

@ -13,6 +13,7 @@ import { buildCatalogResponse } from "../../../api/application/asset-catalog-res
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
import type { RelationshipDTO } from "../../../common";
import { AssetSchema } from "../../../common/inventory";
import { AssetRelationships } from "../../asset-relationships";
import { catalogKeys } from "../../catalog/shared/api";
import { CatalogRelationForm } from "../../catalog-relations";
@ -20,6 +21,7 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list";
import { AssetForm } from "./ui";
@ -218,3 +220,80 @@ test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no
assert.ok(!nested.includes(`aria-label="${label}"`));
}
});
test("la ficha lateral agrupa información y ofrece acciones superiores y enlaces reales", async () => {
const fieldId = UniqueID.generateNewID().toPrimitive();
const personId = UniqueID.generateNewID().toPrimitive();
const siteId = UniqueID.generateNewID().toPrimitive();
const asset = AssetSchema.parse({
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: {
templateId: fieldId,
templateName: "Impresora",
fields: [
{ fieldId, name: "Número de serie", type: "text", unit: "", choices: [], required: false },
],
},
values: {
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
},
history: [
{
id: fieldId,
actorId: personId,
actorName: "Técnico",
recordedAt: "2026-10-09T10:00:00.000Z",
values: {
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
},
},
],
});
const html = await render(
"es",
<AssetSelectionPanel
asset={asset}
canEdit
companyId={companyId}
csrfToken="test"
lookups={{
...lookups,
people: [{ id: personId, name: "Ana", active: true }],
sites: [{ id: siteId, name: "Fábrica" }],
}}
onClose={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
open
/>
);
assert.ok(html.indexOf("Ficha e historial") < html.indexOf("Identificación"));
assert.ok(html.indexOf(">Editar<") < html.indexOf("Identificación"));
for (const group of [
"Identificación",
"Ubicación y asignación",
"Características",
"Observaciones",
]) {
assert.ok(html.includes(group));
}
assert.ok(html.includes("<summary"));
assert.ok(html.includes('open=""'));
assert.ok(html.includes(`href="/companies/${companyId}/people/${personId}"`));
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
assert.ok(html.includes("ABC123"));
});

View File

@ -1,4 +1,5 @@
export type AssetSupportProps = {
compact?: boolean;
companyId: string;
assetId: string;
csrfToken: string;

View File

@ -9,7 +9,7 @@ import { useAssetSupportController } from "../controllers";
import type { AssetSupportProps } from "../types";
export function AssetSupport(props: AssetSupportProps) {
const { companyId, assetId, csrfToken, retired = false } = props;
const { companyId, assetId, csrfToken, retired = false, compact = false } = props;
const {
query,
open,
@ -27,9 +27,9 @@ export function AssetSupport(props: AssetSupportProps) {
} = useAssetSupportController(props);
const { t } = useTranslation("assets");
return (
<section className="space-y-3 rounded-md border p-4">
<section className={compact ? "space-y-3" : "space-y-3 rounded-md border p-4"}>
<header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold">{t("support.title")}</h3>
{!compact && <h3 className="font-semibold">{t("support.title")}</h3>}
<div className="flex flex-wrap gap-2">
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
{t("support.tools")}

View File

@ -1,3 +1,4 @@
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { useTranslation } from "react-i18next";
@ -35,15 +36,17 @@ export function ResourceDetail({
<FieldSections
items={resource.specification.fields}
renderItem={(field) => (
<div key={field.fieldId}>
<dt className="text-sm text-muted-foreground">
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</dt>
<dd className="whitespace-pre-wrap break-words">
{display(resource.values.characteristics[field.fieldId])}
</dd>
</div>
<DetailField
key={field.fieldId}
label={
<>
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</>
}
>
{display(resource.values.characteristics[field.fieldId])}
</DetailField>
)}
sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from "react";
/** Etiqueta y valor responden al ancho de su ficha, no al ancho total del navegador. */
export function DetailField({ label, children }: { label: ReactNode; children: ReactNode }) {
return (
<div className="grid min-w-0 gap-1 border-b border-border/60 py-2.5 first:pt-0 last:border-0 last:pb-0 @min-[240px]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @min-[240px]:gap-3">
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm font-medium leading-5 [&_a]:cursor-pointer [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-ring">
{children}
</dd>
</div>
);
}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from "react";
/** Agrupación plegable accesible para fichas; comienza abierta sin ocultar información. */
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<details className="@container border-t border-border/60 first:border-t-0" open>
<summary className="cursor-pointer py-2 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-ring">
{title}
</summary>
<div className="space-y-3 pb-2 pt-1">{children}</div>
</details>
);
}

View File

@ -29,9 +29,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
}, [hasErrors]);
const content = (fields: readonly T[]) =>
values ? (
<dl className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3">
{fields.map(renderItem)}
</dl>
<dl className="@container">{fields.map(renderItem)}</dl>
) : (
<div className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3">
{fields.map(renderItem)}
@ -62,11 +60,17 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
return null;
}
return (
<details className="@container rounded-md border" key={section.id} open>
<summary className="cursor-pointer rounded-md px-4 py-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring">
<details
className={values ? "@container" : "@container rounded-md border"}
key={section.id}
open
>
<summary
className={`cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`}
>
{section.name}
</summary>
<div className="space-y-3 border-t p-4">
<div className={values ? "space-y-3 pt-1" : "space-y-3 border-t p-4"}>
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}

View File

@ -23,4 +23,5 @@ export {
UserRoundXIcon,
UsersIcon,
WrenchIcon,
XIcon,
} from "lucide-react";