Eliminar un activo
This commit is contained in:
parent
45b559ab92
commit
5dfb50e13a
@ -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);
|
||||
|
||||
@ -21,6 +21,10 @@ export type InventoryContext = Readonly<{
|
||||
}>;
|
||||
|
||||
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>>;
|
||||
saveWorkstation(
|
||||
companyId: UniqueID,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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<Result<void, AssetCatalogError>> {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await this.database.query("SELECT id FROM companies WHERE id=:companyId FOR UPDATE", {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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({
|
||||
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">
|
||||
{t("deleteAsset")}
|
||||
</Button>
|
||||
</div>
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<DetailSection title={t("relations")}>
|
||||
|
||||
@ -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<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 [localViewId, setLocalViewId] = useState<string | undefined>();
|
||||
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,
|
||||
|
||||
@ -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) {
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
<EntityAction
|
||||
className="text-destructive data-highlighted:text-destructive"
|
||||
onClick={() => setDeleteId(asset.id)}
|
||||
>
|
||||
{t("deleteAsset")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
@ -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)}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { saveAsset } from "./save-asset.api";
|
||||
export { removeAsset, saveAsset } from "./save-asset.api";
|
||||
|
||||
@ -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`, {}));
|
||||
}
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export type { InventoryLookups } from "./types";
|
||||
|
||||
export { AssetEditorDialog, AssetForm } from "./ui";
|
||||
export { AssetEditorDialog, AssetForm, DeleteAssetDialog } from "./ui";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
export { AssetEditorDialog } from "./asset-editor-dialog";
|
||||
|
||||
export { AssetForm } from "./asset-form";
|
||||
export { DeleteAssetDialog } from "./delete-asset-dialog";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user