Eliminar y dar de baja un puesto de trabajo

This commit is contained in:
David Arranz 2026-10-13 10:19:01 +02:00
parent 7acf31f144
commit bf02e56d76
12 changed files with 465 additions and 67 deletions

View File

@ -55,6 +55,7 @@ import { verifySupport } from "./support.integration";
import { verifyTelework } from "./telework.integration";
import { verifyTickets } from "./tickets.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
import { verifyWorkstationLifecycle } from "./workstation-lifecycle.integration";
import { verifyWorkstations } from "./workstations.integration";
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
@ -1000,6 +1001,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
}
await verifyPersistence(database);
await verifyRecordDeletion((path, body) => operator.request(path, body), database);
await verifyWorkstationLifecycle(
(...args) => operator.request(...args),
customer.id,
secondCompany.id,
database
);
const countAfterAccess = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM users",
{ type: QueryTypes.SELECT }
@ -1007,6 +1014,15 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
assert.equal(Number(countAfterAccess[0]?.total), 2);
assert.equal((await operator.request("/auth/logout", {})).status, 200);
// Sesión anónima con CSRF válido: se prueba autenticación, no rechazo del token caducado.
assert.equal((await operator.session()).user, null);
for (const action of ["decommission", "delete"]) {
await expectError(
await operator.request(`/assets/${customer.id}/workstations/${customer.id}/${action}`, {}),
401,
"UNAUTHENTICATED"
);
}
await expectError(await operator.request("/access/global"), 401, "UNAUTHENTICATED");
await expectError(
await operator.request(`/work-profiles/${customer.id}`),

View File

@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import {
AssetResponseSchema,
InventoryResponseSchema,
WorkstationResponseSchema,
} from "@metis/assets/common";
import {
CompanyResponseSchema,
TicketListSchema,
TicketResponseSchema,
} from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyWorkstationLifecycle(
request: Request,
companyId: string,
otherId: string,
database: Sequelize
) {
const path = `/assets/${companyId}/workstations`;
const inventory = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
const source = inventory.assets[0];
assert.ok(source);
const assets = [];
for (const name of ["Equipo de baja", "Impresora compartida de baja"]) {
const response = await request(`/assets/${companyId}`, {
values: { ...source.values, name, status: "available" },
});
assert.equal(response.status, 201, await response.clone().text());
assets.push(AssetResponseSchema.parse(await response.json()).asset);
}
const [computer, shared] = assets;
assert.ok(computer && shared);
const personResponse = await request(`/companies/${companyId}/people`, {
name: "Responsable baja",
position: null,
email: null,
phone: null,
active: true,
isContact: false,
});
assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person);
const values = {
name: "Puesto para baja",
active: true,
siteId: source.values.siteId,
personId: person.id,
computerAssetId: computer.id,
assetIds: [computer.id],
sharedAssetIds: [shared.id],
};
const create = async (data = values) => {
const response = await request(path, { values: data });
assert.equal(response.status, 201, await response.clone().text());
return WorkstationResponseSchema.parse(await response.json()).workstation;
};
const station = await create();
const retirePath = `${path}/${station.id}/decommission`;
const deletePath = `${path}/${station.id}/delete`;
for (const action of ["decommission", "delete"]) {
assert.equal(
(await request(`/assets/${otherId}/workstations/${station.id}/${action}`, {})).status,
404
);
assert.equal(
(await request(`${path}/${station.id}/${action}`, {}, "https://foreign.example")).status,
403
);
assert.equal(
(await request(`${path}/${station.id}/${action}`, {}, undefined, "a".repeat(64))).status,
403
);
assert.equal(
(await request(`${path}/${station.id}/${action}`, { actorId: person.id })).status,
400
);
}
const response = await request(retirePath, {});
assert.equal(response.status, 200);
const retired = WorkstationResponseSchema.parse(await response.json()).workstation;
assert.equal(retired.values.active, false);
assert.equal(retired.values.personId, null);
assert.equal(retired.values.computerAssetId ?? null, null);
assert.deepEqual(retired.values.assetIds, []);
assert.deepEqual(retired.values.sharedAssetIds, []);
assert.equal(retired.history.length, 2);
assert.deepEqual(retired.history[0]?.values.assetIds, [computer.id]);
assert.deepEqual(
WorkstationResponseSchema.parse(await (await request(retirePath, {})).json()).workstation,
retired
);
// Ambas clases de composición pueden reservarse de nuevo tras la baja.
const replacement = await create({
...values,
name: "Puesto reutiliza activos",
assetIds: [computer.id, shared.id],
sharedAssetIds: [],
});
const protectedResponse = await request(`/tickets/${companyId}`, {
values: {
subject: "Conservar referencia a puesto",
description: "",
requesterId: null,
assigneeId: null,
state: "open",
priority: "normal",
channel: "internal",
affected: [{ kind: "workstation", targetId: replacement.id }],
},
requestKey: randomUUID(),
expectedRevisionId: null,
});
assert.equal(protectedResponse.status, 201, await protectedResponse.clone().text());
const ticket = TicketResponseSchema.parse(await protectedResponse.json()).ticket;
const blocked = await request(`${path}/${replacement.id}/delete`, {});
assert.equal(blocked.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await blocked.json()).code,
"WORKSTATION_IN_USE"
);
const retained = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).workstations.find((item) => item.id === replacement.id);
assert.deepEqual(retained, replacement);
assert.equal((await request(`${path}/${replacement.id}/decommission`, {})).status, 200);
const retainedTicket = TicketListSchema.parse(
await (await request(`/tickets/${companyId}`)).json()
).tickets.find((item) => item.id === ticket.id);
assert.ok(retainedTicket);
assert.deepEqual(retainedTicket.values.affected, ticket.values.affected);
assert.equal((await request(deletePath, {})).status, 200);
assert.equal((await request(deletePath, {})).status, 404);
const after = InventoryResponseSchema.parse(await (await request(`/assets/${companyId}`)).json());
assert.equal(
after.workstations.some((item) => item.id === station.id),
false
);
assert.ok(after.assets.some((item) => item.id === computer.id));
assert.ok(after.assets.some((item) => item.id === shared.id));
const rows = await database.query<{ total: number }>(
"SELECT COUNT(*) AS total FROM workstation_revisions WHERE workstationId=:id",
{ replacements: { id: station.id }, type: QueryTypes.SELECT }
);
assert.equal(Number(rows[0]?.total), 0);
for (const table of ["workstation_assets", "workstation_members"]) {
const remaining = await database.query<{ total: number }>(
`SELECT COUNT(*) AS total FROM ${table} WHERE workstationId=:id`,
{ replacements: { id: station.id }, type: QueryTypes.SELECT }
);
assert.equal(Number(remaining[0]?.total), 0);
}
// La eliminación directa de un puesto activo libera su reserva sin borrar activos.
const direct = await create({ ...values, name: "Eliminar puesto activo", sharedAssetIds: [] });
assert.equal((await request(`${path}/${direct.id}/delete`, {})).status, 200);
const reusable = await create({
...values,
name: "Reserva después de eliminación",
sharedAssetIds: [],
});
assert.equal((await request(`${path}/${reusable.id}/delete`, {})).status, 200);
const concurrent = await create({ ...values, name: "Puesto concurrente", sharedAssetIds: [] });
const candidate = { ...values, name: "Nueva reserva concurrente", sharedAssetIds: [] };
const [retirement, reservation] = await Promise.all([
request(`${path}/${concurrent.id}/decommission`, {}),
request(path, { values: candidate }),
]);
assert.equal(retirement.status, 200);
assert.ok([201, 409].includes(reservation.status));
const newStation =
reservation.status === 201
? WorkstationResponseSchema.parse(await reservation.json()).workstation
: await create(candidate);
assert.equal((await request(`${path}/${newStation.id}/delete`, {})).status, 200);
assert.equal((await request(`${path}/${concurrent.id}/delete`, {})).status, 200);
}

View File

@ -18,7 +18,7 @@ Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe
El responsable del puesto no cambia automáticamente la sede ni el estado de cada activo, ni identifica a todos sus usuarios. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.
La eliminación definitiva se realiza mediante una operación explícita con confirmación; véase Baja y eliminación. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.
## Arquitectura y API
@ -47,3 +47,18 @@ 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.
## Baja y eliminación
El listado y la ficha completa ofrecen **Dar de baja** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.
Dar de baja deja el puesto inactivo, vacía sus activos exclusivos y compartidos y retira el ordenador principal y responsable. Conserva nombre, sede, UUID e historial. Las asociaciones anteriores permanecen en las revisiones, pero no reservan activos ni representan usos vigentes. Repetir la baja no añade revisiones. También se puede aplicar a un puesto ya inactivo que conserve composición. Se puede editar posteriormente para reconstruirlo y reactivarlo, comprobando las reservas actuales.
Eliminar definitivamente borra el puesto, revisiones y asociaciones técnicas en una transacción y libera sus reservas, incluso si seguía activo. Conserva los activos, personas y sedes. Las referencias externas, incluidos tickets e intervenciones actuales o históricos, impiden la eliminación con `WORKSTATION_IN_USE`; la alternativa es dar de baja. La comprobación utiliza las claves foráneas declaradas para proteger también futuras referencias. Un error no elimina parcialmente la composición ni sus reservas.
En este modelo los recursos compartidos del puesto son activos compartidos (por ejemplo impresoras). Los perfiles de usuario, correo, permisos, certificados y software pertenecen a personas, cuentas o al ordenador, y no son asignaciones directas del puesto. La baja libera la composición del puesto; conserva esos registros y no simula desinstalaciones ni retirada de accesos externos. Los perfiles sin un puesto vigente permanecen para revisión. La ficha actualiza los datos después de confirmar la operación; tras eliminar vuelve al listado.
`POST /api/assets/:companyId/workstations/:id/decommission` y `POST /api/assets/:companyId/workstations/:id/delete` reciben cuerpo vacío `{}`. Requieren sesión, origen y CSRF; compañía y puesto se validan en servidor. La baja registra actor de sesión y fecha en la revisión. Ambas operaciones comparten los bloqueos de catálogo y compañía con las reservas de activos. La baja conserva las referencias históricas; la eliminación elimina las asociaciones internas mediante las claves foráneas y las revisiones, sin desactivar restricciones ni persistir JSON.
Las pruebas cubren baja activa/inactiva, liberación de reservas exclusivas y compartidas, ordenador y responsable, historial e idempotencia, eliminación activa/inactiva, referencias externas, rollback lógico, pertenencia a compañía, cuerpo estricto y protección CSRF/origen.

View File

@ -145,3 +145,49 @@ test("Puestos: ordenador principal exclusivo, único e independiente del respons
)
);
});
test("baja del puesto: libera composición y responsable, conserva historial e identidad y es idempotente", () => {
const actor = UniqueID.generateNewID();
const shared = UniqueID.generateNewID().toPrimitive();
const created = Workstation.create(
UniqueID.generateNewID(),
values({
...input(),
computerAssetId: assetIds[0] ?? null,
personId: actor.toPrimitive(),
sharedAssetIds: [shared],
}),
actor,
"2026-10-13T10:00:00.000Z"
);
assert.ok(created.isSuccess);
const retired = created.data.decommission(actor, "2026-10-13T11:00:00.000Z");
assert.ok(retired.isSuccess);
assert.ok(retired.data.id.equals(created.data.id));
assert.equal(retired.data.history.length, 2);
assert.equal(retired.data.values.active, false);
assert.equal(retired.data.values.toPrimitive().personId, null);
assert.equal(retired.data.values.computerAssetId.getOrUndefined(), undefined);
assert.deepEqual(retired.data.values.toPrimitive().assetIds, []);
assert.deepEqual(retired.data.values.toPrimitive().sharedAssetIds, []);
assert.deepEqual(retired.data.history[0]?.values.toPrimitive().assetIds, assetIds);
assert.deepEqual(retired.data.history[0]?.values.toPrimitive().sharedAssetIds, [shared]);
assert.ok(retired.data.history[1]?.actorId.equals(actor));
const repeated = retired.data.decommission(actor, "2026-10-13T12:00:00.000Z");
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, retired.data);
const previouslyInactive = created.data.update(
values({ ...created.data.values.toPrimitive(), active: false }),
actor,
"2026-10-13T10:30:00.000Z"
);
assert.ok(previouslyInactive.isSuccess);
const cleared = previouslyInactive.data.decommission(actor, "2026-10-13T11:00:00.000Z");
assert.ok(cleared.isSuccess);
assert.deepEqual(cleared.data.values.assetIds, []);
});

View File

@ -63,33 +63,50 @@ export function registerWorkstationRoutes(
for (const action of ["decommission", "delete"] as const) {
router.post(`/:companyId/workstations/:id/${action}`, (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params);
const actor = z.object({ userId: z.uuid() }).safeParse("session" in request ? request.session : undefined);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (!(params.success && actor.success && z.strictObject({}).safeParse(request.body).success && z.strictObject({}).safeParse(request.query).success)) {
if (
!(
params.success &&
actor.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
if (action === "delete") {
useCases.remove(params.data.companyId, params.data.id).then((result) => {
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
return;
}
useCases
.decommission(params.data.companyId, params.data.id, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
}).catch(next);
return;
}
useCases.decommission(params.data.companyId, params.data.id, actor.data.userId).then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames) });
}).catch(next);
response.json({
workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames),
});
})
.catch(next);
});
}
app.use("/api/assets", authenticate, router);

View File

@ -203,9 +203,18 @@ export class SequelizeAssetRepository implements IAssetRepository {
}
}
// Las asociaciones de composición se eliminan; sus activos no se borran.
await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", { replacements, transaction });
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", { replacements, transaction });
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", { replacements, transaction });
await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", {
replacements,
transaction,
});
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", {
replacements,
transaction,
});
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", {
replacements,
transaction,
});
return Result.ok(undefined);
});

View File

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

View File

@ -10,10 +10,10 @@ import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog";
import { InventoryFeedback } from "../../../shared";
import { WorkstationHistoryDialog } from "../../history";
import { WorkstationEditorDialog } from "../../shared";
import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog";
import { useWorkstationsPageController } from "../controllers";
import type { WorkstationsPageProps } from "../types";
@ -140,8 +140,22 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
{t("workstationHistory")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
{(item.values.active || item.values.assetIds.length > 0 || item.values.sharedAssetIds.length > 0 || item.values.personId) && <EntityAction onClick={() => setOperation({ id: item.id, action: "decommission" })}>{t("workstationLifecycle.decommission.title")}</EntityAction>}
<EntityAction className="text-destructive" onClick={() => setOperation({ id: item.id, action: "delete" })}>{t("workstationLifecycle.delete.title")}</EntityAction>
{(item.values.active ||
item.values.assetIds.length > 0 ||
item.values.sharedAssetIds.length > 0 ||
item.values.personId) && (
<EntityAction
onClick={() => setOperation({ id: item.id, action: "decommission" })}
>
{t("workstationLifecycle.decommission.title")}
</EntityAction>
)}
<EntityAction
className="text-destructive"
onClick={() => setOperation({ id: item.id, action: "delete" })}
>
{t("workstationLifecycle.delete.title")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
@ -156,7 +170,13 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
title={t("workstations")}
/>
)}
{targeted && operation && <WorkstationLifecycleDialog workstation={targeted} action={operation.action} onClose={() => setOperation(undefined)} />}
{targeted && operation && (
<WorkstationLifecycleDialog
action={operation.action}
onClose={() => setOperation(undefined)}
workstation={targeted}
/>
)}
{editor && (
<WorkstationEditorDialog
companyId={companyId}

View File

@ -0,0 +1,53 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { WorkstationDTO } from "../../../../common/workstations";
import { WorkstationLifecycleDialog } from "./workstation-lifecycle-dialog";
export function WorkstationActions({
workstation,
onEdit,
onDeleted,
onDecommissioned,
}: {
workstation: WorkstationDTO;
onEdit: () => void;
onDeleted: () => void;
onDecommissioned: () => void;
}) {
const { t } = useTranslation("assets");
const [action, setAction] = useState<"decommission" | "delete">();
const canDecommission =
workstation.values.active ||
workstation.values.assetIds.length > 0 ||
workstation.values.sharedAssetIds.length > 0 ||
Boolean(workstation.values.personId);
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>
{action && (
<WorkstationLifecycleDialog
action={action}
onClose={() => setAction(undefined)}
onCompleted={action === "delete" ? onDeleted : onDecommissioned}
workstation={workstation}
/>
)}
</>
);
}

View File

@ -10,7 +10,12 @@ import { InventoryApiError } from "../../../shared/api/inventory-api-error";
import { inventoryRequest } from "../../../shared/api/inventory-request";
import { inventoryKeys } from "../../../shared/queries";
export function WorkstationLifecycleDialog({ workstation, action, onClose, onCompleted }: {
export function WorkstationLifecycleDialog({
workstation,
action,
onClose,
onCompleted,
}: {
workstation: WorkstationDTO;
action: "decommission" | "delete";
onClose: () => void;
@ -21,31 +26,44 @@ export function WorkstationLifecycleDialog({ workstation, action, onClose, onCom
const [errorCode, setErrorCode] = useState<string>();
const prefix = `workstationLifecycle.${action}`;
return <ConfirmDeleteDialog
open
title={t(`${prefix}.title`)}
description={t(`${prefix}.description`, { name: workstation.values.name })}
cancelLabel={t("cancel")}
confirmLabel={t(`${prefix}.title`)}
pendingLabel={t(`${prefix}.pending`)}
errorLabel={t(errorCode === "WORKSTATION_IN_USE" ? "workstationLifecycle.inUse" : `${prefix}.error`)}
onOpenChange={(open) => { if (!open) { onClose(); } }}
onConfirm={async () => {
setErrorCode(undefined);
try {
const body = await inventoryRequest(`${workstation.companyId}/workstations/${workstation.id}/${action}`, {});
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t(`${prefix}.title`)}
description={t(`${prefix}.description`, { name: workstation.values.name })}
errorLabel={t(
errorCode === "WORKSTATION_IN_USE" ? "workstationLifecycle.inUse" : `${prefix}.error`
)}
onConfirm={async () => {
setErrorCode(undefined);
try {
const body = await inventoryRequest(
`${workstation.companyId}/workstations/${workstation.id}/${action}`,
{}
);
if (action === "delete") {
z.strictObject({ deleted: z.literal(true) }).parse(body);
} else {
WorkstationResponseSchema.parse(body);
if (action === "delete") {
z.strictObject({ deleted: z.literal(true) }).parse(body);
} else {
WorkstationResponseSchema.parse(body);
}
await client.invalidateQueries({
queryKey: inventoryKeys.company(workstation.companyId),
});
onCompleted?.();
} catch (error) {
setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR");
throw error;
}
await client.invalidateQueries({ queryKey: inventoryKeys.company(workstation.companyId) });
onCompleted?.();
} catch (error) {
setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR");
throw error;
}
}}
/>;
}}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
pendingLabel={t(`${prefix}.pending`)}
title={t(`${prefix}.title`)}
/>
);
}

View File

@ -1,10 +1,9 @@
import { WorkstationEditorDialog, WorkstationLifecycleDialog } from "@metis/assets/client";
import { WorkstationActions, WorkstationEditorDialog } from "@metis/assets/client";
import { BrowserLink, navigateTo } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
@ -25,7 +24,6 @@ export function WorkstationDetailPage({
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
}) {
const { t } = useTranslation("assets");
const [operation, setOperation] = useState<"decommission" | "delete">();
const { t: profileTranslation } = useTranslation("companies");
const {
creatingProfile,
@ -98,11 +96,14 @@ export function WorkstationDetailPage({
</div>
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => setEditing(true)} variant="outline">{t("editWorkstation")}</Button>
{(workstation.values.active || workstation.values.assetIds.length > 0 || workstation.values.sharedAssetIds.length > 0 || workstation.values.personId) && <Button onClick={() => setOperation("decommission")} variant="outline">{t("workstationLifecycle.decommission.title")}</Button>}
<Button onClick={() => setOperation("delete")} variant="destructive">{t("workstationLifecycle.delete.title")}</Button>
</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">
@ -230,15 +231,6 @@ export function WorkstationDetailPage({
</section>
</div>
</div>
{operation && <WorkstationLifecycleDialog
workstation={workstation}
action={operation}
onClose={() => setOperation(undefined)}
onCompleted={() => {
if (operation === "delete") { navigateTo(`${root}/workstations`, { replace: true }); }
else { profiles.refetch().catch(() => undefined); }
}}
/>}
{creatingProfile && (
<WorkProfileEditorDialog
company={company}

View File

@ -99,6 +99,8 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
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" ? "Dar de baja" : "Decommission"));
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"));