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:
parent
48de2d08a2
commit
d297fd044f
5 changed files with 94 additions and 7 deletions
|
|
@ -48,6 +48,42 @@ export async function provideVendorId({
|
||||||
return { ok: true };
|
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(
|
export async function discardDraftPo(
|
||||||
poId: string
|
poId: string
|
||||||
): Promise<{ ok: true } | { error: string }> {
|
): Promise<{ ok: true } | { error: string }> {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export async function updatePo(
|
||||||
return { error: "You can only edit your own purchase orders." };
|
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({
|
const parsed = createPoSchema.safeParse({
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
|
|
@ -74,7 +74,9 @@ export async function updatePo(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSubmit = intent === "submit" && po.status === "DRAFT";
|
||||||
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
|
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
|
||||||
|
const shouldSubmit = isSubmit || isResubmit;
|
||||||
|
|
||||||
// Before mutating, snapshot the current PO state so the manager can see
|
// Before mutating, snapshot the current PO state so the manager can see
|
||||||
// exactly what the submitter changed when they resubmit after edits requested.
|
// exactly what the submitter changed when they resubmit after edits requested.
|
||||||
|
|
@ -150,8 +152,8 @@ export async function updatePo(
|
||||||
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||||||
tcOthers: data.tcOthers ?? null,
|
tcOthers: data.tcOthers ?? null,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
status: isResubmit ? "MGR_REVIEW" : "DRAFT",
|
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
|
||||||
submittedAt: isResubmit ? new Date() : po.submittedAt,
|
submittedAt: shouldSubmit ? new Date() : po.submittedAt,
|
||||||
lineItems: {
|
lineItems: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: data.lineItems.map((item, idx) => ({
|
create: data.lineItems.map((item, idx) => ({
|
||||||
|
|
@ -168,18 +170,18 @@ export async function updatePo(
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
create: isResubmit
|
create: shouldSubmit
|
||||||
? {
|
? {
|
||||||
actionType: "SUBMITTED",
|
actionType: "SUBMITTED",
|
||||||
actorId: session.user.id,
|
actorId: session.user.id,
|
||||||
...(resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
|
...(isResubmit && resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isResubmit) {
|
if (shouldSubmit) {
|
||||||
const [fullPo, managers] = await Promise.all([
|
const [fullPo, managers] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }),
|
db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }),
|
||||||
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
accountId: li.accountId ?? undefined,
|
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 [error, setError] = useState("");
|
||||||
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
||||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||||
|
|
||||||
|
const canSubmit = po.status === "DRAFT";
|
||||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||||
|
|
||||||
async function handleSubmit(intent: "save" | "resubmit") {
|
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">
|
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"}
|
{submitting === "save" ? "Saving…" : "Save Draft"}
|
||||||
</button>
|
</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 && (
|
{canResubmit && (
|
||||||
<button type="button" onClick={() => handleSubmit("resubmit")} disabled={!!submitting}
|
<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">
|
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">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import Link from "next/link";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
|
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
|
|
@ -172,6 +173,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{po.status === "DRAFT" &&
|
||||||
|
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
||||||
|
!readOnly && (
|
||||||
|
<SubmitDraftButton poId={po.id} />
|
||||||
|
)}
|
||||||
{po.status === "DRAFT" &&
|
{po.status === "DRAFT" &&
|
||||||
(po.submitter.id === currentUserId || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
|
(po.submitter.id === currentUserId || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
|
|
|
||||||
36
App/pelagia-portal/components/po/submit-draft-button.tsx
Normal file
36
App/pelagia-portal/components/po/submit-draft-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue