feat(resources): add hard-delete action to resource list (per-row and batch)

- Add batchHardDelete adminProcedure to resource-mutations router
- Per-row Delete button visible to ADMIN role only
- Delete Selected button in BatchActionBar for ADMIN role only
- Two-step confirmation dialogs with permanent-action warnings
- Audit log written for each deleted resource

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-03 23:18:30 +02:00
parent f5e41f7efe
commit fba65387fe
2 changed files with 93 additions and 2 deletions
@@ -47,7 +47,9 @@ type ModalState =
type ConfirmState =
| { type: "closed" }
| { type: "batchDeactivate"; ids: string[] }
| { type: "deactivate"; resource: Resource };
| { type: "deactivate"; resource: Resource }
| { type: "delete"; resource: Resource }
| { type: "batchDelete"; ids: string[] };
type ActiveFilter = "active" | "inactive" | "all";
type BooleanFilter = "all" | "yes" | "no";
@@ -163,7 +165,7 @@ export function ResourcesClient() {
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewScores, canViewCosts } = usePermissions();
const { canViewScores, canViewCosts, canManageUsers } = usePermissions();
const {
customFieldFilters,
setCustomFieldFilter,
@@ -323,6 +325,19 @@ export function ResourcesClient() {
selection.clear();
},
});
const hardDeleteMutation = trpc.resource.hardDelete.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
},
});
const batchHardDeleteMutation = trpc.resource.batchHardDelete.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
@@ -348,6 +363,10 @@ export function ResourcesClient() {
deactivateMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDeactivate") {
batchDeactivateMutation.mutate({ ids: confirm.ids });
} else if (confirm.type === "delete") {
hardDeleteMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDelete") {
batchHardDeleteMutation.mutate({ ids: confirm.ids });
}
setConfirm({ type: "closed" });
}
@@ -1384,6 +1403,21 @@ export function ResourcesClient() {
>
{isDeactivating ? "Deactivating…" : "Deactivate"}
</button>
{canManageUsers && (
<button
type="button"
onClick={() =>
setConfirm({
type: "delete",
resource: resource as unknown as Resource,
})
}
disabled={hardDeleteMutation.isPending}
className="ml-3 text-xs font-medium text-red-800 transition-colors hover:text-red-950 disabled:opacity-50 dark:text-red-400 dark:hover:text-red-200"
>
Delete
</button>
)}
</td>
</DraggableTableRow>
);
@@ -1426,6 +1460,16 @@ export function ResourcesClient() {
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
disabled: batchDeactivateMutation.isPending,
},
...(canManageUsers
? [
{
label: `Delete ${selection.count > 0 ? `(${selection.count})` : ""}`,
variant: "danger" as const,
onClick: () => setConfirm({ type: "batchDelete", ids: selection.selectedArray }),
disabled: batchHardDeleteMutation.isPending,
},
]
: []),
]}
/>
@@ -1469,6 +1513,26 @@ export function ResourcesClient() {
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
{confirm.type === "delete" && (
<ConfirmDialog
title="Permanently Delete Resource"
message={`Delete "${confirm.resource.displayName}" (${confirm.resource.eid}) permanently? This will also delete all their assignments and vacation records. This action cannot be undone.`}
confirmLabel="Delete Permanently"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
{confirm.type === "batchDelete" && (
<ConfirmDialog
title="Permanently Delete Resources"
message={`Delete ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""} permanently? All their assignments and vacation records will also be deleted. This action cannot be undone.`}
confirmLabel="Delete All Permanently"
variant="danger"
onConfirm={handleConfirm}
onCancel={() => setConfirm({ type: "closed" })}
/>
)}
<SuccessToast
show={successToast !== null}
@@ -289,4 +289,31 @@ export const resourceMutationProcedures = {
return { deleted: true };
}),
batchHardDelete: adminProcedure
.input(z.object({ ids: z.array(z.string()).min(1) }))
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } },
select: { id: true, displayName: true, eid: true },
});
await ctx.db.$transaction([
ctx.db.assignment.deleteMany({ where: { resourceId: { in: input.ids } } }),
ctx.db.vacation.deleteMany({ where: { resourceId: { in: input.ids } } }),
ctx.db.resource.deleteMany({ where: { id: { in: input.ids } } }),
]);
await ctx.db.auditLog.createMany({
data: resources.map((r) => ({
entityType: "Resource",
entityId: r.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
})),
});
return { deleted: resources.length };
}),
};