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
|
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
|
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.
|
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.
|
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
|
## 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.
|
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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
|
|||||||
@ -370,21 +370,35 @@
|
|||||||
},
|
},
|
||||||
"multiline": "Long text",
|
"multiline": "Long text",
|
||||||
"workstationDetail": {
|
"workstationDetail": {
|
||||||
|
"actions": "More workstation actions",
|
||||||
"back": "Back to workstations",
|
"back": "Back to workstations",
|
||||||
"subtitle": "Assets, user profiles and people connected to this workstation.",
|
"subtitle": "Assets, user profiles and people connected to this workstation.",
|
||||||
"loadError": "Unable to load the workstation.",
|
"loadError": "Unable to load the workstation.",
|
||||||
"notFound": "This workstation does not exist in this company.",
|
"notFound": "This workstation does not exist in this company.",
|
||||||
"assets": "Workstation assets",
|
"assets": "Workstation assets",
|
||||||
"profiles": "User profiles",
|
"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.",
|
"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",
|
"people": "Related people",
|
||||||
"noPeople": "No people are related to this workstation.",
|
"noPeople": "No people are related to this workstation.",
|
||||||
"assignedPerson": "Workstation custodian",
|
"assignedPerson": "Workstation custodian",
|
||||||
"profilePerson": "Has a profile on a workstation asset",
|
"profilePerson": "Has a profile on the main computer",
|
||||||
"needsReview": "References awaiting review",
|
"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",
|
"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.",
|
"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.",
|
"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."
|
"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",
|
"multiline": "Texto largo",
|
||||||
"workstationDetail": {
|
"workstationDetail": {
|
||||||
|
"actions": "Más acciones del puesto",
|
||||||
"back": "Volver a puestos de trabajo",
|
"back": "Volver a puestos de trabajo",
|
||||||
"subtitle": "Activos, perfiles de usuario y personas vinculadas a este puesto.",
|
"subtitle": "Activos, perfiles de usuario y personas vinculadas a este puesto.",
|
||||||
"loadError": "No se ha podido cargar el puesto de trabajo.",
|
"loadError": "No se ha podido cargar el puesto de trabajo.",
|
||||||
"notFound": "Este puesto de trabajo no existe en esta compañía.",
|
"notFound": "Este puesto de trabajo no existe en esta compañía.",
|
||||||
"assets": "Activos del puesto",
|
"assets": "Activos del puesto",
|
||||||
"profiles": "Perfiles de usuario",
|
"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.",
|
"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",
|
"people": "Personas relacionadas",
|
||||||
"noPeople": "No hay personas relacionadas con este puesto.",
|
"noPeople": "No hay personas relacionadas con este puesto.",
|
||||||
"assignedPerson": "Responsable del 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",
|
"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",
|
"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.",
|
"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.",
|
"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."
|
"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({
|
export function AssetSummaryCard({
|
||||||
asset,
|
asset,
|
||||||
template,
|
template,
|
||||||
|
name,
|
||||||
actions,
|
actions,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
asset: AssetDTO | undefined;
|
asset: AssetDTO | undefined;
|
||||||
|
name?: ReactNode;
|
||||||
template?: CatalogItemDTO | undefined;
|
template?: CatalogItemDTO | undefined;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
error?: string | undefined;
|
error?: string | undefined;
|
||||||
@ -35,7 +37,7 @@ export function AssetSummaryCard({
|
|||||||
<AssetTemplateIcon className="size-5" icon={icon ?? "box"} />
|
<AssetTemplateIcon className="size-5" icon={icon ?? "box"} />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1 space-y-1 break-words">
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{asset?.specification.templateName ?? "—"}
|
{asset?.specification.templateName ?? "—"}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,5 +9,6 @@ export { inventoryApi } from "./shared/api";
|
|||||||
export { inventoryKeys } from "./shared/queries";
|
export { inventoryKeys } from "./shared/queries";
|
||||||
export { AssetSupport } from "./support-connections";
|
export { AssetSupport } from "./support-connections";
|
||||||
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
|
export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations";
|
||||||
|
export { WorkstationNameEditor } from "./workstations/rename/ui/workstation-name-editor";
|
||||||
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
|
export { WorkstationActions } from "./workstations/shared/ui/workstation-actions";
|
||||||
export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -26,18 +27,18 @@ export function WorkstationActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={onEdit} variant="outline">
|
<Button onClick={onEdit}>{t("editWorkstation")}</Button>
|
||||||
{t("editWorkstation")}
|
<EntityRowActions label={t("workstationDetail.actions")}>
|
||||||
</Button>
|
{canDecommission && (
|
||||||
{canDecommission && (
|
<EntityAction onClick={() => setAction("decommission")}>
|
||||||
<Button onClick={() => setAction("decommission")} variant="outline">
|
{t("workstationLifecycle.decommission.title")}
|
||||||
{t("workstationLifecycle.decommission.title")}
|
</EntityAction>
|
||||||
</Button>
|
)}
|
||||||
)}
|
<EntityAction className="text-destructive" onClick={() => setAction("delete")}>
|
||||||
<Button onClick={() => setAction("delete")} variant="destructive">
|
{t("workstationLifecycle.delete.title")}
|
||||||
{t("workstationLifecycle.delete.title")}
|
</EntityAction>
|
||||||
</Button>
|
</EntityRowActions>
|
||||||
</div>
|
</div>
|
||||||
{action && (
|
{action && (
|
||||||
<WorkstationLifecycleDialog
|
<WorkstationLifecycleDialog
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useListQuery } from "@metis/core/client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
@ -9,14 +9,18 @@ import { workstationRelations } from "../helpers/workstation-relations";
|
|||||||
export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) {
|
export function useWorkstationDetailController(company: CompanyDTO, workstationId: string) {
|
||||||
const [creatingProfile, setCreatingProfile] = useState(false);
|
const [creatingProfile, setCreatingProfile] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const inventory = useQuery({
|
const inventory = useListQuery({
|
||||||
queryKey: inventoryKeys.company(company.id),
|
queryKey: inventoryKeys.company(company.id),
|
||||||
queryFn: () => inventoryApi.get(company.id),
|
queryFn: () => inventoryApi.get(company.id),
|
||||||
});
|
});
|
||||||
const profiles = useQuery({
|
const profiles = useListQuery({
|
||||||
queryKey: profileKeys.company(company.id),
|
queryKey: profileKeys.company(company.id),
|
||||||
queryFn: () => profileApi.get(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 workstation = inventory.data?.workstations.find((item) => item.id === workstationId);
|
||||||
|
|
||||||
const { relatedProfiles, personIds } = workstationRelations(
|
const { relatedProfiles, personIds } = workstationRelations(
|
||||||
@ -29,6 +33,7 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
|
|||||||
creatingProfile,
|
creatingProfile,
|
||||||
setCreatingProfile,
|
setCreatingProfile,
|
||||||
inventory,
|
inventory,
|
||||||
|
catalog,
|
||||||
profiles,
|
profiles,
|
||||||
workstation,
|
workstation,
|
||||||
editing,
|
editing,
|
||||||
@ -37,3 +42,5 @@ export function useWorkstationDetailController(company: CompanyDTO, workstationI
|
|||||||
people,
|
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 { BrowserLink } from "@metis/core/client";
|
||||||
import { DetailField } from "@repo/rdx-ui/components/detail-field";
|
import { DetailPanel } from "@repo/rdx-ui/components/detail-panel";
|
||||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
import { EmptyState } from "@repo/rdx-ui/components/empty-state";
|
||||||
import { LaptopIcon } from "@repo/rdx-ui/lib/icons";
|
import { LaptopIcon, MonitorIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function WorkstationAssets({
|
export function WorkstationAssets({
|
||||||
companyId,
|
companyId,
|
||||||
workstation,
|
workstation,
|
||||||
assets,
|
assets,
|
||||||
|
catalog = [],
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
workstation: WorkstationDTO;
|
workstation: WorkstationDTO;
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
|
catalog?: readonly CatalogItemDTO[] | undefined;
|
||||||
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const root = `/companies/${companyId}`;
|
const ids = [...new Set(workstation.values.assetIds)];
|
||||||
const assetIds = 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) =>
|
const card = (id: string) => {
|
||||||
assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
|
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 (
|
return (
|
||||||
<section aria-labelledby="workstation-assets" className="space-y-3">
|
<DetailPanel count={ids.length} icon={<LaptopIcon />} title={t("workstationDetail.assets")}>
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold" id="workstation-assets">
|
{computer ? (
|
||||||
<LaptopIcon aria-hidden="true" className="size-5 text-muted-foreground" />
|
<div className="space-y-3">
|
||||||
{t("workstationDetail.assets")}{" "}
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<span className="text-sm font-normal text-muted-foreground">{assetIds.size}</span>
|
{t("workstationComputer")}
|
||||||
</h2>
|
</h3>
|
||||||
{!assetIds.size && (
|
<ul className="grid gap-3">
|
||||||
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
|
<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">
|
{peripherals.length > 0 && (
|
||||||
{[...assetIds].map((id) => {
|
<div className="space-y-3">
|
||||||
const asset = assets.find((item) => item.id === id);
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("workstationDetail.peripherals")}
|
||||||
return (
|
</h3>
|
||||||
<article className="min-w-0 space-y-3 rounded-xl border bg-card p-4" key={id}>
|
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
|
||||||
<div className="space-y-1">
|
{peripherals.map((id) => (
|
||||||
<BrowserLink className="font-medium" href={`${root}/assets/${id}`}>
|
<li className="min-w-0" key={id}>
|
||||||
{assetName(id)}
|
{card(id)}
|
||||||
</BrowserLink>
|
</li>
|
||||||
<p className="text-xs text-muted-foreground">
|
))}
|
||||||
{id === workstation.values.computerAssetId
|
</ul>
|
||||||
? t("workstationComputer")
|
</div>
|
||||||
: t("workstationAssets")}
|
)}
|
||||||
</p>
|
</DetailPanel>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { BrowserLink, navigateTo } from "@metis/core/client";
|
||||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
import { PersonAvatar } from "../../../people/shared";
|
|
||||||
import { SoftwareInstallationsPanel } from "../../../software-installations/list/ui/software-installations-panel";
|
|
||||||
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
|
import { WorkProfileEditorDialog } from "../../../work-profiles/shared";
|
||||||
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
|
import { useWorkstationDetailController } from "../controllers/use-workstation-detail-controller";
|
||||||
|
|
||||||
import { WorkstationAssets } from "./workstation-assets";
|
import { WorkstationDetailContent } from "./workstation-detail-content";
|
||||||
|
|
||||||
export function WorkstationDetailPage({
|
export function WorkstationDetailPage({
|
||||||
company,
|
company,
|
||||||
@ -24,7 +26,7 @@ export function WorkstationDetailPage({
|
|||||||
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
|
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const { t: profileTranslation } = useTranslation("companies");
|
const c = useWorkstationDetailController(company, workstationId);
|
||||||
const {
|
const {
|
||||||
creatingProfile,
|
creatingProfile,
|
||||||
setCreatingProfile,
|
setCreatingProfile,
|
||||||
@ -33,19 +35,12 @@ export function WorkstationDetailPage({
|
|||||||
workstation,
|
workstation,
|
||||||
editing,
|
editing,
|
||||||
setEditing,
|
setEditing,
|
||||||
relatedProfiles,
|
} = c;
|
||||||
people,
|
|
||||||
} = useWorkstationDetailController(company, workstationId);
|
|
||||||
const root = `/companies/${company.id}`;
|
const root = `/companies/${company.id}`;
|
||||||
|
const responsible = company.people.find((person) => person.id === workstation?.values.personId);
|
||||||
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");
|
|
||||||
|
|
||||||
return (
|
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`}>
|
<BrowserLink className="inline-flex items-center gap-1 text-sm" href={`${root}/workstations`}>
|
||||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||||
{t("workstationDetail.back")}
|
{t("workstationDetail.back")}
|
||||||
@ -69,168 +64,53 @@ export function WorkstationDetailPage({
|
|||||||
)}
|
)}
|
||||||
{workstation && (
|
{workstation && (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="space-y-4 rounded-xl border border-border/70 bg-card p-5 sm:p-6">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
<MonitorIcon aria-hidden="true" className="size-6 text-muted-foreground" />
|
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
{workstation.values.name}
|
<MonitorIcon aria-hidden="true" className="size-6" />
|
||||||
</h1>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
<div className="min-w-0 space-y-2">
|
||||||
<EntityStatus active={workstation.values.active}>
|
<WorkstationNameEditor key={workstation.id} workstation={workstation} />
|
||||||
{t(workstation.values.active ? "active" : "inactive")}
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
|
||||||
</EntityStatus>
|
<EntityStatus active={workstation.values.active}>
|
||||||
{workstation.values.siteId && (
|
{t(workstation.values.active ? "active" : "inactive")}
|
||||||
<BrowserLink href={`${root}/sites`}>
|
</EntityStatus>
|
||||||
{company.sites.find((site) => site.id === workstation.values.siteId)?.name ??
|
<span className="inline-flex min-w-0 items-center gap-2">
|
||||||
t("noValue")}
|
<MapPinIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
</BrowserLink>
|
<span className="sr-only">{t("site")}: </span>
|
||||||
)}
|
{workstation.values.siteId ? (
|
||||||
{workstation.values.personId && (
|
<BrowserLink href={`${root}/sites`}>
|
||||||
<span className="inline-flex flex-wrap items-center gap-1">
|
{company.sites.find((site) => site.id === workstation.values.siteId)
|
||||||
<span className="text-muted-foreground">{t("workstationPerson")}:</span>
|
?.name ?? "—"}
|
||||||
<BrowserLink href={`${root}/people/${workstation.values.personId}`}>
|
</BrowserLink>
|
||||||
{personName(workstation.values.personId)}
|
) : (
|
||||||
</BrowserLink>
|
"—"
|
||||||
</span>
|
)}
|
||||||
)}
|
</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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
|
<WorkstationActions
|
||||||
</div>
|
onDecommissioned={() => {
|
||||||
<WorkstationActions
|
profiles.refetch().catch(() => undefined);
|
||||||
onDecommissioned={() => {
|
}}
|
||||||
profiles.refetch().catch(() => undefined);
|
onDeleted={() => navigateTo(`${root}/workstations`, { replace: true })}
|
||||||
}}
|
onEdit={() => setEditing(true)}
|
||||||
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}
|
|
||||||
workstation={workstation}
|
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>
|
||||||
<div className="space-y-6">
|
</header>
|
||||||
<section aria-labelledby="workstation-people" className="space-y-3">
|
<WorkstationDetailContent company={company} controller={c} />
|
||||||
<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>
|
|
||||||
{creatingProfile && (
|
{creatingProfile && (
|
||||||
<WorkProfileEditorDialog
|
<WorkProfileEditorDialog
|
||||||
company={company}
|
company={company}
|
||||||
|
|||||||
@ -1,19 +1,25 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
import { assetsEnglish, assetsSpanish, inventoryKeys } from "@metis/assets/client";
|
import { assetsEnglish, assetsSpanish, catalogKeys, inventoryKeys } from "@metis/assets/client";
|
||||||
import { WorkstationSchema } from "@metis/assets/common";
|
import type { AssetDTO, WorkstationDTO } from "@metis/assets/common";
|
||||||
|
import { AssetSchema, WorkstationSchema } from "@metis/assets/common";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { I18nextProvider } from "react-i18next";
|
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 { WorkProfileSchema } from "../../../../common/work-profiles";
|
||||||
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
|
import { CurrentWorkstations } from "../../../people/detail/ui/current-workstations";
|
||||||
import { companiesEnglish, companiesSpanish } from "../../../shared";
|
import { companiesEnglish, companiesSpanish } from "../../../shared";
|
||||||
import { profileKeys } from "../../../work-profiles/shared/api";
|
import { profileKeys } from "../../../work-profiles/shared/api";
|
||||||
|
|
||||||
import { WorkstationDetailPage } from "./workstation-detail-page";
|
import { WorkstationDetailPage } from "./workstation-detail-page";
|
||||||
|
import { WorkstationPeople } from "./workstation-people";
|
||||||
|
import { WorkstationProfiles } from "./workstation-profiles";
|
||||||
|
|
||||||
const companyId = "0199b200-0000-7000-8000-000000000001";
|
const companyId = "0199b200-0000-7000-8000-000000000001";
|
||||||
const workstationId = "0199b200-0000-7000-8000-000000000002";
|
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();
|
const i18n = createInstance();
|
||||||
|
|
||||||
await i18n.init({
|
await i18n.init({
|
||||||
@ -64,25 +81,28 @@ async function render(language: string, exists: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
client.setQueryData(inventoryKeys.company(companyId), {
|
client.setQueryData(inventoryKeys.company(companyId), {
|
||||||
assets: [],
|
assets: options.assets ?? [],
|
||||||
relationships: [],
|
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 {
|
try {
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<WorkstationDetailPage
|
{options.node ?? (
|
||||||
company={company}
|
<WorkstationDetailPage
|
||||||
lookups={{
|
company={{ ...company, people: options.people ?? [], sites: options.sites ?? [] }}
|
||||||
people: [],
|
lookups={{
|
||||||
sites: [],
|
people: [],
|
||||||
onCreatePerson: () => undefined,
|
sites: [],
|
||||||
onCreateSite: () => undefined,
|
onCreatePerson: () => undefined,
|
||||||
}}
|
onCreateSite: () => undefined,
|
||||||
workstationId={workstationId}
|
}}
|
||||||
/>
|
workstationId={workstationId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</I18nextProvider>
|
</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 () => {
|
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"]) {
|
for (const language of ["es", "en"]) {
|
||||||
const html = await render(language, true);
|
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("Administración"));
|
||||||
assert.ok(html.includes(`href="/companies/${companyId}/workstations"`));
|
assert.ok(html.includes(`href="/companies/${companyId}/workstations"`));
|
||||||
assert.ok(html.includes(language === "es" ? "Editar puesto" : "Edit workstation"));
|
assert.ok(html.includes(labels.editWorkstation));
|
||||||
assert.ok(html.includes(language === "es" ? "Desactivar" : "Deactivate"));
|
assert.ok(html.includes(`aria-label="${labels.workstationDetail.rename}"`));
|
||||||
assert.ok(html.includes(language === "es" ? "Eliminar definitivamente" : "Delete permanently"));
|
const header = html.slice(html.indexOf("<header"), html.indexOf("</header>"));
|
||||||
assert.ok(html.includes(language === "es" ? "Perfiles de usuario" : "User profiles"));
|
|
||||||
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));
|
assert.ok(header.includes(`aria-label="${labels.workstationDetail.actions}"`));
|
||||||
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
|
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.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,
|
IconMail as MailIcon,
|
||||||
IconMapPin as MapPinIcon,
|
IconMapPin as MapPinIcon,
|
||||||
IconMouse as MouseIcon,
|
IconMouse as MouseIcon,
|
||||||
|
IconPencil as PencilIcon,
|
||||||
IconPhone as PhoneIcon,
|
IconPhone as PhoneIcon,
|
||||||
IconPlus as PlusIcon,
|
IconPlus as PlusIcon,
|
||||||
IconPrinter as PrinterIcon,
|
IconPrinter as PrinterIcon,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user