Eliminar un activo

This commit is contained in:
David Arranz 2026-10-10 22:47:23 +02:00
parent 45b559ab92
commit 5dfb50e13a
16 changed files with 229 additions and 6 deletions

View File

@ -87,6 +87,15 @@ export async function verifyInventory(
assert.equal(created.history[0]?.id.charAt(14), "7"); assert.equal(created.history[0]?.id.charAt(14), "7");
assert.ok(created.history[0]?.actorName); assert.ok(created.history[0]?.actorName);
assert.ok(created.history[0]?.actorId); assert.ok(created.history[0]?.actorId);
const disposable = await save(path, { ...input, name: "MON-ELIMINAR" });
const deletion = await request(`${path}/${disposable.id}/delete`, {});
assert.equal(deletion.status, 200, await deletion.clone().text());
assert.equal(AssetResponseSchema.parse(await deletion.json()).asset.id, disposable.id);
assert.equal(
(await read()).assets.some((asset) => asset.id === disposable.id),
false
);
assert.equal((await read(`/assets/${otherId}`)).assets.length, 0); assert.equal((await read(`/assets/${otherId}`)).assets.length, 0);
await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404); await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404);
await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404); await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404);

View File

@ -21,6 +21,10 @@ export type InventoryContext = Readonly<{
}>; }>;
export interface IAssetRepository { export interface IAssetRepository {
remove(
company: UniqueID,
id: UniqueID
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>; removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>>;
saveWorkstation( saveWorkstation(
companyId: UniqueID, companyId: UniqueID,

View File

@ -22,6 +22,17 @@ export class AssetUseCases {
return this.repository.get(company.data); return this.repository.get(company.data);
} }
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) { save(companyId: string, id: string | undefined, input: AssetInput, actorId: string) {
const company = UniqueID.create(companyId); const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId); const actor = UniqueID.create(actorId);

View File

@ -55,6 +55,32 @@ export function registerAssetRoutes(
}) })
.catch(next); .catch(next);
}); });
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = AssetParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.query).success &&
z.strictObject({}).safeParse(request.body).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ asset: buildAssetResponse(result.data.asset, result.data.actorNames) });
})
.catch(next);
});
for (const path of ["/:companyId", "/:companyId/:id/update"]) { for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => { router.post(path, (request, response, next) => {
const params = ( const params = (

View File

@ -12,6 +12,7 @@ import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mappe
import { readBindings, readDefinitions } from "./asset-catalog-relational"; import { readBindings, readDefinitions } from "./asset-catalog-relational";
import { readInventory, writeInventory } from "./asset-inventory-relational"; import { readInventory, writeInventory } from "./asset-inventory-relational";
import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper"; import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper";
import { sqlIdentifier } from "./asset-relational-sql";
import { RelationshipModel } from "./asset-relationship.model"; import { RelationshipModel } from "./asset-relationship.model";
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper"; import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
import { loadWorkstations } from "./load-workstations"; import { loadWorkstations } from "./load-workstations";
@ -186,6 +187,78 @@ export class SequelizeAssetRepository implements IAssetRepository {
} }
} }
private async hasExternalReferences(assetId: string, transaction: Transaction) {
const references = await this.database.query<{ tableName: string; columnName: string }>(
"SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='assets' AND REFERENCED_COLUMN_NAME='id'",
{ type: QueryTypes.SELECT, transaction }
);
const ownedTables = new Set([
"asset_characteristics",
"asset_revision_characteristics",
"asset_revisions",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
]);
for (const reference of references) {
if (ownedTables.has(reference.tableName)) {
continue;
}
const rows = await this.database.query<{ value: number }>(
`SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:assetId LIMIT 1`,
{ replacements: { assetId }, type: QueryTypes.SELECT, transaction }
);
if (rows.length) {
return true;
}
}
return false;
}
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
for (const table of [
"asset_revision_characteristics",
"asset_characteristics",
"asset_specification_field_choices",
"asset_specification_fields",
"asset_specification_sections",
"asset_revisions",
]) {
await this.database.query(`DELETE FROM ${sqlIdentifier(table)} WHERE assetId=:assetId`, {
replacements: { assetId },
transaction,
});
}
await this.database.query("DELETE FROM assets WHERE companyId=:companyId AND id=:assetId", {
replacements: { assetId, companyId },
transaction,
});
}
remove(companyId: UniqueID, id: UniqueID) {
return this.database.transaction(async (transaction) => {
const loaded = await this.load(companyId, transaction, true);
if (!loaded.isSuccess) {
return Result.fail(loaded.error);
}
const asset = loaded.data.assets.find((item) => item.id.equals(id));
if (!asset) {
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
}
if (await this.hasExternalReferences(id.toPrimitive(), transaction)) {
return Result.fail(new AssetCatalogError("ASSET_IN_USE"));
}
await this.removeInventory(id.toPrimitive(), companyId.toPrimitive(), transaction);
return Result.ok({ asset, actorNames: loaded.data.actorNames });
});
}
removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> { removeRelationship(company: UniqueID, id: UniqueID): Promise<Result<void, AssetCatalogError>> {
return this.database.transaction(async (transaction) => { return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", { await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {

View File

@ -90,6 +90,10 @@
"relatedNotice": "This item is saved separately and remains available if you later cancel the main form.", "relatedNotice": "This item is saved separately and remains available if you later cancel the main form.",
"newAsset": "New asset", "newAsset": "New asset",
"editAsset": "Edit asset", "editAsset": "Edit asset",
"deleteAsset": "Delete asset",
"deleteAssetDescription": "The asset “{{name}}” and its history will be deleted. It cannot be deleted while it is linked to other records.",
"deletingAsset": "Deleting asset…",
"deleteAssetError": "The asset could not be deleted. Please try again.",
"viewAsset": "Details and history", "viewAsset": "Details and history",
"template": "Template", "template": "Template",
"site": "Site", "site": "Site",
@ -123,6 +127,7 @@
"INVALID_MEMBERS": "Select distinct valid assets.", "INVALID_MEMBERS": "Select distinct valid assets.",
"WORKSTATION_NOT_FOUND": "Workstation not found in this company.", "WORKSTATION_NOT_FOUND": "Workstation not found in this company.",
"ASSET_IN_WORKSTATION": "An asset belongs to another active workstation. Remove it there before assigning it.", "ASSET_IN_WORKSTATION": "An asset belongs to another active workstation. Remove it there before assigning it.",
"ASSET_IN_USE": "The asset cannot be deleted because it is linked to other records.",
"INVALID_REQUEST": "Check the form data.", "INVALID_REQUEST": "Check the form data.",
"INVALID_CHARACTERISTICS": "A characteristic does not match its field or type.", "INVALID_CHARACTERISTICS": "A characteristic does not match its field or type.",
"REQUIRED_CHARACTERISTICS": "Complete the template's required characteristics.", "REQUIRED_CHARACTERISTICS": "Complete the template's required characteristics.",

View File

@ -90,6 +90,10 @@
"relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.", "relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.",
"newAsset": "Nuevo activo", "newAsset": "Nuevo activo",
"editAsset": "Editar activo", "editAsset": "Editar activo",
"deleteAsset": "Eliminar activo",
"deleteAssetDescription": "Se eliminarán el activo «{{name}}» y su historial. No se puede eliminar si está relacionado con otros registros.",
"deletingAsset": "Eliminando activo…",
"deleteAssetError": "No se ha podido eliminar el activo. Inténtalo de nuevo.",
"viewAsset": "Ficha e historial", "viewAsset": "Ficha e historial",
"template": "Plantilla", "template": "Plantilla",
"site": "Sede", "site": "Sede",
@ -123,6 +127,7 @@
"INVALID_MEMBERS": "Selecciona activos distintos y válidos.", "INVALID_MEMBERS": "Selecciona activos distintos y válidos.",
"WORKSTATION_NOT_FOUND": "El puesto no existe en esta compañía.", "WORKSTATION_NOT_FOUND": "El puesto no existe en esta compañía.",
"ASSET_IN_WORKSTATION": "Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.", "ASSET_IN_WORKSTATION": "Un activo ya pertenece a otro puesto activo. Retíralo de allí antes de asignarlo.",
"ASSET_IN_USE": "No se puede eliminar el activo porque está relacionado con otros registros.",
"INVALID_REQUEST": "Revisa los datos del formulario.", "INVALID_REQUEST": "Revisa los datos del formulario.",
"INVALID_CHARACTERISTICS": "Alguna característica no corresponde a su campo o tipo.", "INVALID_CHARACTERISTICS": "Alguna característica no corresponde a su campo o tipo.",
"REQUIRED_CHARACTERISTICS": "Completa las características obligatorias de la plantilla.", "REQUIRED_CHARACTERISTICS": "Completa las características obligatorias de la plantilla.",

View File

@ -22,6 +22,7 @@ export function AssetSelectionPanel({
onClose, onClose,
onView, onView,
onEdit, onEdit,
onDelete,
}: { }: {
companyId: string; companyId: string;
asset: AssetDTO | undefined; asset: AssetDTO | undefined;
@ -32,6 +33,7 @@ export function AssetSelectionPanel({
onClose: () => void; onClose: () => void;
onView: (id: string) => void; onView: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
onDelete: (id: string) => void;
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
@ -63,6 +65,9 @@ export function AssetSelectionPanel({
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline"> <Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
{t("edit")} {t("edit")}
</Button> </Button>
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">
{t("deleteAsset")}
</Button>
</div> </div>
<AssetDetail asset={asset} compact lookups={lookups}> <AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}> <DetailSection title={t("relations")}>

View File

@ -1,10 +1,11 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client"; import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries"; import { inventoryKeys } from "../../../shared/queries";
import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api"; import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types"; import type { AssetInventoryPageProps } from "../types";
@ -17,6 +18,7 @@ export function useAssetInventoryPageController({
initialCreate = false, initialCreate = false,
}: AssetInventoryPageProps) { }: AssetInventoryPageProps) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const client = useQueryClient();
const query = useQuery({ const query = useQuery({
queryKey: inventoryKeys.company(companyId), queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId), queryFn: () => getAssetInventory(companyId),
@ -38,6 +40,14 @@ export function useAssetInventoryPageController({
}); });
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined); const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [deleteId, setDeleteId] = useState<string | undefined>();
const remove = useMutation({
mutationFn: (id: string) => removeAsset(companyId, id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
setDeleteId(undefined);
},
});
const compact = useCompactInventory(); const compact = useCompactInventory();
const [localViewId, setLocalViewId] = useState<string | undefined>(); const [localViewId, setLocalViewId] = useState<string | undefined>();
const { const {
@ -53,6 +63,7 @@ export function useAssetInventoryPageController({
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0]; const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0];
const edited = assets.find((asset) => asset.id === editor); const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId); const viewed = assets.find((asset) => asset.id === viewId);
const deleted = assets.find((asset) => asset.id === deleteId);
const filtered = assets.filter((asset) => { const filtered = assets.filter((asset) => {
const site = lookups.sites.find((site) => site.id === asset.values.siteId); const site = lookups.sites.find((site) => site.id === asset.values.siteId);
@ -73,6 +84,10 @@ export function useAssetInventoryPageController({
setSearch, setSearch,
editor, editor,
setEditor, setEditor,
deleted,
deleteId,
setDeleteId,
remove,
setViewId, setViewId,
setSelectedId, setSelectedId,
detailOpen, detailOpen,

View File

@ -13,7 +13,7 @@ import { CatalogError } from "../../../catalog";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon"; import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { InventoryFeedback } from "../../../shared"; import { InventoryFeedback } from "../../../shared";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail"; import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog } from "../../shared"; import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { useAssetInventoryPageController } from "../controllers"; import { useAssetInventoryPageController } from "../controllers";
import type { AssetInventoryPageProps } from "../types"; import type { AssetInventoryPageProps } from "../types";
@ -32,6 +32,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
setSearch, setSearch,
editor, editor,
setEditor, setEditor,
deleted,
deleteId,
setDeleteId,
remove,
setViewId, setViewId,
setSelectedId, setSelectedId,
detailOpen, detailOpen,
@ -173,6 +177,12 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}> <EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")} {t("edit")}
</EntityAction> </EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "} </div>{" "}
</EntityRowActions> </EntityRowActions>
), ),
@ -195,6 +205,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
companyId={companyId} companyId={companyId}
lookups={lookups} lookups={lookups}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
onDelete={setDeleteId}
onEdit={setEditor} onEdit={setEditor}
onView={setViewId} onView={setViewId}
open={detailOpen} open={detailOpen}
@ -222,6 +233,21 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
onClose={() => setViewId(undefined)} onClose={() => setViewId(undefined)}
/> />
)} )}
<DeleteAssetDialog
asset={deleted}
errorCode={remove.error instanceof Error ? remove.error.message : undefined}
onClose={() => setDeleteId(undefined)}
onConfirm={async () => {
if (!deleteId) {
return;
}
await remove.mutateAsync(deleteId);
setDetailOpen(false);
setSelectedId(undefined);
setViewId(undefined);
}}
open={Boolean(deleted)}
/>
</div> </div>
); );
} }

View File

@ -1 +1 @@
export { saveAsset } from "./save-asset.api"; export { removeAsset, saveAsset } from "./save-asset.api";

View File

@ -7,3 +7,7 @@ export async function saveAsset(companyId: string, values: AssetInput, id?: stri
await inventoryRequest(`${companyId}${id ? `/${id}/update` : ""}`, { values }) await inventoryRequest(`${companyId}${id ? `/${id}/update` : ""}`, { values })
); );
} }
export async function removeAsset(companyId: string, id: string) {
return AssetResponseSchema.parse(await inventoryRequest(`${companyId}/${id}/delete`, {}));
}

View File

@ -1,3 +1,2 @@
export type { InventoryLookups } from "./types"; export type { InventoryLookups } from "./types";
export { AssetEditorDialog, AssetForm, DeleteAssetDialog } from "./ui";
export { AssetEditorDialog, AssetForm } from "./ui";

View File

@ -272,6 +272,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
sites: [{ id: siteId, name: "Fábrica" }], sites: [{ id: siteId, name: "Fábrica" }],
}} }}
onClose={() => undefined} onClose={() => undefined}
onDelete={() => undefined}
onEdit={() => undefined} onEdit={() => undefined}
onView={() => undefined} onView={() => undefined}
open open

View File

@ -0,0 +1,40 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../../../../common/inventory";
export function DeleteAssetDialog({
asset,
open,
errorCode,
onClose,
onConfirm,
}: {
asset: AssetDTO | undefined;
open: boolean;
errorCode: string | undefined;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
const { t } = useTranslation("assets");
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t("deleteAsset")}
description={t("deleteAssetDescription", { name: asset?.values.name ?? "" })}
errorLabel={
errorCode === "ASSET_IN_USE" ? t("inventoryErrors.ASSET_IN_USE") : t("deleteAssetError")
}
onConfirm={onConfirm}
onOpenChange={(value) => {
if (!value) {
onClose();
}
}}
open={open}
pendingLabel={t("deletingAsset")}
title={t("deleteAsset")}
/>
);
}

View File

@ -1,3 +1,3 @@
export { AssetEditorDialog } from "./asset-editor-dialog"; export { AssetEditorDialog } from "./asset-editor-dialog";
export { AssetForm } from "./asset-form"; export { AssetForm } from "./asset-form";
export { DeleteAssetDialog } from "./delete-asset-dialog";