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

View File

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

View File

@ -135,11 +135,26 @@ class WorkstationDashboardTests(unittest.TestCase):
self.capture("S08-compact-summary") self.capture("S08-compact-summary")
def test_03_history_edit_software_and_navigation(self): def test_03_history_edit_software_and_navigation(self):
for width, height in [(1440, 900), (390, 844), (320, 640)]:
self.tool("browser_resize", {"width": width, "height": height})
self.navigate() self.navigate()
self.code("await page.getByRole('button',{name:'Ver historial',exact:true}).click();await page.getByRole('dialog').waitFor();return true;") self.assertEqual(self.code("return await page.getByRole('tab').count();"), 2)
self.assertTrue(self.code("return await page.getByRole('dialog').getByText('Administración — puesto inicial',{exact:true}).isVisible();")) self.assertEqual(self.code("return await page.getByRole('tab',{name:'Información',exact:true}).getAttribute('aria-selected');"), "true")
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;") self.assertEqual(self.code("return await page.locator('main header').getByRole('button',{name:'Ver historial',exact:true}).count();"), 0)
self.assertEqual(self.code("return await page.evaluate(()=>document.activeElement?.textContent.trim());"),"Ver historial") 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.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.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;") self.code("await page.getByRole('button',{name:'Gestionar software',exact:true}).click();await page.getByRole('dialog').waitFor();return true;")