feat: implementar pestañas de información e historial en la ficha del puesto de trabajo

This commit is contained in:
David Arranz 2026-10-25 10:10:42 +01:00
parent e7ecf1558e
commit 0ceeb9658c
6 changed files with 142 additions and 58 deletions

View File

@ -1518,3 +1518,12 @@ software y perfil. `ApplicationResourceUsage`, compuesto desde Companies, consul
a demanda las APIs operativas existentes de la compañía y conserva sus query
keys. Resources no depende de Companies. Véanse contratos, evidencia y límites en
[R2.6](architecture/resources-global-scope/17-r2-6-coexistencia-duplicados.md).
### Pestañas de información e historial del puesto
La ficha actual conserva sus paneles en Información e integra `WorkstationHistory`
en Historial, con `Tabs`, `TabsContent` y `ScrollableTabsList`, siguiendo el contrato
genérico descrito arriba. La cabecera común mantiene identidad y acciones de edición.
Información permanece montada para conservar las configuraciones abiertas; el acceso
«Ver historial completo» activa Historial y sitúa el foco en su pestaña. La composición
se detalla en [la implementación del dashboard](workplace-dashboard-implementacion.md#pestañas-de-la-ficha-del-puesto).

View File

@ -1001,3 +1001,29 @@ No se modificaron vendor ni los ocho documentos históricos de auditoría.
El preparador autenticado y sus servicios temporales se cerraron al terminar; el archivo
de sesión se retiró. Se conservaron únicamente las evidencias sanitizadas en la ruta
estable indicada. No se instalaron dependencias ni se cambió configuración global.
## Pestañas de la ficha del puesto
La ficha mantiene la identidad y las acciones del puesto en una cabecera común y
organiza el contenido mediante `Tabs` y `ScrollableTabsList`, siguiendo la ficha de activos:
- **Información** es la pestaña inicial y conserva los paneles actuales. Su contenido
permanece montado al alternar para mantener el contexto de las configuraciones abiertas.
- **Historial** integra `WorkstationHistory` con revisiones de más reciente a más antigua,
autor y valores de cada momento. No abre un diálogo desde esta ficha.
La cabecera ya no incluye el botón «Ver historial». «Ver historial completo», en el
resumen del último cambio, selecciona Historial y lleva el foco a su pestaña.
El diálogo de historial sigue disponible en el listado de puestos.
Validación de este cambio: typecheck de `@metis/companies`, Biome sobre el detalle,
ESLint sobre los tres ficheros TypeScript modificados y las 26 pruebas de
`workstation-detail.test.tsx`, correctos. La prueba MCP
`WorkstationDashboardTests.test_03_history_edit_software_and_navigation` pasó a
1440×900, 390×844 y 320×640: pestañas con flechas y Enter, foco desde el resumen,
conservación de configuración desplegada, ausencia de diálogo de historial,
sin desbordamiento horizontal del historial y regresión de edición/software/navegación.
Se inspeccionaron las seis capturas de ambas pestañas. Las fixtures desechables
contenían 15 compañías y, en la principal, 59 personas, 135 activos, 73 recursos
y 64 perfiles. No se repitieron las suites globales ni una auditoría completa
de estados de red o accesibilidad.

View File

@ -37,7 +37,7 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
enabled: Boolean(computer),
});
const configurationQueries = useWorkstationConfigurationQueries(company.id, Boolean(computer));
const [viewingHistory, setViewingHistory] = useState(false);
const [activeTab, setActiveTab] = useState<"information" | "history">("information");
const dashboard = workstation
? workstationDashboard(
company,
@ -83,8 +83,8 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
selectedProfile,
expandedProfile,
profileExpansion,
viewingHistory,
setViewingHistory,
activeTab,
setActiveTab,
pendingCount,
creatingProfile,
setCreatingProfile,

View File

@ -1,18 +1,14 @@
import {
WorkstationActions,
WorkstationEditorDialog,
WorkstationHistoryDialog,
WorkstationHistory,
WorkstationNameEditor,
} from "@metis/assets/client";
import { BrowserLink, returnToList, useBrowserSearchParams } from "@metis/core/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import {
Building2Icon,
ChevronLeftIcon,
ClockIcon,
MapPinIcon,
PersonIcon,
} from "@repo/rdx-ui/lib/icons";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { ChevronLeftIcon, ClockIcon, MapPinIcon } from "@repo/rdx-ui/lib/icons";
import {
Breadcrumb,
BreadcrumbItem,
@ -21,12 +17,13 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@repo/shadcn-ui/components/breadcrumb";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import type { ComponentProps, MouseEvent } from "react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { PersonAvatar } from "../../../people/shared";
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
@ -44,13 +41,13 @@ export function WorkstationDetailPage({
workstationId: string;
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
}) {
const { t } = useTranslation("assets");
const { t, i18n } = useTranslation("assets");
const c = useWorkstationDetailController(company, workstationId);
const editReturn = useRef<HTMLElement | null>(null);
const editFromEmpty = useRef(false);
const editSaved = useRef(false);
const headerEdit = useRef<HTMLButtonElement>(null);
const historyReturn = useRef<HTMLElement | null>(null);
const tabsRef = useRef<HTMLDivElement>(null);
const { workstation, inventory, dashboard } = c;
const root = `/companies/${company.id}`;
const { searchParams } = useBrowserSearchParams();
@ -73,9 +70,10 @@ export function WorkstationDetailPage({
};
const onHistory = () => {
historyReturn.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
c.setViewingHistory(true);
c.setActiveTab("history");
tabsRef.current
?.querySelector<HTMLButtonElement>('[role="tab"][data-value="history"]')
?.focus();
};
return (
@ -135,21 +133,12 @@ export function WorkstationDetailPage({
onDeleted={() => returnToList(backHref)}
onEdit={onEdit}
workstation={workstation}
>
<Button onClick={onHistory} variant="outline">
<ClockIcon aria-hidden="true" />
{t("workstationDashboard.viewHistory")}
</Button>
</WorkstationActions>
/>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground @min-[640px]:gap-x-5 @min-[640px]:gap-y-2">
<EntityStatus active={workstation.values.active}>
{t(workstation.values.active ? "active" : "inactive")}
</EntityStatus>
<BrowserLink className="inline-flex min-w-0 items-center gap-1.5" href={root}>
<Building2Icon aria-hidden="true" className="size-4 shrink-0" />
<span className="break-words">{company.name}</span>
</BrowserLink>
<span className="inline-flex min-w-0 items-center gap-1.5">
<MapPinIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("site")}: </span>
@ -163,15 +152,22 @@ export function WorkstationDetailPage({
)}
</span>
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<PersonIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
{responsible ? (
<>
<BrowserLink
className="min-w-0 break-words [overflow-wrap:anywhere]"
className="inline-flex min-w-0 items-center gap-1.5"
href={`${root}/people/${responsible.id}`}
>
{responsible.name}
<PersonAvatar
companyId={company.id}
name={responsible.name}
personId={responsible.id}
photoVersion={responsible.photoVersion}
/>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">
{responsible.name}
</span>
</BrowserLink>
<WorkstationResponsibleContext
company={company}
@ -185,30 +181,59 @@ export function WorkstationDetailPage({
</span>
</div>
</header>
<WorkstationDetailContent
company={company}
controller={c}
<Tabs
className="min-w-0 gap-3"
key={workstation.id}
onEdit={onEdit}
onHistory={onHistory}
/>
{c.viewingHistory && (
<WorkstationHistoryDialog
finalFocus={() => historyReturn.current ?? true}
members={(ids) =>
ids
.map(
(id) =>
inventory.data?.assets.find((asset) => asset.id === id)?.values.name ?? "—"
)
.join(", ") || "—"
onValueChange={(value) => {
if (value === "information" || value === "history") {
c.setActiveTab(value);
}
onClose={() => c.setViewingHistory(false)}
personName={(id) => company.people.find((person) => person.id === id)?.name ?? "—"}
siteName={(id) => company.sites.find((site) => site.id === id)?.name ?? "—"}
workstation={workstation}
}}
ref={tabsRef}
value={c.activeTab}
>
<ScrollableTabsList
items={[
{ value: "information", label: t("workstationDetail.information") },
{ value: "history", label: t("history") },
]}
label={t("workstationDetail.sections")}
locale={i18n.language}
value={c.activeTab}
/>
)}
<TabsContent className="min-w-0" keepMounted value="information">
<WorkstationDetailContent
company={company}
controller={c}
onEdit={onEdit}
onHistory={onHistory}
/>
</TabsContent>
<TabsContent className="min-w-0" value="history">
<DetailPanel
description={t("workstationDetail.historyHint")}
icon={<ClockIcon />}
title={t("workstationHistory")}
>
<WorkstationHistory
members={(ids) =>
ids
.map(
(id) =>
inventory.data?.assets.find((asset) => asset.id === id)?.values.name ??
"—"
)
.join(", ") || "—"
}
personName={(id) =>
company.people.find((person) => person.id === id)?.name ?? "—"
}
siteName={(id) => company.sites.find((site) => site.id === id)?.name ?? "—"}
workstation={workstation}
/>
</DetailPanel>
</TabsContent>
</Tabs>
{c.creatingProfile && (
<WorkProfileEditorDialog
company={company}

View File

@ -165,8 +165,13 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
.map(([button]) => button)
.filter((button) => button.includes('role="tab"'));
assert.equal(tabs.length, 0);
assert.ok(html.includes(labels.workstationDashboard.viewHistory));
assert.equal(tabs.length, 2);
assert.ok(tabs[0]?.includes(labels.workstationDetail.information));
assert.ok(tabs[0]?.includes('aria-selected="true"'));
assert.ok(tabs[1]?.includes(labels.history));
assert.ok(tabs[1]?.includes('aria-selected="false"'));
assert.equal(header.includes(labels.workstationDashboard.viewHistory), false);
assert.ok(html.includes(labels.workstationDashboard.fullHistory));
assert.ok(html.includes(labels.workstationDashboard.softwareTitle));
assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}</h2>`), false);
assert.ok(html.includes(labels.noWorkstationAssets));
@ -850,14 +855,18 @@ test("cabecera es/en: responsable largo o ausente, contexto independiente y seis
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
assert.equal(header.includes(`href="/companies/${companyId}"`), false);
assert.equal(header.includes(company.name), false);
assert.equal(html.includes(`${labels.workstationDashboard.responsibleContext}</h2>`), false);
assert.equal([...html.matchAll(/<h2\b/g)].length, 6);
if (personId) {
assert.ok(header.includes(longPerson.name));
assert.ok(header.includes('data-slot="avatar"'));
assert.ok(header.includes('aria-haspopup="dialog"'));
assert.ok(header.includes(`href="/companies/${companyId}/people/${personId}"`));
} else {
assert.ok(header.includes(labels.workstationDashboard.noResponsible));
assert.equal(header.includes('data-slot="avatar"'), false);
assert.equal(header.includes('aria-haspopup="dialog"'), false);
}
assert.ok(

View File

@ -135,11 +135,26 @@ class WorkstationDashboardTests(unittest.TestCase):
self.capture("S08-compact-summary")
def test_03_history_edit_software_and_navigation(self):
self.navigate()
self.code("await page.getByRole('button',{name:'Ver historial',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('dialog').getByText('Administración — puesto inicial',{exact:true}).isVisible();"))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.assertEqual(self.code("return await page.evaluate(()=>document.activeElement?.textContent.trim());"),"Ver historial")
for width, height in [(1440, 900), (390, 844), (320, 640)]:
self.tool("browser_resize", {"width": width, "height": height})
self.navigate()
self.assertEqual(self.code("return await page.getByRole('tab').count();"), 2)
self.assertEqual(self.code("return await page.getByRole('tab',{name:'Información',exact:true}).getAttribute('aria-selected');"), "true")
self.assertEqual(self.code("return await page.locator('main header').getByRole('button',{name:'Ver historial',exact:true}).count();"), 0)
if not self.code("return await page.locator('#workstation-configurations').isVisible();"):
self.expand_primary()
self.capture(f"information-tabs-{width}")
self.code("await page.getByRole('tab',{name:'Información',exact:true}).focus();await page.keyboard.press('ArrowRight');await page.keyboard.press('Enter');await page.getByRole('tabpanel',{name:'Historial',exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.getByRole('tabpanel',{name:'Historial',exact:true}).getByText('Administración — puesto inicial',{exact:true}).isVisible();"))
self.assertEqual(self.code("return await page.getByRole('dialog').count();"), 0)
self.capture(f"history-tabs-{width}")
self.assertLessEqual(self.code("return await page.evaluate(()=>document.documentElement.scrollWidth);"), width)
self.code("await page.keyboard.press('ArrowLeft');await page.keyboard.press('Enter');await page.getByRole('tabpanel',{name:'Información',exact:true}).waitFor();return true;")
self.assertTrue(self.code("return await page.locator('#workstation-configurations').isVisible();"))
self.code("await page.getByRole('button',{name:'Ver historial completo',exact:true}).click();await page.getByRole('tabpanel',{name:'Historial',exact:true}).waitFor();return true;")
self.assertEqual(self.code("return await page.evaluate(()=>document.activeElement?.textContent.trim());"), "Historial")
self.code("await page.getByRole('tab',{name:'Información',exact:true}).click();return true;")
self.tool("browser_resize", {"width": 1440, "height": 900})
self.code("await page.getByRole('button',{name:'Editar puesto',exact:true}).first().click();await page.getByRole('dialog').waitFor();return true;")
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.code("await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")