feat(admin): add delete with guard rails to all entity tables

Adds deleteX server actions and ConfirmDeleteButton to the Items,
Vessels, Sites, Users, and Accounts tables.

Guard rails per entity:
- Items: blocked by non-draft PO line items; nulls draft references,
  cascades inventory, consumption, and vendor prices
- Sites: blocked by non-draft POs; nulls draft PO siteId and vessel
  siteId, cascades inventory and consumption
- Vessels, Accounts: blocked by any PO (FK is non-nullable)
- Users: blocked by any submitted PO; cascades notifications

UI: inline two-step confirm ("Delete X? Confirm / Cancel") using the
shared ConfirmDeleteButton component; errors surface inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-14 17:08:51 +05:30
parent 392bad7549
commit c06745a9f9
10 changed files with 137 additions and 29 deletions

View file

@ -61,6 +61,18 @@ export async function updateAccount(formData: FormData): Promise<ActionResult> {
return { ok: true }; return { ok: true };
} }
export async function deleteAccount(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
const inUse = await db.purchaseOrder.findFirst({ where: { accountId: id } });
if (inUse) return { error: "Cannot delete: account is referenced in purchase orders. Remove those POs first." };
await db.account.delete({ where: { id } });
revalidatePath("/admin/accounts");
return { ok: true };
}
export async function toggleAccountActive(accountId: string): Promise<ActionResult> { export async function toggleAccountActive(accountId: string): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {

View file

@ -3,6 +3,8 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AddAccountButton, EditAccountButton } from "./account-form"; import { AddAccountButton, EditAccountButton } from "./account-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteAccount } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Account Management" }; export const metadata: Metadata = { title: "Account Management" };
@ -47,13 +49,16 @@ export default async function AdminAccountsPage() {
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<EditAccountButton account={{ <span className="flex items-center gap-3">
id: account.id, <EditAccountButton account={{
code: account.code, id: account.id,
name: account.name, code: account.code,
description: account.description, name: account.name,
isActive: account.isActive, description: account.description,
}} /> isActive: account.isActive,
}} />
<ConfirmDeleteButton onDelete={deleteAccount.bind(null, account.id)} label={account.name} />
</span>
</td> </td>
</tr> </tr>
))} ))}

View file

@ -35,6 +35,27 @@ export async function createProduct(formData: FormData): Promise<ActionResult> {
return { ok: true }; return { ok: true };
} }
export async function deleteProduct(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_products")) return { error: "Forbidden" };
const blocked = await db.pOLineItem.findFirst({
where: { productId: id, po: { status: { not: "DRAFT" } } },
});
if (blocked) return { error: "Cannot delete: item is referenced in submitted or active purchase orders." };
await db.$transaction(async (tx) => {
await tx.pOLineItem.updateMany({ where: { productId: id }, data: { productId: null } });
await tx.itemConsumption.deleteMany({ where: { productId: id } });
await tx.itemInventory.deleteMany({ where: { productId: id } });
await tx.productVendorPrice.deleteMany({ where: { productId: id } });
await tx.product.delete({ where: { id } });
});
revalidatePath("/admin/products");
return { ok: true };
}
export async function toggleProductActive(productId: string): Promise<ActionResult> { export async function toggleProductActive(productId: string): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_products")) { if (!session?.user || !hasPermission(session.user.role, "manage_products")) {

View file

@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { AddProductButton, ToggleProductButton } from "./product-form"; import { AddProductButton, ToggleProductButton } from "./product-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteProduct } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Item Catalogue" }; export const metadata: Metadata = { title: "Item Catalogue" };
@ -91,13 +93,16 @@ export default async function AdminProductsPage() {
</td> </td>
{canManage && ( {canManage && (
<td className="px-4 py-3"> <td className="px-4 py-3">
<ToggleProductButton product={{ <span className="flex items-center gap-3">
id: product.id, <ToggleProductButton product={{
code: product.code, id: product.id,
name: product.name, code: product.code,
description: product.description, name: product.name,
isActive: product.isActive, description: product.description,
}} /> isActive: product.isActive,
}} />
<ConfirmDeleteButton onDelete={deleteProduct.bind(null, product.id)} label={product.name} />
</span>
</td> </td>
)} )}
</tr> </tr>

View file

@ -72,6 +72,27 @@ export async function toggleSiteActive(id: string): Promise<Result> {
return { ok: true }; return { ok: true };
} }
export async function deleteSite(id: string): Promise<Result> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_sites")) return { error: "Forbidden" };
const blocked = await db.purchaseOrder.findFirst({
where: { siteId: id, status: { not: "DRAFT" } },
});
if (blocked) return { error: "Cannot delete: site is referenced in submitted or active purchase orders." };
await db.$transaction(async (tx) => {
await tx.purchaseOrder.updateMany({ where: { siteId: id, status: "DRAFT" }, data: { siteId: null } });
await tx.itemConsumption.deleteMany({ where: { siteId: id } });
await tx.itemInventory.deleteMany({ where: { siteId: id } });
await tx.vessel.updateMany({ where: { siteId: id }, data: { siteId: null } });
await tx.site.delete({ where: { id } });
});
revalidatePath("/admin/sites");
return { ok: true };
}
export async function recordConsumption(formData: FormData): Promise<Result> { export async function recordConsumption(formData: FormData): Promise<Result> {
const session = await auth(); const session = await auth();
if (!session?.user) return { error: "Unauthorized" }; if (!session?.user) return { error: "Unauthorized" };

View file

@ -4,6 +4,8 @@ import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { AddSiteButton, EditSiteButton } from "./site-form"; import { AddSiteButton, EditSiteButton } from "./site-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteSite } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Sites" }; export const metadata: Metadata = { title: "Sites" };
@ -77,7 +79,10 @@ export default async function SitesPage() {
</td> </td>
{canEdit && ( {canEdit && (
<td className="px-4 py-3"> <td className="px-4 py-3">
<EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} /> <span className="flex items-center gap-3">
<EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />
<ConfirmDeleteButton onDelete={deleteSite.bind(null, site.id)} label={site.name} />
</span>
</td> </td>
)} )}
</tr> </tr>

View file

@ -100,6 +100,23 @@ export async function updateUser(formData: FormData): Promise<ActionResult> {
return { ok: true }; return { ok: true };
} }
export async function deleteUser(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) return { error: "Unauthorized" };
if (id === session.user.id) return { error: "Cannot delete your own account." };
const inUse = await db.purchaseOrder.findFirst({ where: { submitterId: id } });
if (inUse) return { error: "Cannot delete: user has submitted purchase orders. Deactivate them instead." };
await db.$transaction(async (tx) => {
await tx.notification.deleteMany({ where: { userId: id } });
await tx.user.delete({ where: { id } });
});
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleUserActive(userId: string): Promise<ActionResult> { export async function toggleUserActive(userId: string): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_users")) { if (!session?.user || !hasPermission(session.user.role, "manage_users")) {

View file

@ -4,6 +4,8 @@ import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { AddUserButton, EditUserButton } from "./user-form"; import { AddUserButton, EditUserButton } from "./user-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteUser } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "User Management" }; export const metadata: Metadata = { title: "User Management" };
@ -66,14 +68,17 @@ export default async function AdminUsersPage() {
</td> </td>
<td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td> <td className="px-4 py-3 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<EditUserButton user={{ <span className="flex items-center gap-3">
id: user.id, <EditUserButton user={{
employeeId: user.employeeId, id: user.id,
name: user.name, employeeId: user.employeeId,
email: user.email, name: user.name,
role: user.role, email: user.email,
isActive: user.isActive, role: user.role,
}} /> isActive: user.isActive,
}} />
<ConfirmDeleteButton onDelete={deleteUser.bind(null, user.id)} label={user.name} />
</span>
</td> </td>
</tr> </tr>
))} ))}

View file

@ -62,6 +62,18 @@ export async function updateVessel(formData: FormData): Promise<ActionResult> {
return { ok: true }; return { ok: true };
} }
export async function deleteVessel(id: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) return { error: "Unauthorized" };
const inUse = await db.purchaseOrder.findFirst({ where: { vesselId: id } });
if (inUse) return { error: "Cannot delete: vessel is referenced in purchase orders. Remove those POs first." };
await db.vessel.delete({ where: { id } });
revalidatePath("/admin/vessels");
return { ok: true };
}
export async function toggleVesselActive(vesselId: string): Promise<ActionResult> { export async function toggleVesselActive(vesselId: string): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) { if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {

View file

@ -3,6 +3,8 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AddVesselButton, EditVesselButton } from "./vessel-form"; import { AddVesselButton, EditVesselButton } from "./vessel-form";
import { ConfirmDeleteButton } from "@/components/ui/confirm-delete-button";
import { deleteVessel } from "./actions";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Vessel Management" }; export const metadata: Metadata = { title: "Vessel Management" };
@ -47,12 +49,15 @@ export default async function AdminVesselsPage() {
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<EditVesselButton vessel={{ <span className="flex items-center gap-3">
id: vessel.id, <EditVesselButton vessel={{
name: vessel.name, id: vessel.id,
imoNumber: vessel.imoNumber, name: vessel.name,
isActive: vessel.isActive, imoNumber: vessel.imoNumber,
}} /> isActive: vessel.isActive,
}} />
<ConfirmDeleteButton onDelete={deleteVessel.bind(null, vessel.id)} label={vessel.name} />
</span>
</td> </td>
</tr> </tr>
))} ))}