fix(po): add Submit for Approval button on draft PO detail and edit pages

A draft PO had no way to be submitted without creating a brand new PO.
Two surfaces are now fixed:

Edit page (/po/[id]/edit):
- updatePo action now accepts intent "submit" (alongside "save" and
  "resubmit"), which transitions a DRAFT PO to MGR_REVIEW, creates a
  SUBMITTED action, and fires notifications — identical behaviour to
  the new-PO submit flow
- EditPoForm shows a primary "Submit for Approval" button when the PO
  is in DRAFT status; "Resubmit for Approval" remains for EDITS_REQUESTED

PO detail page (/po/[id]):
- New submitDraftPo server action in po/[id]/actions.ts handles the
  same DRAFT → MGR_REVIEW transition without requiring the full edit form
- New SubmitDraftButton client component renders next to the Edit link
  in the detail header for DRAFT POs owned by the current user
- Button order: Edit · Submit for Approval · Discard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-16 16:32:44 +05:30
parent 48de2d08a2
commit d297fd044f
5 changed files with 94 additions and 7 deletions

View file

@ -48,6 +48,42 @@ export async function provideVendorId({
return { ok: true };
}
export async function submitDraftPo(
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 },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (po.status !== "DRAFT") return { error: "Only draft purchase orders can be submitted." };
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
return { error: "You can only submit your own purchase orders." };
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "MGR_REVIEW",
submittedAt: new Date(),
actions: {
create: { actionType: "SUBMITTED", actorId: session.user.id },
},
},
});
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
await notify({ event: "PO_SUBMITTED", po, recipients: [po.submitter, ...managers] });
revalidatePath(`/po/${poId}`);
revalidatePath("/dashboard");
revalidatePath("/my-orders");
return { ok: true };
}
export async function discardDraftPo(
poId: string
): Promise<{ ok: true } | { error: string }> {

View file

@ -41,7 +41,7 @@ export async function updatePo(
return { error: "You can only edit your own purchase orders." };
}
const intent = formData.get("intent") as "save" | "resubmit";
const intent = formData.get("intent") as "save" | "submit" | "resubmit";
const parsed = createPoSchema.safeParse({
title: formData.get("title"),
@ -74,7 +74,9 @@ export async function updatePo(
0
);
const isSubmit = intent === "submit" && po.status === "DRAFT";
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
const shouldSubmit = isSubmit || isResubmit;
// Before mutating, snapshot the current PO state so the manager can see
// exactly what the submitter changed when they resubmit after edits requested.
@ -150,8 +152,8 @@ export async function updatePo(
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
totalAmount: total,
status: isResubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: isResubmit ? new Date() : po.submittedAt,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt,
lineItems: {
deleteMany: {},
create: data.lineItems.map((item, idx) => ({
@ -168,18 +170,18 @@ export async function updatePo(
})),
},
actions: {
create: isResubmit
create: shouldSubmit
? {
actionType: "SUBMITTED",
actorId: session.user.id,
...(resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
...(isResubmit && resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
}
: undefined,
},
},
});
if (isResubmit) {
if (shouldSubmit) {
const [fullPo, managers] = await Promise.all([
db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }),
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),

View file

@ -53,12 +53,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
accountId: li.accountId ?? undefined,
}))
);
const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null);
const [submitting, setSubmitting] = useState<"save" | "submit" | "resubmit" | null>(null);
const [error, setError] = useState("");
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED";
async function handleSubmit(intent: "save" | "resubmit") {
@ -283,6 +284,12 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors">
{submitting === "save" ? "Saving…" : "Save Draft"}
</button>
{canSubmit && (
<button type="button" onClick={() => handleSubmit("submit")} disabled={!!submitting}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors">
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
</button>
)}
{canResubmit && (
<button type="button" onClick={() => handleSubmit("resubmit")} disabled={!!submitting}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors">

View file

@ -2,6 +2,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 { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { TC_FIXED_LINE } from "@/lib/validations/po";
@ -172,6 +173,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Edit
</Link>
)}
{po.status === "DRAFT" &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
!readOnly && (
<SubmitDraftButton poId={po.id} />
)}
{po.status === "DRAFT" &&
(po.submitter.id === currentUserId || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
!readOnly && (

View file

@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { submitDraftPo } from "@/app/(portal)/po/[id]/actions";
export function SubmitDraftButton({ poId }: { poId: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit() {
setPending(true);
setError("");
const result = await submitDraftPo(poId);
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
router.refresh();
}
}
return (
<span className="inline-flex items-center gap-2">
<button
onClick={handleSubmit}
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
>
{pending ? "Submitting…" : "Submit for Approval"}
</button>
{error && <span className="text-xs text-danger-700">{error}</span>}
</span>
);
}