diff --git a/apps/server/src/inventory.integration.ts b/apps/server/src/inventory.integration.ts index 49e0bee..7d91341 100644 --- a/apps/server/src/inventory.integration.ts +++ b/apps/server/src/inventory.integration.ts @@ -87,6 +87,15 @@ export async function verifyInventory( assert.equal(created.history[0]?.id.charAt(14), "7"); assert.ok(created.history[0]?.actorName); 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); await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404); await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404); diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts index 710473a..f94e17d 100644 --- a/modules/assets/src/api/application/asset.repository.ts +++ b/modules/assets/src/api/application/asset.repository.ts @@ -21,6 +21,10 @@ export type InventoryContext = Readonly<{ }>; export interface IAssetRepository { + remove( + company: UniqueID, + id: UniqueID + ): Promise }, AssetCatalogError>>; removeRelationship(company: UniqueID, id: UniqueID): Promise>; saveWorkstation( companyId: UniqueID, diff --git a/modules/assets/src/api/application/asset.use-cases.ts b/modules/assets/src/api/application/asset.use-cases.ts index 004090d..66ef6b3 100644 --- a/modules/assets/src/api/application/asset.use-cases.ts +++ b/modules/assets/src/api/application/asset.use-cases.ts @@ -22,6 +22,17 @@ export class AssetUseCases { 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) { const company = UniqueID.create(companyId); const actor = UniqueID.create(actorId); diff --git a/modules/assets/src/api/infrastructure/http/asset.routes.ts b/modules/assets/src/api/infrastructure/http/asset.routes.ts index b2429d7..8de376d 100644 --- a/modules/assets/src/api/infrastructure/http/asset.routes.ts +++ b/modules/assets/src/api/infrastructure/http/asset.routes.ts @@ -55,6 +55,32 @@ export function registerAssetRoutes( }) .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"]) { router.post(path, (request, response, next) => { const params = ( diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index 44dffb6..bfd6615 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -12,6 +12,7 @@ import { AssetCatalogPersistenceMapper } from "./asset-catalog-persistence.mappe import { readBindings, readDefinitions } from "./asset-catalog-relational"; import { readInventory, writeInventory } from "./asset-inventory-relational"; import { AssetPersistenceMapper, inventoryIdentity } from "./asset-persistence.mapper"; +import { sqlIdentifier } from "./asset-relational-sql"; import { RelationshipModel } from "./asset-relationship.model"; import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper"; 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> { return this.database.transaction(async (transaction) => { await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", { diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index bb00165..393bcf8 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -90,6 +90,10 @@ "relatedNotice": "This item is saved separately and remains available if you later cancel the main form.", "newAsset": "New 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", "template": "Template", "site": "Site", @@ -123,6 +127,7 @@ "INVALID_MEMBERS": "Select distinct valid assets.", "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_USE": "The asset cannot be deleted because it is linked to other records.", "INVALID_REQUEST": "Check the form data.", "INVALID_CHARACTERISTICS": "A characteristic does not match its field or type.", "REQUIRED_CHARACTERISTICS": "Complete the template's required characteristics.", diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 3c90ede..783b275 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -90,6 +90,10 @@ "relatedNotice": "El elemento se guarda por separado y se conserva aunque después canceles el formulario principal.", "newAsset": "Nuevo 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", "template": "Plantilla", "site": "Sede", @@ -123,6 +127,7 @@ "INVALID_MEMBERS": "Selecciona activos distintos y válidos.", "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_USE": "No se puede eliminar el activo porque está relacionado con otros registros.", "INVALID_REQUEST": "Revisa los datos del formulario.", "INVALID_CHARACTERISTICS": "Alguna característica no corresponde a su campo o tipo.", "REQUIRED_CHARACTERISTICS": "Completa las características obligatorias de la plantilla.", diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index ecafc73..0a4986f 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -22,6 +22,7 @@ export function AssetSelectionPanel({ onClose, onView, onEdit, + onDelete, }: { companyId: string; asset: AssetDTO | undefined; @@ -32,6 +33,7 @@ export function AssetSelectionPanel({ onClose: () => void; onView: (id: string) => void; onEdit: (id: string) => void; + onDelete: (id: string) => void; }) { const { t } = useTranslation("assets"); @@ -63,6 +65,9 @@ export function AssetSelectionPanel({ + diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index c38a052..7f35797 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -1,10 +1,11 @@ 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 { useTranslation } from "react-i18next"; import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; import { inventoryKeys } from "../../../shared/queries"; +import { removeAsset } from "../../shared/api"; import { getAssetInventory } from "../api"; import type { AssetInventoryPageProps } from "../types"; @@ -17,6 +18,7 @@ export function useAssetInventoryPageController({ initialCreate = false, }: AssetInventoryPageProps) { const { t } = useTranslation("assets"); + const client = useQueryClient(); const query = useQuery({ queryKey: inventoryKeys.company(companyId), queryFn: () => getAssetInventory(companyId), @@ -38,6 +40,14 @@ export function useAssetInventoryPageController({ }); const [editor, setEditor] = useState(initialCreate ? "new" : undefined); + const [deleteId, setDeleteId] = useState(); + const remove = useMutation({ + mutationFn: (id: string) => removeAsset(companyId, id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); + setDeleteId(undefined); + }, + }); const compact = useCompactInventory(); const [localViewId, setLocalViewId] = useState(); const { @@ -53,6 +63,7 @@ export function useAssetInventoryPageController({ const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0]; const edited = assets.find((asset) => asset.id === editor); const viewed = assets.find((asset) => asset.id === viewId); + const deleted = assets.find((asset) => asset.id === deleteId); const filtered = assets.filter((asset) => { const site = lookups.sites.find((site) => site.id === asset.values.siteId); @@ -73,6 +84,10 @@ export function useAssetInventoryPageController({ setSearch, editor, setEditor, + deleted, + deleteId, + setDeleteId, + remove, setViewId, setSelectedId, detailOpen, diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 87e1299..6b04f5b 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -13,7 +13,7 @@ import { CatalogError } from "../../../catalog"; import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon"; import { InventoryFeedback } from "../../../shared"; import { AssetDetailDialog, AssetSelectionPanel } from "../../detail"; -import { AssetEditorDialog } from "../../shared"; +import { AssetEditorDialog, DeleteAssetDialog } from "../../shared"; import { useAssetInventoryPageController } from "../controllers"; import type { AssetInventoryPageProps } from "../types"; @@ -32,6 +32,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { setSearch, editor, setEditor, + deleted, + deleteId, + setDeleteId, + remove, setViewId, setSelectedId, detailOpen, @@ -173,6 +177,12 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { setEditor(asset.id)}> {t("edit")} + setDeleteId(asset.id)} + > + {t("deleteAsset")} + {" "} ), @@ -195,6 +205,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { companyId={companyId} lookups={lookups} onClose={() => setDetailOpen(false)} + onDelete={setDeleteId} onEdit={setEditor} onView={setViewId} open={detailOpen} @@ -222,6 +233,21 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) { onClose={() => setViewId(undefined)} /> )} + setDeleteId(undefined)} + onConfirm={async () => { + if (!deleteId) { + return; + } + await remove.mutateAsync(deleteId); + setDetailOpen(false); + setSelectedId(undefined); + setViewId(undefined); + }} + open={Boolean(deleted)} + /> ); } diff --git a/modules/assets/src/web/assets/shared/api/index.ts b/modules/assets/src/web/assets/shared/api/index.ts index 0ebf8c8..efed7f2 100644 --- a/modules/assets/src/web/assets/shared/api/index.ts +++ b/modules/assets/src/web/assets/shared/api/index.ts @@ -1 +1 @@ -export { saveAsset } from "./save-asset.api"; +export { removeAsset, saveAsset } from "./save-asset.api"; diff --git a/modules/assets/src/web/assets/shared/api/save-asset.api.ts b/modules/assets/src/web/assets/shared/api/save-asset.api.ts index 941193b..8d4941c 100644 --- a/modules/assets/src/web/assets/shared/api/save-asset.api.ts +++ b/modules/assets/src/web/assets/shared/api/save-asset.api.ts @@ -7,3 +7,7 @@ export async function saveAsset(companyId: string, values: AssetInput, id?: stri await inventoryRequest(`${companyId}${id ? `/${id}/update` : ""}`, { values }) ); } + +export async function removeAsset(companyId: string, id: string) { + return AssetResponseSchema.parse(await inventoryRequest(`${companyId}/${id}/delete`, {})); +} diff --git a/modules/assets/src/web/assets/shared/index.ts b/modules/assets/src/web/assets/shared/index.ts index 1daedf7..8a8cc14 100644 --- a/modules/assets/src/web/assets/shared/index.ts +++ b/modules/assets/src/web/assets/shared/index.ts @@ -1,3 +1,2 @@ export type { InventoryLookups } from "./types"; - -export { AssetEditorDialog, AssetForm } from "./ui"; +export { AssetEditorDialog, AssetForm, DeleteAssetDialog } from "./ui"; diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index c062f8c..c12e769 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -272,6 +272,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace sites: [{ id: siteId, name: "Fábrica" }], }} onClose={() => undefined} + onDelete={() => undefined} onEdit={() => undefined} onView={() => undefined} open diff --git a/modules/assets/src/web/assets/shared/ui/delete-asset-dialog.tsx b/modules/assets/src/web/assets/shared/ui/delete-asset-dialog.tsx new file mode 100644 index 0000000..ef28c6f --- /dev/null +++ b/modules/assets/src/web/assets/shared/ui/delete-asset-dialog.tsx @@ -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; +}) { + const { t } = useTranslation("assets"); + + return ( + { + if (!value) { + onClose(); + } + }} + open={open} + pendingLabel={t("deletingAsset")} + title={t("deleteAsset")} + /> + ); +} diff --git a/modules/assets/src/web/assets/shared/ui/index.ts b/modules/assets/src/web/assets/shared/ui/index.ts index f6e48f6..a78be9d 100644 --- a/modules/assets/src/web/assets/shared/ui/index.ts +++ b/modules/assets/src/web/assets/shared/ui/index.ts @@ -1,3 +1,3 @@ export { AssetEditorDialog } from "./asset-editor-dialog"; - export { AssetForm } from "./asset-form"; +export { DeleteAssetDialog } from "./delete-asset-dialog";