Eliminar y dar de baja un puesto de trabajo
This commit is contained in:
parent
7acf31f144
commit
bf02e56d76
@ -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}`),
|
||||
|
||||
209
apps/server/src/workstation-lifecycle.integration.ts
Normal file
209
apps/server/src/workstation-lifecycle.integration.ts
Normal 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);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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, []);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user