feat: discard draft POs

Adds a "Discard" button to the PO detail header for any DRAFT PO.
Submitters, managers, and superusers can discard. The action deletes
the PO and its related actions/notifications, then redirects to
/my-orders. Non-cascade child records (POAction, Notification) are
explicitly deleted in a transaction before the PO row is removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-09 18:57:01 +05:30
parent 43f0861591
commit 96314d89f8
3 changed files with 77 additions and 0 deletions

View file

@ -47,3 +47,33 @@ export async function provideVendorId({
revalidatePath(`/po/${poId}`);
return { ok: true };
}
export async function discardDraftPo(
poId: string
): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
select: { id: true, status: true, submitterId: true },
});
if (!po) return { error: "PO not found" };
if (po.status !== "DRAFT") return { error: "Only DRAFT purchase orders can be discarded." };
const { role, id: userId } = session.user;
const canDiscard =
po.submitterId === userId || ["MANAGER", "SUPERUSER"].includes(role);
if (!canDiscard) return { error: "You do not have permission to discard this PO." };
// POAction has no cascade — delete child records before the PO
await db.$transaction([
db.pOAction.deleteMany({ where: { poId } }),
db.notification.deleteMany({ where: { poId } }),
db.purchaseOrder.delete({ where: { id: poId } }),
]);
revalidatePath("/my-orders");
revalidatePath("/dashboard");
return { ok: true };
}

View file

@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
export function DiscardDraftButton({ poId }: { poId: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleDiscard() {
if (!confirm("Permanently discard this draft? This cannot be undone.")) return;
setPending(true);
const result = await discardDraftPo(poId);
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
router.push("/my-orders");
}
}
return (
<span className="relative">
<button
type="button"
onClick={handleDiscard}
disabled={pending}
className="rounded-lg border border-danger-200 bg-white px-3 py-2 text-sm font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-60 transition-colors"
>
{pending ? "Discarding…" : "Discard"}
</button>
{error && (
<span className="absolute right-0 top-full mt-1 w-56 rounded-lg bg-danger-50 border border-danger-200 px-3 py-2 text-xs text-danger-700 shadow-sm z-10">
{error}
</span>
)}
</span>
);
}

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { TC_FIXED_LINE } from "@/lib/validations/po";
@ -137,6 +138,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Edit
</Link>
)}
{po.status === "DRAFT" &&
(po.submitter.id === currentUserId || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
!readOnly && (
<DiscardDraftButton poId={po.id} />
)}
<a
href={`/api/po/${po.id}/export?format=pdf`}
target="_blank"