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:
parent
392bad7549
commit
c06745a9f9
10 changed files with 137 additions and 29 deletions
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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")) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue