feat(workstations): implement workstation renaming functionality and UI components

- Added API for renaming workstations with validation and error handling.
- Created tests for the rename workstation API to ensure proper functionality.
- Developed UI components for editing workstation names, including form handling and feedback.
- Introduced new detail panels for displaying workstation assets, people, and profiles.
- Enhanced existing tests for workstation detail page to cover new features and edge cases.
- Updated icon imports to include a pencil icon for editing actions.
This commit is contained in:
David Arranz 2026-10-16 17:28:43 +02:00
parent 53a58ee0de
commit df44fdceb4
20 changed files with 1271 additions and 296 deletions

View File

@ -814,3 +814,61 @@ Una autorización pendiente se explica tanto en su paso como en el resumen, con
para registrarla cuando está permitida. Guardar solo se ofrece al final del alta. La edición
conserva las excepciones de dominio para modificar nombre/notas o archivar perfiles con
referencias que requieren revisión; el servidor mantiene la validación definitiva.
## Paneles de consulta y ficha del puesto
`DetailPanel`, en `@repo/rdx-ui/components/detail-panel`, compone las primitivas `Card`,
`CardHeader` y `CardContent` con una cabecera uniforme: título accesible `h2` enlazado
mediante un identificador único, icono decorativo, descripción opcional, contador y
acciones opcionales. Recibe `title`, `icon`, `children`, `description`, `count` y `actions`;
no conoce entidades, consultas ni traducciones. El contador conserva cero y se omite
si el consumidor todavía no conoce el resultado. El contenido responde al ancho de su
propio contenedor. No incorpora scroll, acciones ni estado de apertura por su cuenta.
La ficha del puesto reutiliza el panel en activos, personas y perfiles. Se organiza en
Resumen, Perfiles y Software mediante `Tabs` y `ScrollableTabsList`. El resumen destaca
el ordenador principal y presenta el resto como tarjetas de periféricos sin duplicarlo.
Reutiliza `AssetSummaryCard`, que acepta contenido opcional para `name` y permite enlazar
el nombre desde el consumidor sin depender de una librería de rutas. Las tarjetas
mantienen plantilla, icono, características y estado; la ficha completa del activo sigue
accesible desde el nombre. Las listas usan `ul`/`li` y una o dos columnas según su ancho.
El panel lateral presenta las personas con avatar y su relación; el responsable aparece
primero y cada persona se muestra una sola vez. Los perfiles pertenecen exclusivamente
al ordenador principal, incluidos los archivados que ya se mostraban en la ficha.
Su pestaña reutiliza `EntityTable` con búsqueda por nombre o persona, ordenación,
paginación, estados y avisos de revisión; omite repetir el mismo dispositivo por fila.
La tabla conserva su estado al cambiar a otra pestaña. El software se monta al abrir
su pestaña y permanece montado después, conservando sus filtros al cambiar de sección.
Mantiene el panel de gestión existente, con sus acciones y restricciones.
La cabecera presenta estado, sede y responsable bajo el nombre editable, sustituyendo
el subtítulo genérico. Editar puesto aparece a la derecha junto a `EntityRowActions`,
que agrupa desactivación y eliminación con sus confirmaciones. Nuevo perfil se muestra
en las acciones del panel Perfiles. El alta se deshabilita con explicación si el puesto no está activo, no tiene
ordenador principal en su composición o el ordenador está retirado. Los datos de
inventario, perfiles y catálogo se actualizan mediante `useListQuery`; fallar o cargar
no se presenta como ausencia de registros. El catálogo aporta el icono actual de la
plantilla y su ausencia conserva la alternativa visual habitual.
## Nombre del puesto editable en la ficha
`WorkstationNameEditor`, exportado por `@metis/assets/client`, vive en
`workstations/rename/ui`. Encapsula el título `h1` y su edición inline: botón de lápiz,
input etiquetado, Guardar/Cancelar, Enter/Escape y gestión del foco. El editor es propio
de puestos; no se introduce un editor genérico sin otros consumidores. La ficha de
compañía lo compone sin conocer la llamada HTTP.
La validación y el máximo del input se obtienen de `WorkstationInputSchema.shape.name`,
normalizando espacios exteriores. El borrador solo se inicia desde la acción de edición,
sin efectos que lo sincronicen con las consultas. El efecto existente gestiona únicamente
el foco del DOM. La mutación conserva el error en contexto, impide envíos repetidos y
cierra la edición cuando el servidor confirma el resultado. Cancelar no escribe datos.
`renameWorkstation` relee el inventario y reutiliza `saveWorkstation` con los valores
vigentes y el nuevo nombre. No añade endpoints ni cambia las reglas del agregado o su
historial. Si el nombre ya coincide, evita una escritura. La respuesta actualiza la caché
de inventario y la invalidación posterior consulta el estado definitivo. Se mantiene el
tratamiento habitual de CSRF y de errores de inventario.

View File

@ -49,6 +49,42 @@ La ficha reúne únicamente perfiles del ordenador principal, incluidos los arch
La ficha consulta también el [software del ordenador](software-del-ordenador.md), incluido el sistema operativo actual, por `computerAssetId`. Las instalaciones permanecen vinculadas al activo al cambiar el ordenador principal.
## Presentación de la ficha
El título de la ficha permite cambiar el nombre mediante un botón de lápiz, sin abrir
el formulario completo. La edición ofrece Guardar y Cancelar; Enter guarda y Escape
cancela. El campo recibe el foco y lo devuelve al botón al terminar. El borrador se
conserva si se actualizan los datos en segundo plano. Mientras guarda, se bloquean el
campo y sus acciones; los errores permanecen en el editor y solo se cierra tras confirmar
el guardado. Los nombres vacíos o demasiado largos se rechazan con el schema existente.
Un nombre equivalente tras normalizar espacios no genera otra escritura.
El guardado reutiliza la actualización del puesto y su historial. Antes relee los valores
vigentes para no enviar sede, responsable o composición capturados al abrir el editor.
La respuesta confirmada actualiza la caché de inventario y después invalida esa consulta;
no se invalidan perfiles ni software porque sus referencias no cambian.
La ficha separa Resumen, Perfiles y Software con pestañas accesibles y desplazables en
espacios estrechos. Bajo el nombre, la cabecera muestra estado, sede y responsable si
existe, con enlaces a sede y persona. A la derecha aparece Editar puesto y un menú de
acciones con Desactivar y Eliminar definitivamente, manteniendo sus confirmaciones.
Nuevo perfil se sitúa en la cabecera del panel Perfiles y se habilita únicamente para un puesto activo con un
ordenador principal incluido en su composición y no retirado; el bloqueo explica cómo
completar el puesto.
El resumen destaca el ordenador principal y muestra los demás activos como periféricos
mediante `AssetSummaryCard`, sin duplicados. Sus nombres enlazan las fichas de activos,
que conservan el detalle técnico completo. Las personas se presentan con avatar, con el
responsable primero y una única aparición por persona. La carga o el error de perfiles
no se confunde con ausencia de personas. La pestaña Perfiles reúne los del ordenador
principal, incluidos los archivados, mediante la tabla común con búsqueda, ordenación
y paginación. Conserva los enlaces y avisos de revisión, sin repetir el mismo ordenador
en cada fila. El software se carga al abrir su pestaña y mantiene la gestión existente.
Los paneles de activos, personas y perfiles reutilizan `DetailPanel` de `@repo/rdx-ui`.
El diseño responde al ancho disponible y mantiene accesibles contenidos y acciones en
móvil y escritorio. Véase [Componentes de interfaz](componentes-de-interfaz.md).
## Desactivación y eliminación
El listado y la ficha completa ofrecen **Desactivar** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",

View File

@ -370,21 +370,35 @@
},
"multiline": "Long text",
"workstationDetail": {
"actions": "More workstation actions",
"back": "Back to workstations",
"subtitle": "Assets, user profiles and people connected to this workstation.",
"loadError": "Unable to load the workstation.",
"notFound": "This workstation does not exist in this company.",
"assets": "Workstation assets",
"profiles": "User profiles",
"profilesHint": "Profiles associated with the assets of this workstation.",
"profilesHint": "Profiles on the main computer. Peripherals do not host profiles for this workstation.",
"profilesError": "Unable to load user profiles.",
"noProfiles": "No profiles are linked to this workstation’s assets.",
"noProfiles": "No profiles are linked to the main computer yet.",
"people": "Related people",
"noPeople": "No people are related to this workstation.",
"assignedPerson": "Workstation custodian",
"profilePerson": "Has a profile on a workstation asset",
"profilePerson": "Has a profile on the main computer",
"needsReview": "References awaiting review",
"missingAsset": "Asset unavailable."
"missingAsset": "Asset unavailable.",
"overview": "Overview",
"software": "Computer software",
"sections": "Workstation sections",
"peripherals": "Other assets and peripherals",
"noComputer": "This workstation has no available main computer",
"completeComputer": "Edit the workstation to add its assets and choose the main computer.",
"profileUnavailable": "To create a profile, the workstation must be active and have an available main computer.",
"peopleHint": "The custodian and people with profiles on the main computer.",
"searchProfiles": "Search by profile or person",
"review": "Review",
"rename": "Rename workstation",
"nameSaved": "Name updated.",
"nameInvalid": "Enter a valid workstation name."
},
"workstationComputer": "Main computer",
"workstationComputerHelp": "Select the main computer by clicking its card among the workstation assets. Profiles will be created on it; the other assets are peripherals.",
@ -452,4 +466,4 @@
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
}
}
}

View File

@ -370,21 +370,35 @@
},
"multiline": "Texto largo",
"workstationDetail": {
"actions": "Más acciones del puesto",
"back": "Volver a puestos de trabajo",
"subtitle": "Activos, perfiles de usuario y personas vinculadas a este puesto.",
"loadError": "No se ha podido cargar el puesto de trabajo.",
"notFound": "Este puesto de trabajo no existe en esta compañía.",
"assets": "Activos del puesto",
"profiles": "Perfiles de usuario",
"profilesHint": "Perfiles asociados a los activos de este puesto.",
"profilesHint": "Perfiles del ordenador principal. Los periféricos no alojan perfiles de este puesto.",
"profilesError": "No se han podido cargar los perfiles de usuario.",
"noProfiles": "No hay perfiles vinculados a los activos de este puesto.",
"noProfiles": "Todavía no hay perfiles vinculados al ordenador principal.",
"people": "Personas relacionadas",
"noPeople": "No hay personas relacionadas con este puesto.",
"assignedPerson": "Responsable del puesto",
"profilePerson": "Con perfil en un activo del puesto",
"profilePerson": "Con perfil en el ordenador principal",
"needsReview": "Referencias pendientes de revisión",
"missingAsset": "Activo no disponible."
"missingAsset": "Activo no disponible.",
"overview": "Resumen",
"software": "Software del ordenador",
"sections": "Secciones del puesto de trabajo",
"peripherals": "Otros activos y periféricos",
"noComputer": "Este puesto no tiene un ordenador principal disponible",
"completeComputer": "Edita el puesto para incorporar sus activos y elegir el ordenador principal.",
"profileUnavailable": "Para crear un perfil, el puesto debe estar activo y tener un ordenador principal disponible.",
"peopleHint": "Responsable y personas con perfiles en el ordenador principal.",
"searchProfiles": "Buscar por perfil o persona",
"review": "Revisión",
"rename": "Cambiar nombre del puesto",
"nameSaved": "Nombre actualizado.",
"nameInvalid": "Introduce un nombre válido para el puesto."
},
"workstationComputer": "Ordenador principal",
"workstationComputerHelp": "Pulsa la tarjeta del ordenador principal entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.",
@ -452,4 +466,4 @@
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
}
}
}

View File

@ -12,10 +12,12 @@ import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-su
export function AssetSummaryCard({
asset,
template,
name,
actions,
error,
}: {
asset: AssetDTO | undefined;
name?: ReactNode;
template?: CatalogItemDTO | undefined;
actions?: ReactNode;
error?: string | undefined;
@ -35,7 +37,7 @@ export function AssetSummaryCard({
<AssetTemplateIcon className="size-5" icon={icon ?? "box"} />
</span>
<div className="min-w-0 flex-1 space-y-1 break-words">
<p className="font-semibold leading-snug">{asset?.values.name ?? "—"}</p>
<p className="font-semibold leading-snug">{name ?? asset?.values.name ?? "—"}</p>
<p className="text-xs text-muted-foreground">
{asset?.specification.templateName ?? "—"}
</p>

View File

@ -9,5 +9,6 @@ export { inventoryApi } from "./shared/api";
export { inventoryKeys } from "./shared/queries";
export { AssetSupport } from "./support-connections";
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";

View File

@ -0,0 +1,26 @@
import { WorkstationInputSchema } from "../../../../common/workstations";
import { getAssetInventory } from "../../../assets/list/api/get-asset-inventory.api";
import { InventoryApiError } from "../../../shared/api/inventory-api-error";
import { saveWorkstation } from "../../shared/api/save-workstation.api";
/** Relee la composición vigente para no enviar el resto de valores capturados al comenzar la edición. */
export async function renameWorkstation(companyId: string, id: string, name: string) {
const parsed = WorkstationInputSchema.shape.name.safeParse(name.trim());
if (!parsed.success) {
throw new InventoryApiError("INVALID_REQUEST");
}
const inventory = await getAssetInventory(companyId);
const workstation = inventory.workstations.find(
(item) => item.id === id && item.companyId === companyId
);
if (!workstation) {
throw new InventoryApiError("WORKSTATION_NOT_FOUND");
}
if (parsed.data === workstation.values.name) {
return { workstation };
}
return saveWorkstation(companyId, { ...workstation.values, name: parsed.data }, id);
}

View File

@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { configureCsrfTokenReader } from "@metis/core/client";
import { WorkstationSchema } from "../../../../common/workstations";
import { InventoryApiError } from "../../../shared/api/inventory-api-error";
import { renameWorkstation } from "./rename-workstation.api";
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
const companyId = id(1);
const values = {
name: "Nombre actual",
active: false,
siteId: id(3),
personId: id(4),
computerAssetId: id(5),
assetIds: [id(5), id(6)],
};
const workstation = WorkstationSchema.parse({
id: id(2),
companyId,
values,
history: [
{
id: id(7),
actorId: id(8),
actorName: "Técnico",
recordedAt: "2026-10-16T12:00:00.000Z",
values,
},
],
});
test("renombrar: usa valores vigentes, normaliza el nombre, valida respuesta y envía CSRF", async (context) => {
context.after(configureCsrfTokenReader(() => "a".repeat(64)));
const original = globalThis.fetch;
const saved = { ...workstation, values: { ...values, name: "Nuevo nombre" } };
const requests: string[] = [];
globalThis.fetch = async (input, options) => {
requests.push(String(input));
assert.equal(options?.credentials, "same-origin");
if (options?.method === "GET") {
assert.equal(input, `/api/assets/${companyId}`);
return Response.json({ assets: [], relationships: [], workstations: [workstation] });
}
assert.equal(input, `/api/assets/${companyId}/workstations/${workstation.id}/update`);
assert.equal(options?.method, "POST");
assert.equal(new Headers(options.headers).get("x-csrf-token"), "a".repeat(64));
assert.deepEqual(JSON.parse(String(options.body)), { values: saved.values });
return Response.json({ workstation: saved });
};
try {
assert.deepEqual(await renameWorkstation(companyId, workstation.id, " Nuevo nombre "), {
workstation: saved,
});
assert.equal(requests.length, 2);
} finally {
globalThis.fetch = original;
}
});
test("renombrar: rechaza vacíos, espacios y exceso de longitud antes de realizar peticiones", async () => {
const original = globalThis.fetch;
let requests = 0;
globalThis.fetch = async () => {
requests += 1;
return Response.json({});
};
try {
for (const name of ["", " ", "a".repeat(101)]) {
await assert.rejects(
() => renameWorkstation(companyId, workstation.id, name),
(error: unknown) => error instanceof InventoryApiError && error.code === "INVALID_REQUEST"
);
}
assert.equal(requests, 0);
} finally {
globalThis.fetch = original;
}
});
test("renombrar: un nombre equivalente no provoca una escritura ni otra revisión", async () => {
const original = globalThis.fetch;
let requests = 0;
globalThis.fetch = async (_input, options) => {
requests += 1;
assert.equal(options?.method, "GET");
return Response.json({ assets: [], relationships: [], workstations: [workstation] });
};
try {
assert.deepEqual(await renameWorkstation(companyId, workstation.id, ` ${values.name} `), {
workstation,
});
assert.equal(requests, 1);
} finally {
globalThis.fetch = original;
}
});
test("renombrar: propaga puesto ausente y errores del servidor sin escritura posterior", async (context) => {
context.after(configureCsrfTokenReader(() => "a".repeat(64)));
const original = globalThis.fetch;
let exists = false;
let posts = 0;
globalThis.fetch = async (_input, options) => {
if (options?.method === "GET") {
return Response.json({
assets: [],
relationships: [],
workstations: exists ? [workstation] : [],
});
}
posts += 1;
return Response.json({ code: "WORKSTATION_IN_USE" }, { status: 409 });
};
try {
await assert.rejects(
() => renameWorkstation(companyId, workstation.id, "Nuevo"),
(error: unknown) =>
error instanceof InventoryApiError && error.code === "WORKSTATION_NOT_FOUND"
);
assert.equal(posts, 0);
exists = true;
await assert.rejects(
() => renameWorkstation(companyId, workstation.id, "Nuevo"),
(error: unknown) => error instanceof InventoryApiError && error.code === "WORKSTATION_IN_USE"
);
assert.equal(posts, 1);
} finally {
globalThis.fetch = original;
}
});

View File

@ -0,0 +1,177 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { WorkstationDTO } from "../../../../common/workstations";
import { WorkstationInputSchema } from "../../../../common/workstations";
import type { getAssetInventory } from "../../../assets/list/api/get-asset-inventory.api";
import { inventoryKeys } from "../../../shared/queries";
import { InventoryFeedback } from "../../../shared/ui/inventory-feedback";
import { renameWorkstation } from "../api/rename-workstation.api";
/** Borrador local explícito: Enter guarda, Escape cancela y los refrescos no sustituyen lo escrito. */
export function WorkstationNameEditor({ workstation }: { workstation: WorkstationDTO }) {
const { t } = useTranslation("assets");
const client = useQueryClient();
const id = useId();
const input = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const restoreFocus = useRef(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [submitted, setSubmitted] = useState(false);
const validation = WorkstationInputSchema.shape.name.safeParse(draft.trim());
const invalid = submitted && !validation.success;
const mutation = useMutation({
mutationFn: (name: string) => renameWorkstation(workstation.companyId, workstation.id, name),
onSuccess: async ({ workstation: saved }) => {
client.setQueryData<Awaited<ReturnType<typeof getAssetInventory>>>(
inventoryKeys.company(saved.companyId),
(current) =>
current
? {
...current,
workstations: current.workstations.map((item) =>
item.id === saved.id ? saved : item
),
}
: current
);
await client.invalidateQueries({ queryKey: inventoryKeys.company(saved.companyId) });
},
});
useEffect(() => {
if (editing) {
input.current?.focus();
input.current?.select();
} else if (restoreFocus.current) {
trigger.current?.focus();
restoreFocus.current = false;
}
}, [editing]);
const close = () => {
restoreFocus.current = true;
setEditing(false);
};
const begin = () => {
setDraft(workstation.values.name);
setSubmitted(false);
mutation.reset();
setEditing(true);
};
const save = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (mutation.isPending) {
return;
}
setSubmitted(true);
if (!validation.success) {
input.current?.focus();
return;
}
if (validation.data === workstation.values.name) {
close();
return;
}
mutation.mutate(validation.data, { onSuccess: close });
};
if (!editing) {
return (
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-start gap-2">
<h1 className="min-w-0 break-words text-2xl font-semibold tracking-tight">
{workstation.values.name}
</h1>
<Button
aria-label={t("workstationDetail.rename")}
onClick={begin}
ref={trigger}
size="icon-sm"
title={t("workstationDetail.rename")}
type="button"
variant="ghost"
>
<PencilIcon aria-hidden="true" className="size-4" />
</Button>
</div>
{mutation.isSuccess && (
<p className="text-xs text-muted-foreground" role="status">
{t("workstationDetail.nameSaved")}
</p>
)}
</div>
);
}
return (
<div className="min-w-0 space-y-2">
<h1 className="sr-only">{workstation.values.name}</h1>
<form
aria-label={t("workstationDetail.rename")}
className="space-y-2"
noValidate
onKeyDown={(event) => {
if (event.key === "Escape" && !mutation.isPending) {
event.preventDefault();
event.stopPropagation();
close();
}
}}
onSubmit={save}
>
<FormLabel className="sr-only" htmlFor={id} required>
{t("name")}
</FormLabel>
<div className="flex flex-wrap items-center gap-2">
<Input
aria-describedby={invalid ? `${id}-error` : undefined}
aria-invalid={invalid}
className="min-w-0 flex-1 basis-full font-semibold sm:basis-auto"
disabled={mutation.isPending}
id={id}
maxLength={WorkstationInputSchema.shape.name.maxLength ?? undefined}
onChange={(event) => {
setDraft(event.target.value);
if (mutation.isError) {
mutation.reset();
}
}}
ref={input}
required
value={draft}
/>
<Button disabled={mutation.isPending} size="sm" type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
<Button
disabled={mutation.isPending}
onClick={close}
size="sm"
type="button"
variant="outline"
>
{t("cancel")}
</Button>
</div>
{invalid && (
<p className="text-sm text-destructive" id={`${id}-error`} role="alert">
{t("workstationDetail.nameInvalid")}
</p>
)}
<InventoryFeedback error={mutation.error} />
</form>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { EntityAction, EntityRowActions } from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -26,18 +27,18 @@ export function WorkstationActions({
return (
<>
<div className="flex flex-wrap gap-2">
<Button onClick={onEdit} variant="outline">
{t("editWorkstation")}
</Button>
{canDecommission && (
<Button onClick={() => setAction("decommission")} variant="outline">
{t("workstationLifecycle.decommission.title")}
</Button>
)}
<Button onClick={() => setAction("delete")} variant="destructive">
{t("workstationLifecycle.delete.title")}
</Button>
<div className="flex items-center gap-2">
<Button onClick={onEdit}>{t("editWorkstation")}</Button>
<EntityRowActions label={t("workstationDetail.actions")}>
{canDecommission && (
<EntityAction onClick={() => setAction("decommission")}>
{t("workstationLifecycle.decommission.title")}
</EntityAction>
)}
<EntityAction className="text-destructive" onClick={() => setAction("delete")}>
{t("workstationLifecycle.delete.title")}
</EntityAction>
</EntityRowActions>
</div>
{action && (
<WorkstationLifecycleDialog

View File

@ -1,5 +1,5 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useQuery } from "@tanstack/react-query";
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useListQuery } from "@metis/core/client";
import { useState } from "react";
import type { CompanyDTO } from "../../../../common";
@ -9,14 +9,18 @@ import { workstationRelations } from "../helpers/workstation-relations";
export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) {
const [creatingProfile, setCreatingProfile] = useState(false);
const [editing, setEditing] = useState(false);
const inventory = useQuery({
const inventory = useListQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const profiles = useQuery({
const profiles = useListQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const catalog = useListQuery({
queryKey: catalogKeys.scope(company.id),
queryFn: () => catalogApi.get(company.id),
});
const workstation = inventory.data?.workstations.find((item) => item.id === workstationId);
const { relatedProfiles, personIds } = workstationRelations(
@ -29,6 +33,7 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
creatingProfile,
setCreatingProfile,
inventory,
catalog,
profiles,
workstation,
editing,
@ -37,3 +42,5 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
people,
};
}
export type WorkstationDetailController = ReturnType<typeof useWorkstationDetailController>;

View File

@ -1,90 +1,89 @@
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
import { AssetSummaryCard } from "@metis/assets/client";
import type { AssetDTO, CatalogItemDTO, WorkstationDTO } from "@metis/assets/common";
import { BrowserLink } from "@metis/core/client";
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { LaptopIcon } from "@repo/rdx-ui/lib/icons";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { EmptyState } from "@repo/rdx-ui/components/empty-state";
import { LaptopIcon, MonitorIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
export function WorkstationAssets({
companyId,
workstation,
assets,
catalog = [],
onEdit,
}: {
companyId: string;
workstation: WorkstationDTO;
assets: readonly AssetDTO[];
catalog?: readonly CatalogItemDTO[] | undefined;
onEdit: () => void;
}) {
const { t, i18n } = useTranslation("assets");
const root = `/companies/${companyId}`;
const assetIds = new Set(workstation.values.assetIds);
const { t } = useTranslation("assets");
const ids = [...new Set(workstation.values.assetIds)];
const computerId = workstation.values.computerAssetId;
const computer = assets.find((asset) => asset.id === computerId && ids.includes(asset.id));
const peripherals = ids.filter((id) => id !== computerId);
const assetName = (id: string) =>
assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
const card = (id: string) => {
const asset = assets.find((item) => item.id === id);
return (
<AssetSummaryCard
asset={asset}
error={asset ? undefined : t("workstationDetail.missingAsset")}
name={
asset ? (
<BrowserLink href={`/companies/${companyId}/assets/${id}`}>
{asset.values.name}
</BrowserLink>
) : undefined
}
template={catalog.find((item) => item.id === asset?.values.templateId)}
/>
);
};
return (
<section aria-labelledby="workstation-assets" className="space-y-3">
<h2 className="flex items-center gap-2 text-lg font-semibold" id="workstation-assets">
<LaptopIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t("workstationDetail.assets")}{" "}
<span className="text-sm font-normal text-muted-foreground">{assetIds.size}</span>
</h2>
{!assetIds.size && (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
<DetailPanel count={ids.length} icon={<LaptopIcon />} title={t("workstationDetail.assets")}>
{computer ? (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("workstationComputer")}
</h3>
<ul className="grid gap-3">
<li className="min-w-0 rounded-lg ring-2 ring-primary/30 ring-offset-2 ring-offset-background">
{card(computer.id)}
</li>
</ul>
</div>
) : (
<EmptyState
actions={
<Button onClick={onEdit} type="button" variant="outline">
{t("editWorkstation")}
</Button>
}
description={t("workstationDetail.completeComputer")}
icon={<MonitorIcon />}
title={t(ids.length > 0 ? "workstationDetail.noComputer" : "noWorkstationAssets")}
/>
)}
<div className="grid gap-3 md:grid-cols-2">
{[...assetIds].map((id) => {
const asset = assets.find((item) => item.id === id);
return (
<article className="min-w-0 space-y-3 rounded-xl border bg-card p-4" key={id}>
<div className="space-y-1">
<BrowserLink className="font-medium" href={`${root}/assets/${id}`}>
{assetName(id)}
</BrowserLink>
<p className="text-xs text-muted-foreground">
{id === workstation.values.computerAssetId
? t("workstationComputer")
: t("workstationAssets")}
</p>
</div>
{asset ? (
<>
<p className="text-sm text-muted-foreground">
{asset.specification.templateName}
</p>
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
<dl>
<DetailField label={t("registeredOn")}>
{asset.values.registeredOn
? new Intl.DateTimeFormat(i18n.language, {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${asset.values.registeredOn}T00:00:00Z`))
: t("noValue")}
</DetailField>
{asset.specification.fields
.filter((field) => asset.values.characteristics[field.fieldId] !== undefined)
.slice(0, 3)
.map((field) => (
<DetailField key={field.fieldId} label={field.name}>
{typeof asset.values.characteristics[field.fieldId] === "boolean"
? t(asset.values.characteristics[field.fieldId] ? "yes" : "no")
: String(asset.values.characteristics[field.fieldId])}
</DetailField>
))}
</dl>
</>
) : (
<p className="text-sm text-muted-foreground">
{t("workstationDetail.missingAsset")}
</p>
)}
</article>
);
})}
</div>
</section>
{peripherals.length > 0 && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("workstationDetail.peripherals")}
</h3>
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
{peripherals.map((id) => (
<li className="min-w-0" key={id}>
{card(id)}
</li>
))}
</ul>
</div>
)}
</DetailPanel>
);
}

View File

@ -0,0 +1,115 @@
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { SoftwareInstallationsPanel } from "../../../software-installations/list/ui/software-installations-panel";
import type { WorkstationDetailController } from "../controllers/use-workstation-detail-controller";
import { WorkstationAssets } from "./workstation-assets";
import { WorkstationPeople } from "./workstation-people";
import { WorkstationProfiles } from "./workstation-profiles";
export function WorkstationDetailContent({
company,
controller: c,
}: {
company: CompanyDTO;
controller: WorkstationDetailController;
}) {
const { t, i18n } = useTranslation("assets");
const [tab, setTab] = useState("overview");
const [softwareOpened, setSoftwareOpened] = useState(false);
const workstation = c.workstation;
if (!workstation) {
return null;
}
const computer = c.inventory.data?.assets.find(
(asset) =>
asset.id === workstation.values.computerAssetId &&
workstation.values.assetIds.includes(asset.id)
);
const canCreateProfile = Boolean(
workstation.values.active && computer && computer.values.status !== "retired"
);
return (
<Tabs
className="min-w-0 gap-5"
onValueChange={(value) => {
if (typeof value === "string") {
setTab(value);
if (value === "software") {
setSoftwareOpened(true);
}
}
}}
value={tab}
>
<ScrollableTabsList
items={[
{ value: "overview", label: t("workstationDetail.overview") },
{
value: "profiles",
label: (
<span className="inline-flex items-center gap-2">
{t("workstationDetail.profiles")}
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums">
{c.profiles.isSuccess ? c.relatedProfiles.length : "—"}
</span>
</span>
),
},
{ value: "software", label: t("workstationDetail.software") },
]}
label={t("workstationDetail.sections")}
locale={i18n.language}
value={tab}
/>
<TabsContent className="min-w-0" value="overview">
<div className="grid items-start gap-5 @min-[1000px]:grid-cols-[minmax(0,1fr)_minmax(260px,0.45fr)]">
<WorkstationAssets
assets={c.inventory.data?.assets ?? []}
catalog={c.catalog.data?.items}
companyId={company.id}
onEdit={() => c.setEditing(true)}
workstation={workstation}
/>
<WorkstationPeople
companyId={company.id}
failed={c.profiles.isError}
loading={c.profiles.isPending}
people={c.people}
responsibleId={workstation.values.personId}
/>
</div>
</TabsContent>
<TabsContent className="min-w-0" keepMounted value="profiles">
<WorkstationProfiles
canCreate={canCreateProfile}
companyId={company.id}
failed={c.profiles.isError}
loading={c.profiles.isPending}
onCreate={() => c.setCreatingProfile(true)}
onRetry={() => {
c.profiles.refetch().catch(() => undefined);
}}
people={company.people}
profiles={c.relatedProfiles}
/>
</TabsContent>
<TabsContent className="min-w-0" keepMounted value="software">
{softwareOpened && (
<SoftwareInstallationsPanel
assetId={workstation.values.computerAssetId}
canCreate={workstation.values.active}
companyId={company.id}
/>
)}
</TabsContent>
</Tabs>
);
}

View File

@ -1,18 +1,20 @@
import { WorkstationActions, WorkstationEditorDialog } from "@metis/assets/client";
import {
WorkstationActions,
WorkstationEditorDialog,
WorkstationNameEditor,
} from "@metis/assets/client";
import { BrowserLink, navigateTo } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { ChevronLeftIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { ChevronLeftIcon, MapPinIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { PersonAvatar } from "../../../people/shared";
import { SoftwareInstallationsPanel } from "../../../software-installations/list/ui/software-installations-panel";
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
import { WorkstationAssets } from "./workstation-assets";
import { WorkstationDetailContent } from "./workstation-detail-content";
export function WorkstationDetailPage({
company,
@ -24,7 +26,7 @@ export function WorkstationDetailPage({
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
}) {
const { t } = useTranslation("assets");
const { t: profileTranslation } = useTranslation("companies");
const c = useWorkstationDetailController(company, workstationId);
const {
creatingProfile,
setCreatingProfile,
@ -33,19 +35,12 @@ export function WorkstationDetailPage({
workstation,
editing,
setEditing,
relatedProfiles,
people,
} = useWorkstationDetailController(company, workstationId);
} = c;
const root = `/companies/${company.id}`;
const personName = (id: string) =>
company.people.find((person) => person.id === id)?.name ?? t("noValue");
const assetName = (id: string) =>
inventory.data?.assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
const responsible = company.people.find((person) => person.id === workstation?.values.personId);
return (
<div className="space-y-6">
<div className="@container min-w-0 space-y-5">
<BrowserLink className="inline-flex items-center gap-1 text-sm" href={`${root}/workstations`}>
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("workstationDetail.back")}
@ -69,168 +64,53 @@ export function WorkstationDetailPage({
)}
{workstation && (
<>
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h1 className="flex items-center gap-3 text-2xl font-semibold">
<MonitorIcon aria-hidden="true" className="size-6 text-muted-foreground" />
{workstation.values.name}
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
<EntityStatus active={workstation.values.active}>
{t(workstation.values.active ? "active" : "inactive")}
</EntityStatus>
{workstation.values.siteId && (
<BrowserLink href={`${root}/sites`}>
{company.sites.find((site) => site.id === workstation.values.siteId)?.name ??
t("noValue")}
</BrowserLink>
)}
{workstation.values.personId && (
<span className="inline-flex flex-wrap items-center gap-1">
<span className="text-muted-foreground">{t("workstationPerson")}:</span>
<BrowserLink href={`${root}/people/${workstation.values.personId}`}>
{personName(workstation.values.personId)}
</BrowserLink>
</span>
)}
<header className="space-y-4 rounded-xl border border-border/70 bg-card p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<MonitorIcon aria-hidden="true" className="size-6" />
</span>
<div className="min-w-0 space-y-2">
<WorkstationNameEditor key={workstation.id} workstation={workstation} />
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
<EntityStatus active={workstation.values.active}>
{t(workstation.values.active ? "active" : "inactive")}
</EntityStatus>
<span className="inline-flex min-w-0 items-center gap-2">
<MapPinIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("site")}: </span>
{workstation.values.siteId ? (
<BrowserLink href={`${root}/sites`}>
{company.sites.find((site) => site.id === workstation.values.siteId)
?.name ?? "—"}
</BrowserLink>
) : (
"—"
)}
</span>
{responsible && (
<span className="inline-flex min-w-0 items-center gap-2">
<UsersIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("workstationDetail.assignedPerson")}: </span>
<BrowserLink href={`${root}/people/${responsible.id}`}>
{responsible.name}
</BrowserLink>
</span>
)}
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
</div>
<WorkstationActions
onDecommissioned={() => {
profiles.refetch().catch(() => undefined);
}}
onDeleted={() => navigateTo(`${root}/workstations`, { replace: true })}
onEdit={() => setEditing(true)}
workstation={workstation}
/>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)]">
<div className="space-y-6">
<WorkstationAssets
assets={inventory.data?.assets ?? []}
companyId={company.id}
<WorkstationActions
onDecommissioned={() => {
profiles.refetch().catch(() => undefined);
}}
onDeleted={() => navigateTo(`${root}/workstations`, { replace: true })}
onEdit={() => setEditing(true)}
workstation={workstation}
/>
<SoftwareInstallationsPanel
assetId={workstation.values.computerAssetId}
canCreate={workstation.values.active}
companyId={company.id}
/>
<section aria-labelledby="workstation-profiles" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold" id="workstation-profiles">
{t("workstationDetail.profiles")}
</h2>
<Button
disabled={!workstation.values.active}
onClick={() => setCreatingProfile(true)}
size="sm"
variant="outline"
>
{profileTranslation("profiles.new")}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t("workstationDetail.profilesHint")}
</p>
{profiles.isPending && <p role="status">{t("loading")}</p>}
{profiles.isError && (
<div role="alert">
<p className="text-sm text-muted-foreground">
{t("workstationDetail.profilesError")}
</p>
<Button
onClick={() => {
profiles.refetch().catch(() => undefined);
}}
size="sm"
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{profiles.isSuccess && !relatedProfiles.length && (
<p className="text-sm text-muted-foreground">
{t("workstationDetail.noProfiles")}
</p>
)}
<div className="divide-y">
{relatedProfiles.map((profile) => (
<article
className="flex flex-wrap items-start justify-between gap-3 py-3"
key={profile.id}
>
<div className="space-y-1">
<BrowserLink
className="font-medium"
href={`${root}/work-profiles/${profile.id}`}
>
{profile.values.name}
</BrowserLink>
<p className="text-sm">
<BrowserLink href={`${root}/people/${profile.values.personId}`}>
{personName(profile.values.personId)}
</BrowserLink>
<span className="mx-2 text-muted-foreground">·</span>
<BrowserLink href={`${root}/assets/${profile.values.assetId}`}>
{assetName(profile.values.assetId)}
</BrowserLink>
</p>
{profile.needsReview && (
<p className="text-xs text-muted-foreground">
{t("workstationDetail.needsReview")}
</p>
)}
</div>
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
</article>
))}
</div>
</section>
</div>
<div className="space-y-6">
<section aria-labelledby="workstation-people" className="space-y-3">
<h2
className="flex items-center gap-2 text-lg font-semibold"
id="workstation-people"
>
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
{t("workstationDetail.people")}
</h2>
{!people.length && profiles.isSuccess && (
<p className="text-sm text-muted-foreground">{t("workstationDetail.noPeople")}</p>
)}
<div className="divide-y">
{people.map((person) => (
<div className="flex items-center gap-3 py-3" key={person.id}>
<PersonAvatar
companyId={company.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<div>
<BrowserLink href={`${root}/people/${person.id}`}>
{person.name}
</BrowserLink>
<p className="text-xs text-muted-foreground">
{t(
person.id === workstation.values.personId
? "workstationDetail.assignedPerson"
: "workstationDetail.profilePerson"
)}
</p>
</div>
</div>
))}
</div>
</section>
</div>
</div>
</header>
<WorkstationDetailContent company={company} controller={c} />
{creatingProfile && (
<WorkProfileEditorDialog
company={company}

View File

@ -1,19 +1,25 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { assetsEnglish, assetsSpanish, inventoryKeys } from "@metis/assets/client";
import { WorkstationSchema } from "@metis/assets/common";
import { assetsEnglish, assetsSpanish, catalogKeys, inventoryKeys } from "@metis/assets/client";
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
import { AssetSchema, WorkstationSchema } from "@metis/assets/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import type { CompanyDTO, PersonDTO } from "../../../../common";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
import { WorkProfileSchema } from "../../../../common/work-profiles";
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
import { companiesEnglish, companiesSpanish } from "../../../shared";
import { profileKeys } from "../../../work-profiles/shared/api";
import { WorkstationDetailPage } from "./workstation-detail-page";
import { WorkstationPeople } from "./workstation-people";
import { WorkstationProfiles } from "./workstation-profiles";
const companyId = "0199b200-0000-7000-8000-000000000001";
const workstationId = "0199b200-0000-7000-8000-000000000002";
@ -49,7 +55,18 @@ const workstation = WorkstationSchema.parse({
],
});
async function render(language: string, exists: boolean) {
async function render(
language: string,
exists: boolean,
options: {
node?: ReactNode;
assets?: AssetDTO[];
workstation?: WorkstationDTO;
people?: PersonDTO[];
sites?: CompanyDTO["sites"];
profiles?: WorkProfileDTO[];
} = {}
) {
const i18n = createInstance();
await i18n.init({
@ -64,25 +81,28 @@ async function render(language: string, exists: boolean) {
});
client.setQueryData(inventoryKeys.company(companyId), {
assets: [],
assets: options.assets ?? [],
relationships: [],
workstations: exists ? [workstation] : [],
workstations: exists ? [options.workstation ?? workstation] : [],
});
client.setQueryData(profileKeys.company(companyId), { profiles: [] });
client.setQueryData(profileKeys.company(companyId), { profiles: options.profiles ?? [] });
client.setQueryData(catalogKeys.scope(companyId), { items: [] });
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>
<WorkstationDetailPage
company={company}
lookups={{
people: [],
sites: [],
onCreatePerson: () => undefined,
onCreateSite: () => undefined,
}}
workstationId={workstationId}
/>
{options.node ?? (
<WorkstationDetailPage
company={{ ...company, people: options.people ?? [], sites: options.sites ?? [] }}
lookups={{
people: [],
sites: [],
onCreatePerson: () => undefined,
onCreateSite: () => undefined,
}}
workstationId={workstationId}
/>
)}
</QueryClientProvider>
</I18nextProvider>
);
@ -94,16 +114,37 @@ async function render(language: string, exists: boolean) {
test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal sin formulario incrustado", async () => {
for (const language of ["es", "en"]) {
const html = await render(language, true);
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const companies = language === "es" ? companiesSpanish : companiesEnglish;
assert.ok(html.includes("Administración"));
assert.ok(html.includes(`href="/companies/${companyId}/workstations"`));
assert.ok(html.includes(language === "es" ? "Editar puesto" : "Edit workstation"));
assert.ok(html.includes(language === "es" ? "Desactivar" : "Deactivate"));
assert.ok(html.includes(language === "es" ? "Eliminar definitivamente" : "Delete permanently"));
assert.ok(html.includes(language === "es" ? "Perfiles de usuario" : "User profiles"));
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
assert.ok(html.includes(labels.editWorkstation));
assert.ok(html.includes(`aria-label="${labels.workstationDetail.rename}"`));
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
assert.ok(header.includes(`aria-label="${labels.workstationDetail.actions}"`));
assert.ok(header.includes('aria-haspopup="menu"'));
assert.ok(header.includes(labels.editWorkstation));
assert.equal(header.includes(companies.profiles.new), false);
assert.equal(header.includes(labels.workstationDetail.subtitle), false);
assert.equal(header.includes(labels.workstationDetail.profileUnavailable), false);
assert.ok(html.includes(labels.workstationDetail.profiles));
assert.ok(html.includes(labels.workstationDetail.people));
assert.ok(html.includes(companies.profiles.new));
assert.equal(html.includes("<form"), false);
assert.ok(html.includes(labels.workstationDetail.overview));
assert.ok(html.includes(labels.workstationDetail.software));
assert.ok(html.includes(labels.noWorkstationAssets));
assert.ok(html.includes(labels.workstationDetail.profileUnavailable));
assert.match(
html,
/<button(?=[^>]*disabled="")(?=[^>]*type="button")[^>]*>[\s\S]*?(Nuevo perfil|New profile)/
);
assert.equal(
html.includes(language === "es" ? "Instalar software" : "Install software"),
false
);
}
});
@ -191,3 +232,191 @@ test("ficha de persona es/en: distingue responsabilidad y uso, y enlaza los pues
}
}
});
function asset(n: number, name: string) {
const id = `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
const assetValues = {
name,
templateId: companyId,
siteId: null,
status: "operational",
notes: "",
characteristics: { [companyId]: "ThinkCentre" },
};
return AssetSchema.parse({
id,
companyId,
values: assetValues,
assigned: true,
displayStatus: "in_use",
specification: {
templateId: companyId,
templateName: "Equipo",
fields: [
{
fieldId: companyId,
name: "Modelo",
type: "text",
unit: "",
choices: [],
required: false,
},
],
},
history: [{ ...workstation.history[0], values: assetValues }],
});
}
function userProfile(n: number, assetId: string, personId: string, active: boolean) {
const profileValues = {
name: `Perfil ${n}`,
assetId,
personId,
active,
accountId: null,
notes: null,
};
return WorkProfileSchema.parse({
id: `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`,
companyId,
values: profileValues,
needsReview: !active,
history: [{ ...workstation.history[0], values: profileValues }],
});
}
const person: PersonDTO = {
id: companyId,
companyId,
name: "Ana",
active: true,
isContact: false,
position: null,
email: null,
phone: null,
};
test("ficha: ordenador destacado y periféricos sin repetir, perfiles solo del ordenador y personas deduplicadas", async () => {
const computer = asset(40, "PC-PRINCIPAL");
const peripheral = asset(41, "MONITOR-SECUNDARIO");
const station = {
...workstation,
values: {
...workstation.values,
personId: person.id,
siteId: companyId,
computerAssetId: computer.id,
assetIds: [peripheral.id, computer.id],
},
};
const profiles = [
userProfile(50, computer.id, person.id, true),
userProfile(51, computer.id, person.id, false),
userProfile(52, peripheral.id, person.id, true),
];
for (const language of ["es", "en"]) {
const html = await render(language, true, {
assets: [computer, peripheral],
workstation: station,
people: [person],
sites: [
{
id: companyId,
companyId,
active: true,
name: "Sede central",
address: null,
phone: null,
},
],
profiles,
});
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
assert.ok(header.includes(`href="/companies/${companyId}/people/${person.id}"`));
assert.ok(header.includes(person.name));
assert.ok(header.includes("Sede central"));
assert.ok(header.includes(`href="/companies/${companyId}/sites"`));
assert.ok(header.includes(labels.workstationDetail.assignedPerson));
assert.ok(header.includes(labels.active));
assert.ok(html.includes(`href="/companies/${companyId}/assets/${computer.id}"`));
assert.ok(html.includes(`href="/companies/${companyId}/assets/${peripheral.id}"`));
assert.ok(html.indexOf(computer.values.name) < html.indexOf(peripheral.values.name));
assert.equal((html.match(/data-slot="card" data-size="sm"/g) ?? []).length, 2);
assert.ok(html.includes("ThinkCentre"));
assert.ok(html.includes(labels.workstationDetail.peripherals));
assert.ok(html.includes(labels.workstationDetail.assignedPerson));
assert.ok(html.includes(`href="/companies/${companyId}/work-profiles/${profiles[0]?.id}"`));
assert.ok(html.includes(`href="/companies/${companyId}/work-profiles/${profiles[1]?.id}"`));
assert.equal(html.includes("Perfil 52"), false);
assert.ok(html.includes(labels.workstationDetail.needsReview));
assert.equal(html.includes(labels.workstationDetail.profileUnavailable), false);
}
});
test("personas y perfiles: carga y error no se presentan como listados vacíos", async () => {
for (const language of ["es", "en"]) {
const labels = language === "es" ? assetsSpanish : assetsEnglish;
const people = await render(language, true, {
node: (
<WorkstationPeople
companyId={companyId}
failed={false}
loading
people={[]}
responsibleId={null}
/>
),
});
const profiles = await render(language, true, {
node: (
<WorkstationProfiles
canCreate={false}
companyId={companyId}
failed
loading={false}
onCreate={() => undefined}
onRetry={() => undefined}
people={[]}
profiles={[]}
/>
),
});
assert.ok(people.includes('role="status"'));
assert.equal(people.includes(labels.workstationDetail.noPeople), false);
assert.ok(profiles.includes('role="alert"'));
assert.ok(profiles.includes(labels.retry));
assert.equal(profiles.includes(labels.workstationDetail.noProfiles), false);
assert.ok(profiles.includes('data-quick-search=""'));
}
});
test("ficha: no permite crear perfiles si el ordenador está retirado o el puesto está desactivado", async () => {
const computer = asset(40, "PC-PRINCIPAL");
const station = {
...workstation,
values: { ...workstation.values, computerAssetId: computer.id, assetIds: [computer.id] },
};
for (const [active, status] of [
[false, "operational"],
[true, "retired"],
] as const) {
const html = await render("es", true, {
workstation: { ...station, values: { ...station.values, active } },
assets: [{ ...computer, values: { ...computer.values, status } }],
});
assert.ok(html.includes(assetsSpanish.workstationDetail.profileUnavailable));
assert.match(
html,
/<button(?=[^>]*disabled="")(?=[^>]*type="button")[^>]*>Nuevo perfil<\/button>/
);
}
});

View File

@ -0,0 +1,84 @@
import { BrowserLink } from "@metis/core/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import type { PersonDTO } from "../../../../common";
import { PersonAvatar } from "../../../people/shared";
export function WorkstationPeople({
companyId,
people,
responsibleId,
loading,
failed,
}: {
companyId: string;
people: readonly PersonDTO[];
responsibleId: string | null | undefined;
loading: boolean;
failed: boolean;
}) {
const { t, i18n } = useTranslation("assets");
const ordered = [...people].sort(
(a, b) =>
Number(b.id === responsibleId) - Number(a.id === responsibleId) ||
a.name.localeCompare(b.name, i18n.language)
);
return (
<DetailPanel
count={loading || failed ? undefined : people.length}
description={t("workstationDetail.peopleHint")}
icon={<UsersIcon />}
title={t("workstationDetail.people")}
>
{loading && (
<p className="text-sm text-muted-foreground" role="status">
{t("loading")}
</p>
)}
{failed && (
<p className="text-sm text-muted-foreground" role="alert">
{t("workstationDetail.profilesError")}
</p>
)}
{ordered.length > 0 && (
<ul className="divide-y divide-border/60">
{ordered.map((person) => (
<li
className="flex min-w-0 items-center gap-3 py-3 first:pt-0 last:pb-0"
key={person.id}
>
<PersonAvatar
className="size-10 shrink-0"
companyId={companyId}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<div className="min-w-0 space-y-1">
<BrowserLink
className="break-words font-medium"
href={`/companies/${companyId}/people/${person.id}`}
>
{person.name}
</BrowserLink>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(
person.id === responsibleId
? "workstationDetail.assignedPerson"
: "workstationDetail.profilePerson"
)}
</p>
</div>
</li>
))}
</ul>
)}
{!(people.length || loading || failed) && (
<p className="text-sm text-muted-foreground">{t("workstationDetail.noPeople")}</p>
)}
</DetailPanel>
);
}

View File

@ -0,0 +1,133 @@
import { BrowserLink } from "@metis/core/client";
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { TriangleAlertIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { PersonDTO } from "../../../../common";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
import { PersonAvatar } from "../../../people/shared";
export function WorkstationProfiles({
companyId,
canCreate,
onCreate,
profiles,
people,
loading,
failed,
onRetry,
}: {
companyId: string;
canCreate: boolean;
onCreate: () => void;
profiles: readonly WorkProfileDTO[];
people: readonly PersonDTO[];
loading: boolean;
failed: boolean;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation("assets");
const { t: companies } = useTranslation("companies");
return (
<DetailPanel
actions={
<Button disabled={!canCreate} onClick={onCreate} type="button">
{companies("profiles.new")}
</Button>
}
count={loading || failed ? undefined : profiles.length}
description={t("workstationDetail.profilesHint")}
icon={<UsersIcon />}
title={t("workstationDetail.profiles")}
>
{!canCreate && (
<p className="text-xs text-muted-foreground">{t("workstationDetail.profileUnavailable")}</p>
)}
{failed && (
<div className="space-y-2" role="alert">
<p>{t("workstationDetail.profilesError")}</p>
<Button onClick={onRetry} size="sm" type="button" variant="outline">
{t("retry")}
</Button>
</div>
)}
<EntityTable
columns={[
{
id: "name",
label: companies("profiles.name"),
hideable: false,
value: (profile) => profile.values.name,
render: (profile) => (
<BrowserLink
className="font-medium"
href={`/companies/${companyId}/work-profiles/${profile.id}`}
>
{profile.values.name}
</BrowserLink>
),
},
{
id: "person",
label: companies("profiles.person"),
value: (profile) =>
people.find((person) => person.id === profile.values.personId)?.name,
render: (profile) => {
const person = people.find((item) => item.id === profile.values.personId);
return person ? (
<span className="inline-flex min-w-0 items-center gap-2">
<PersonAvatar
companyId={companyId}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<BrowserLink href={`/companies/${companyId}/people/${person.id}`}>
{person.name}
</BrowserLink>
</span>
) : (
"—"
);
},
},
{
id: "status",
label: t("status"),
value: (profile) => t(profile.values.active ? "active" : "inactive"),
render: (profile) => (
<EntityStatus active={profile.values.active}>
{t(profile.values.active ? "active" : "inactive")}
</EntityStatus>
),
},
{
id: "review",
label: t("workstationDetail.review"),
value: (profile) => (profile.needsReview ? t("workstationDetail.needsReview") : null),
render: (profile) =>
profile.needsReview ? (
<span className="inline-flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
<TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
{t("workstationDetail.needsReview")}
</span>
) : (
"—"
),
},
]}
emptyMessage={t("workstationDetail.noProfiles")}
items={profiles}
loading={loading}
locale={i18n.language}
searchPlaceholder={t("workstationDetail.searchProfiles")}
title={t("workstationDetail.profiles")}
unavailable={failed}
/>
</DetailPanel>
);
}

View File

@ -0,0 +1,54 @@
import { Card, CardContent, CardHeader } from "@repo/shadcn-ui/components/card";
import type { ReactNode } from "react";
import { useId } from "react";
/** Panel de consulta con jerarquía común; datos, textos y acciones pertenecen al consumidor. */
export function DetailPanel({
title,
description,
icon,
count,
actions,
children,
}: {
title: string;
description?: string;
icon: ReactNode;
count?: number | undefined;
actions?: ReactNode;
children: ReactNode;
}) {
const id = useId();
return (
<section aria-labelledby={id} className="min-w-0">
<Card className="@container min-w-0 shadow-none">
<CardHeader className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2
className="flex min-w-0 flex-wrap items-center gap-2 text-base font-semibold"
id={id}
>
<span aria-hidden="true" className="text-primary [&_svg]:size-5">
{icon}
</span>
{title}
{count !== undefined && (
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
{count}
</span>
)}
</h2>
{description && (
<p className="max-w-prose text-sm leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
{actions && <div className="flex shrink-0 flex-wrap gap-2">{actions}</div>}
</CardHeader>
<CardContent className="space-y-5">{children}</CardContent>
</Card>
</section>
);
}

View File

@ -32,6 +32,7 @@ export {
IconMail as MailIcon,
IconMapPin as MapPinIcon,
IconMouse as MouseIcon,
IconPencil as PencilIcon,
IconPhone as PhoneIcon,
IconPlus as PlusIcon,
IconPrinter as PrinterIcon,