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:
parent
53a58ee0de
commit
df44fdceb4
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
54
packages/rdx-ui/src/components/detail-panel.tsx
Normal file
54
packages/rdx-ui/src/components/detail-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user