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 { verifyTelework } from "./telework.integration";
|
||||||
import { verifyTickets } from "./tickets.integration";
|
import { verifyTickets } from "./tickets.integration";
|
||||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||||
|
import { verifyWorkstationLifecycle } from "./workstation-lifecycle.integration";
|
||||||
import { verifyWorkstations } from "./workstations.integration";
|
import { verifyWorkstations } from "./workstations.integration";
|
||||||
|
|
||||||
test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => {
|
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 verifyPersistence(database);
|
||||||
await verifyRecordDeletion((path, body) => operator.request(path, body), 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 }>(
|
const countAfterAccess = await database.query<{ total: number }>(
|
||||||
"SELECT COUNT(*) AS total FROM users",
|
"SELECT COUNT(*) AS total FROM users",
|
||||||
{ type: QueryTypes.SELECT }
|
{ 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(Number(countAfterAccess[0]?.total), 2);
|
||||||
assert.equal((await operator.request("/auth/logout", {})).status, 200);
|
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("/access/global"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(
|
await expectError(
|
||||||
await operator.request(`/work-profiles/${customer.id}`),
|
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.
|
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
|
## 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.
|
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) {
|
for (const action of ["decommission", "delete"] as const) {
|
||||||
router.post(`/:companyId/workstations/:id/${action}`, (request, response, next) => {
|
router.post(`/:companyId/workstations/:id/${action}`, (request, response, next) => {
|
||||||
const params = AssetParamsSchema.safeParse(request.params);
|
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");
|
fail(response, "INVALID_REQUEST");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "delete") {
|
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) {
|
if (!result.isSuccess) {
|
||||||
fail(response, result.error.code);
|
fail(response, result.error.code);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.json({ deleted: true });
|
response.json({
|
||||||
}).catch(next);
|
workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames),
|
||||||
|
});
|
||||||
return;
|
})
|
||||||
}
|
.catch(next);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
app.use("/api/assets", authenticate, router);
|
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.
|
// 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_assets WHERE workstationId=:id", {
|
||||||
await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", { replacements, transaction });
|
replacements,
|
||||||
await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", { replacements, transaction });
|
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);
|
return Result.ok(undefined);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,4 +7,5 @@ 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 { 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";
|
||||||
|
|||||||
@ -10,10 +10,10 @@ 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";
|
||||||
|
|
||||||
import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog";
|
|
||||||
import { InventoryFeedback } from "../../../shared";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
import { WorkstationHistoryDialog } from "../../history";
|
import { WorkstationHistoryDialog } from "../../history";
|
||||||
import { WorkstationEditorDialog } from "../../shared";
|
import { WorkstationEditorDialog } from "../../shared";
|
||||||
|
import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog";
|
||||||
import { useWorkstationsPageController } from "../controllers";
|
import { useWorkstationsPageController } from "../controllers";
|
||||||
import type { WorkstationsPageProps } from "../types";
|
import type { WorkstationsPageProps } from "../types";
|
||||||
|
|
||||||
@ -140,8 +140,22 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
{t("workstationHistory")}
|
{t("workstationHistory")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</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>}
|
{(item.values.active ||
|
||||||
<EntityAction className="text-destructive" onClick={() => setOperation({ id: item.id, action: "delete" })}>{t("workstationLifecycle.delete.title")}</EntityAction>
|
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>{" "}
|
</div>{" "}
|
||||||
</EntityRowActions>
|
</EntityRowActions>
|
||||||
),
|
),
|
||||||
@ -156,7 +170,13 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
title={t("workstations")}
|
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 && (
|
{editor && (
|
||||||
<WorkstationEditorDialog
|
<WorkstationEditorDialog
|
||||||
companyId={companyId}
|
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 { inventoryRequest } from "../../../shared/api/inventory-request";
|
||||||
import { inventoryKeys } from "../../../shared/queries";
|
import { inventoryKeys } from "../../../shared/queries";
|
||||||
|
|
||||||
export function WorkstationLifecycleDialog({ workstation, action, onClose, onCompleted }: {
|
export function WorkstationLifecycleDialog({
|
||||||
|
workstation,
|
||||||
|
action,
|
||||||
|
onClose,
|
||||||
|
onCompleted,
|
||||||
|
}: {
|
||||||
workstation: WorkstationDTO;
|
workstation: WorkstationDTO;
|
||||||
action: "decommission" | "delete";
|
action: "decommission" | "delete";
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -21,31 +26,44 @@ export function WorkstationLifecycleDialog({ workstation, action, onClose, onCom
|
|||||||
const [errorCode, setErrorCode] = useState<string>();
|
const [errorCode, setErrorCode] = useState<string>();
|
||||||
const prefix = `workstationLifecycle.${action}`;
|
const prefix = `workstationLifecycle.${action}`;
|
||||||
|
|
||||||
return <ConfirmDeleteDialog
|
return (
|
||||||
open
|
<ConfirmDeleteDialog
|
||||||
title={t(`${prefix}.title`)}
|
cancelLabel={t("cancel")}
|
||||||
description={t(`${prefix}.description`, { name: workstation.values.name })}
|
confirmLabel={t(`${prefix}.title`)}
|
||||||
cancelLabel={t("cancel")}
|
description={t(`${prefix}.description`, { name: workstation.values.name })}
|
||||||
confirmLabel={t(`${prefix}.title`)}
|
errorLabel={t(
|
||||||
pendingLabel={t(`${prefix}.pending`)}
|
errorCode === "WORKSTATION_IN_USE" ? "workstationLifecycle.inUse" : `${prefix}.error`
|
||||||
errorLabel={t(errorCode === "WORKSTATION_IN_USE" ? "workstationLifecycle.inUse" : `${prefix}.error`)}
|
)}
|
||||||
onOpenChange={(open) => { if (!open) { onClose(); } }}
|
onConfirm={async () => {
|
||||||
onConfirm={async () => {
|
setErrorCode(undefined);
|
||||||
setErrorCode(undefined);
|
try {
|
||||||
try {
|
const body = await inventoryRequest(
|
||||||
const body = await inventoryRequest(`${workstation.companyId}/workstations/${workstation.id}/${action}`, {});
|
`${workstation.companyId}/workstations/${workstation.id}/${action}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
if (action === "delete") {
|
if (action === "delete") {
|
||||||
z.strictObject({ deleted: z.literal(true) }).parse(body);
|
z.strictObject({ deleted: z.literal(true) }).parse(body);
|
||||||
} else {
|
} else {
|
||||||
WorkstationResponseSchema.parse(body);
|
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?.();
|
onOpenChange={(open) => {
|
||||||
} catch (error) {
|
if (!open) {
|
||||||
setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR");
|
onClose();
|
||||||
throw error;
|
}
|
||||||
}
|
}}
|
||||||
}}
|
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 { 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 { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
import { BriefcaseIcon, ChevronLeftIcon, 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 { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
@ -25,7 +24,6 @@ export function WorkstationDetailPage({
|
|||||||
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
|
lookups: ComponentProps<typeof WorkstationEditorDialog>["lookups"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const [operation, setOperation] = useState<"decommission" | "delete">();
|
|
||||||
const { t: profileTranslation } = useTranslation("companies");
|
const { t: profileTranslation } = useTranslation("companies");
|
||||||
const {
|
const {
|
||||||
creatingProfile,
|
creatingProfile,
|
||||||
@ -98,11 +96,14 @@ export function WorkstationDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
|
<p className="text-sm text-muted-foreground">{t("workstationDetail.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<WorkstationActions
|
||||||
<Button onClick={() => setEditing(true)} variant="outline">{t("editWorkstation")}</Button>
|
onDecommissioned={() => {
|
||||||
{(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>}
|
profiles.refetch().catch(() => undefined);
|
||||||
<Button onClick={() => setOperation("delete")} variant="destructive">{t("workstationLifecycle.delete.title")}</Button>
|
}}
|
||||||
</div>
|
onDeleted={() => navigateTo(`${root}/workstations`, { replace: true })}
|
||||||
|
onEdit={() => setEditing(true)}
|
||||||
|
workstation={workstation}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(280px,1fr)]">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -230,15 +231,6 @@ export function WorkstationDetailPage({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{creatingProfile && (
|
||||||
<WorkProfileEditorDialog
|
<WorkProfileEditorDialog
|
||||||
company={company}
|
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("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(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" ? "Perfiles de usuario" : "User profiles"));
|
||||||
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));
|
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));
|
||||||
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
|
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user