Merge pull request 'feat(po): cancel POs + optional supersede link (#53)' (#56) from feat/po-cancel-supersede into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m7s

Reviewed-on: #56
This commit is contained in:
shad0w 2026-06-21 06:58:31 +00:00
commit 9de60200f9
19 changed files with 612 additions and 10 deletions

View file

@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved", DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed", SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
SUBMITTED: "Submitted", REJECTED: "Rejected", SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
}; };
return ( return (

View file

@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review", DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
}; };

View file

@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review", DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
}; };
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED") const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")

View file

@ -14,6 +14,7 @@ const STATUSES = [
{ value: "PAID_DELIVERED", label: "Paid / Delivered" }, { value: "PAID_DELIVERED", label: "Paid / Delivered" },
{ value: "CLOSED", label: "Closed" }, { value: "CLOSED", label: "Closed" },
{ value: "REJECTED", label: "Rejected" }, { value: "REJECTED", label: "Rejected" },
{ value: "CANCELLED", label: "Cancelled" },
]; ];
interface Props { interface Props {

View file

@ -115,7 +115,10 @@ export default async function HistoryPage({ searchParams }: Props) {
</thead> </thead>
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{orders.map((po) => ( {orders.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50"> <tr
key={po.id}
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700"> <Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
{po.poNumber} {po.poNumber}

View file

@ -2,7 +2,8 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine"; import { canPerformAction, canCancel } from "@/lib/po-state-machine";
import { hasPermission } from "@/lib/permissions";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -113,3 +114,118 @@ export async function discardDraftPo(
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { ok: true }; return { ok: true };
} }
// ── Cancel a PO ───────────────────────────────────────────────────────────────
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
// explicit whitelists, none of which include CANCELLED).
export async function cancelPo({
poId,
reason,
}: {
poId: string;
reason: string;
}): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "cancel_po")) {
return { error: "You do not have permission to cancel purchase orders." };
}
const trimmed = (reason ?? "").trim();
if (!trimmed) return { error: "A cancellation reason is required." };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (!canCancel(po.status, session.user.role)) {
return {
error: po.status === "CANCELLED"
? "This purchase order is already cancelled."
: "You cannot cancel this purchase order.",
};
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancellationReason: trimmed,
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
},
});
// Notify the submitter and Accounts (they track spend).
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
const recipients = [po.submitter, ...accounts].filter(
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
);
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
revalidatePath(`/po/${poId}`);
revalidatePath("/dashboard");
revalidatePath("/history");
revalidatePath("/my-orders");
revalidatePath("/payments");
return { ok: true };
}
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
// surfaced on the replacement via the schema self-relation.
export async function supersedePo({
poId,
replacementPoNumber,
}: {
poId: string;
replacementPoNumber: string;
}): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "cancel_po")) {
return { error: "You do not have permission to link a superseding purchase order." };
}
const num = (replacementPoNumber ?? "").trim();
if (!num) return { error: "Enter the PO number that supersedes this one." };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
select: { id: true, status: true },
});
if (!po) return { error: "PO not found" };
if (po.status !== "CANCELLED") {
return { error: "Only a cancelled purchase order can be superseded." };
}
const replacement = await db.purchaseOrder.findUnique({
where: { poNumber: num },
select: { id: true, poNumber: true },
});
if (!replacement) return { error: `No purchase order found with number "${num}".` };
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
await db.purchaseOrder.update({
where: { id: poId },
data: {
supersededById: replacement.id,
actions: {
create: {
actionType: "SUPERSEDED",
actorId: session.user.id,
note: `Superseded by ${replacement.poNumber}`,
},
},
},
});
revalidatePath(`/po/${poId}`);
revalidatePath(`/po/${replacement.id}`);
return { ok: true };
}

View file

@ -32,6 +32,8 @@ export default async function PoDetailPage({ params }: Props) {
documents: { orderBy: { uploadedAt: "desc" } }, documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } }, actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
receipt: true, receipt: true,
supersededBy: { select: { id: true, poNumber: true } },
supersedes: { select: { id: true, poNumber: true } },
}, },
}); });

View file

@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { downloadBuffer } from "@/lib/storage"; import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64 } from "@/lib/cancelled-watermark";
// ── Company fallback constants (used when no company is linked to a PO) ────── // ── Company fallback constants (used when no company is linked to a PO) ──────
@ -65,9 +66,11 @@ export async function GET(request: NextRequest, { params }: Props) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document. // Exports are available for approved POs (manager approval is a prerequisite for a valid PO
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
// The submitter's signature is never embedded; only the approving manager's signature is used. // The submitter's signature is never embedded; only the approving manager's signature is used.
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"]; const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
const isCancelled = po.status === "CANCELLED";
if (!EXPORTABLE_STATUSES.includes(po.status)) { if (!EXPORTABLE_STATUSES.includes(po.status)) {
return NextResponse.json( return NextResponse.json(
{ error: "Export is only available for approved purchase orders." }, { error: "Export is only available for approved purchase orders." },
@ -508,6 +511,16 @@ export async function GET(request: NextRequest, { params }: Props) {
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill }); for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`); ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
// ══ Cancelled watermark — diagonal "CANCELLED" floating over the sheet ═══
if (isCancelled) {
const wmId = wb.addImage({ base64: CANCELLED_WATERMARK_PNG_BASE64, extension: "png" });
ws.addImage(wmId, {
tl: { col: 0.2, row: 4 } as unknown as ExcelJS.Anchor,
br: { col: 9, row: BAR_ROW } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}
// ── Serialise ───────────────────────────────────────────────────────── // ── Serialise ─────────────────────────────────────────────────────────
const buf = await wb.xlsx.writeBuffer(); const buf = await wb.xlsx.writeBuffer();
const slug = po.poNumber.replace(/\//g, "-"); const slug = po.poNumber.replace(/\//g, "-");
@ -665,6 +678,24 @@ export async function GET(request: NextRequest, { params }: Props) {
background: ${BRAND_BAR_COLOR}; background: ${BRAND_BAR_COLOR};
} }
/* ── Cancelled watermark ── */
.cancelled-watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 96pt;
font-weight: 800;
letter-spacing: 8px;
color: rgba(200, 0, 0, 0.18);
border: 6px solid rgba(200, 0, 0, 0.18);
padding: 8px 32px;
border-radius: 8px;
white-space: nowrap;
z-index: 9999;
pointer-events: none;
}
@media print { @media print {
.no-print { display: none; } .no-print { display: none; }
body { margin: 8mm 10mm; } body { margin: 8mm 10mm; }
@ -674,6 +705,8 @@ export async function GET(request: NextRequest, { params }: Props) {
</head> </head>
<body> <body>
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<div class="no-print" style="margin-bottom:8px"> <div class="no-print" style="margin-bottom:8px">
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px"> <button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
🖨 Print / Save as PDF 🖨 Print / Save as PDF

View file

@ -8,7 +8,7 @@ const PO_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval", DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", CANCELLED: "Cancelled",
}; };
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
// ── Cancel PO button + confirmation modal ──────────────────────────────────────
// The manager must type the word "cancel" and provide a reason before the action
// is enabled — a deliberate friction step for an irreversible, terminal action.
export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const confirmed = confirmText.trim().toLowerCase() === "cancel";
const canSubmit = confirmed && reason.trim().length > 0 && !pending;
function close() {
if (pending) return;
setOpen(false);
setReason("");
setConfirmText("");
setError("");
}
async function handleCancel() {
if (!canSubmit) return;
setPending(true);
setError("");
const result = await cancelPo({ poId, reason: reason.trim() });
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setPending(false);
setOpen(false);
router.refresh();
}
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-lg border border-danger-200 bg-white px-3 py-2 text-sm font-medium text-danger-600 hover:bg-danger-50 transition-colors"
>
Cancel PO
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={close}>
<div
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-neutral-900">Cancel {poNumber}?</h2>
<p className="mt-1.5 text-sm text-neutral-600">
This marks the purchase order as <strong>cancelled</strong> and removes its value from
all spend trackers and graphs. This cannot be undone.
</p>
<label className="mt-4 block text-xs font-medium text-neutral-700">
Reason for cancellation <span className="text-danger-600">*</span>
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
autoFocus
placeholder="e.g. Duplicate order — superseded by a corrected PO"
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-danger-500 focus:outline-none focus:ring-2 focus:ring-danger-500/20"
/>
<label className="mt-3 block text-xs font-medium text-neutral-700">
Type <span className="font-mono font-semibold">cancel</span> to confirm
</label>
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="cancel"
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-danger-500 focus:outline-none focus:ring-2 focus:ring-danger-500/20"
/>
{error && <p className="mt-3 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={close}
disabled={pending}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
Keep PO
</button>
<button
type="button"
onClick={handleCancel}
disabled={!canSubmit}
className="rounded-lg bg-danger-600 px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
>
{pending ? "Cancelling…" : "Cancel this PO"}
</button>
</div>
</div>
</div>
)}
</>
);
}
// ── Supersede: link a cancelled PO to the existing PO that replaces it ──────────
export function SupersedeForm({ poId }: { poId: string }) {
const router = useRouter();
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleLink(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!value.trim()) return;
setPending(true);
setError("");
const result = await supersedePo({ poId, replacementPoNumber: value.trim() });
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setPending(false);
setValue("");
router.refresh();
}
}
return (
<form onSubmit={handleLink} className="mt-2 flex flex-wrap items-start gap-2">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Replacement PO number, e.g. PMS/HNR1/9001/2026-27"
className="min-w-[260px] flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
<button
type="submit"
disabled={pending || !value.trim()}
className="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50"
>
{pending ? "Linking…" : "Link replacement"}
</button>
{error && <p className="w-full text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
</form>
);
}

View file

@ -3,6 +3,7 @@ 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 { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
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 { groupAttachments } from "@/lib/attachments"; import { groupAttachments } from "@/lib/attachments";
@ -40,6 +41,10 @@ type PoWithRelations = {
approvedAt: Date | null; approvedAt: Date | null;
paidAt: Date | null; paidAt: Date | null;
closedAt: Date | null; closedAt: Date | null;
cancelledAt?: Date | null;
cancellationReason?: string | null;
supersededBy?: { id: string; poNumber: string } | null;
supersedes?: { id: string; poNumber: string }[];
submitter: { id: string; name: string; email: string }; submitter: { id: string; name: string; email: string };
vessel: { id: string; name: string }; vessel: { id: string; name: string };
account: { id: string; name: string; code: string }; account: { id: string; name: string; code: string };
@ -92,6 +97,8 @@ const ACTION_LABELS: Record<string, string> = {
CLOSED: "Closed", CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items", MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated", PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
}; };
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) { export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
@ -203,8 +210,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && ( !readOnly && (
<DiscardDraftButton poId={po.id} /> <DiscardDraftButton poId={po.id} />
)} )}
{/* Export buttons — only available once the PO has been approved by a manager */} {/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<> {["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
<a <a
href={`/api/po/${po.id}/export?format=pdf`} href={`/api/po/${po.id}/export?format=pdf`}
target="_blank" target="_blank"
@ -220,9 +227,59 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Export XLSX Export XLSX
</a> </a>
</>)} </>)}
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
{po.status !== "CANCELLED" &&
["MANAGER", "SUPERUSER"].includes(currentRole) &&
!readOnly && (
<CancelPoButton poId={po.id} poNumber={po.poNumber} />
)}
</div> </div>
</div> </div>
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
{po.status === "CANCELLED" && (
<div className="rounded-lg border border-danger-200 bg-danger-50 px-4 py-3">
<p className="text-sm font-semibold text-danger-700">
Cancelled{po.cancelledAt ? ` on ${formatDate(po.cancelledAt)}` : ""}
</p>
{po.cancellationReason && (
<p className="mt-0.5 text-sm text-danger-700">Reason: {po.cancellationReason}</p>
)}
<div className="mt-2 text-sm text-danger-700">
{po.supersededBy ? (
<p>
Superseded by{" "}
<Link href={`/po/${po.supersededBy.id}`} className="font-mono font-medium underline">
{po.supersededBy.poNumber}
</Link>
</p>
) : ["MANAGER", "SUPERUSER"].includes(currentRole) && !readOnly ? (
<div>
<p className="text-danger-700/80">Optionally link the PO that replaces this one:</p>
<SupersedeForm poId={po.id} />
</div>
) : null}
</div>
</div>
)}
{/* Reciprocal "supersedes" link — shown on the replacement PO */}
{po.supersedes && po.supersedes.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3">
<p className="text-sm text-neutral-700">
Supersedes{" "}
{po.supersedes.map((s, i) => (
<span key={s.id}>
{i > 0 && ", "}
<Link href={`/po/${s.id}`} className="font-mono font-medium text-primary-600 underline">
{s.poNumber}
</Link>
</span>
))}
</p>
</div>
)}
{/* Manager note banner */} {/* Manager note banner */}
{po.managerNote && ( {po.managerNote && (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3"> <div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,7 @@ export type NotificationEvent =
| "PO_APPROVED" | "PO_APPROVED"
| "PO_APPROVED_WITH_NOTE" | "PO_APPROVED_WITH_NOTE"
| "PO_REJECTED" | "PO_REJECTED"
| "PO_CANCELLED"
| "EDITS_REQUESTED" | "EDITS_REQUESTED"
| "VENDOR_ID_REQUESTED" | "VENDOR_ID_REQUESTED"
| "VENDOR_ID_PROVIDED" | "VENDOR_ID_PROVIDED"
@ -119,6 +120,9 @@ function buildInAppBody(
case "PO_REJECTED": case "PO_REJECTED":
return `${pn} rejected`; return `${pn} rejected`;
case "PO_CANCELLED":
return `${pn} has been cancelled`;
case "EDITS_REQUESTED": case "EDITS_REQUESTED":
return `Edits requested on ${pn}`; return `Edits requested on ${pn}`;
@ -215,6 +219,7 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
PO_APPROVED: `${base} has been approved`, PO_APPROVED: `${base} has been approved`,
PO_APPROVED_WITH_NOTE: `${base} has been approved`, PO_APPROVED_WITH_NOTE: `${base} has been approved`,
PO_REJECTED: `${base} has been rejected`, PO_REJECTED: `${base} has been rejected`,
PO_CANCELLED: `${base} has been cancelled`,
EDITS_REQUESTED: `Edits requested on ${base}`, EDITS_REQUESTED: `Edits requested on ${base}`,
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`, VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`, VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
@ -245,6 +250,8 @@ function buildEmailBody(
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`; return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
case "PO_REJECTED": case "PO_REJECTED":
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`; return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
case "PO_CANCELLED":
return `Purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">cancelled</span>.${noteHtml}`;
case "EDITS_REQUESTED": case "EDITS_REQUESTED":
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`; return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
case "VENDOR_ID_REQUESTED": case "VENDOR_ID_REQUESTED":

View file

@ -8,6 +8,7 @@ export type Permission =
| "view_all_pos" | "view_all_pos"
| "approve_po" | "approve_po"
| "reject_po" | "reject_po"
| "cancel_po"
| "request_edits" | "request_edits"
| "request_vendor_id" | "request_vendor_id"
| "process_payment" | "process_payment"
@ -33,6 +34,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_all_pos", "view_all_pos",
"approve_po", "approve_po",
"reject_po", "reject_po",
"cancel_po",
"request_edits", "request_edits",
"request_vendor_id", "request_vendor_id",
"view_analytics", "view_analytics",
@ -53,6 +55,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_all_pos", "view_all_pos",
"approve_po", "approve_po",
"reject_po", "reject_po",
"cancel_po",
"request_edits", "request_edits",
"request_vendor_id", "request_vendor_id",
"process_payment", "process_payment",

View file

@ -187,3 +187,15 @@ export function getAvailableActions(status: POStatus, role: Role): POAction[] {
export function requiresNote(from: POStatus, action: POAction): boolean { export function requiresNote(from: POStatus, action: POAction): boolean {
return getTransition(from, action)?.requiresNote ?? false; return getTransition(from, action)?.requiresNote ?? false;
} }
// ── Cancellation ──────────────────────────────────────────────────────────────
// Cancellation is orthogonal to the normal lifecycle: a PO can be cancelled from
// ANY state (except when it is already cancelled), by a MANAGER or SUPERUSER, and
// always requires a reason. It is modelled separately from TRANSITIONS so it does
// not have to be enumerated on every source state.
export const CANCEL_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
export function canCancel(from: POStatus, role: Role): boolean {
return from !== "CANCELLED" && CANCEL_ROLES.includes(role);
}

View file

@ -75,6 +75,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
PAID_DELIVERED: "Paid", PAID_DELIVERED: "Paid",
PARTIALLY_CLOSED: "Partially Received", PARTIALLY_CLOSED: "Partially Received",
CLOSED: "Closed", CLOSED: "Closed",
CANCELLED: "Cancelled",
}; };
// Statuses a PO can be in once it has received manager approval. A PO keeps its // Statuses a PO can be in once it has received manager approval. A PO keeps its
@ -110,4 +111,5 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
PAID_DELIVERED: "success", PAID_DELIVERED: "success",
PARTIALLY_CLOSED: "warning", PARTIALLY_CLOSED: "warning",
CLOSED: "secondary", CLOSED: "secondary",
CANCELLED: "danger",
}; };

View file

@ -0,0 +1,12 @@
-- Cancel + supersede: a new terminal CANCELLED status, cancel metadata, and a
-- self-referential supersede link (cancelled PO -> the existing PO that replaces it).
ALTER TYPE "POStatus" ADD VALUE 'CANCELLED';
ALTER TYPE "ActionType" ADD VALUE 'CANCELLED';
ALTER TYPE "ActionType" ADD VALUE 'SUPERSEDED';
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancelledAt" TIMESTAMP(3);
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancellationReason" TEXT;
ALTER TABLE "PurchaseOrder" ADD COLUMN "supersededById" TEXT;
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supersededById_fkey"
FOREIGN KEY ("supersededById") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -30,6 +30,7 @@ enum POStatus {
PAID_DELIVERED PAID_DELIVERED
PARTIALLY_CLOSED PARTIALLY_CLOSED
CLOSED CLOSED
CANCELLED
} }
enum ActionType { enum ActionType {
@ -49,6 +50,8 @@ enum ActionType {
REASSIGNED REASSIGNED
PRODUCT_PRICE_UPDATED PRODUCT_PRICE_UPDATED
MANAGER_LINE_EDIT MANAGER_LINE_EDIT
CANCELLED
SUPERSEDED
} }
enum RequestStatus { enum RequestStatus {
@ -270,6 +273,8 @@ model PurchaseOrder {
approvedAt DateTime? approvedAt DateTime?
paidAt DateTime? paidAt DateTime?
closedAt DateTime? closedAt DateTime?
cancelledAt DateTime?
cancellationReason String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -286,6 +291,12 @@ model PurchaseOrder {
siteId String? siteId String?
site Site? @relation(fields: [siteId], references: [id]) site Site? @relation(fields: [siteId], references: [id])
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
supersededById String?
supersededBy PurchaseOrder? @relation("Supersede", fields: [supersededById], references: [id])
supersedes PurchaseOrder[] @relation("Supersede")
lineItems POLineItem[] lineItems POLineItem[]
documents PODocument[] documents PODocument[]
actions POAction[] actions POAction[]

View file

@ -0,0 +1,181 @@
/**
* Integration tests for PO cancellation and supersede linkage.
* Covers: cancel from any state (MANAGER/SUPERUSER, reason required), exclusion
* from spend aggregation, and linking a cancelled PO to an existing replacement.
*
* POs are built directly via db.create (not the makePoForm helper) so the test is
* self-contained and cleans up cascade-safely (POAction has no onDelete: Cascade).
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor } from "./helpers";
import type { POStatus } from "@prisma/client";
const mockedAuth = vi.mocked(auth);
const PREFIX = "INTTEST_CANCEL_";
let techId: string;
let managerId: string;
let vesselId: string;
let accountId: string;
let vendorId: string;
let seq = 0;
beforeAll(async () => {
const [tech, mgr, vessel, account, vendor] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Galatea"),
getSeedAccount("700201"),
getSeedVendor("Apar Industries Ltd"),
]);
techId = tech.id; managerId = mgr.id;
vesselId = vessel.id; accountId = account.id; vendorId = vendor.id;
});
afterEach(async () => {
const pos = await db.purchaseOrder.findMany({ where: { title: { startsWith: PREFIX } }, select: { id: true } });
const ids = pos.map((p) => p.id);
if (ids.length === 0) return;
await db.purchaseOrder.updateMany({ where: { id: { in: ids } }, data: { supersededById: null } });
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
});
async function makePo(label: string, status: POStatus): Promise<string> {
seq += 1;
const po = await db.purchaseOrder.create({
data: {
poNumber: `CANCELTEST-${seq}-${label}`,
title: `${PREFIX}${label}`,
status,
totalAmount: 1180,
currency: "INR",
vesselId,
accountId,
submitterId: techId,
...(status === "MGR_APPROVED" ? { vendorId, approvedAt: new Date() } : {}),
lineItems: { create: [{ name: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, totalPrice: 1180, gstRate: 0.18, sortOrder: 0 }] },
actions: { create: { actionType: "CREATED", actorId: techId } },
},
});
return po.id;
}
describe("cancelPo", () => {
it("cancels a DRAFT PO with a reason and writes an audit row", async () => {
const poId = await makePo("Draft", "DRAFT");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
const result = await cancelPo({ poId, reason: "Duplicate order" });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(po.status).toBe("CANCELLED");
expect(po.cancelledAt).not.toBeNull();
expect(po.cancellationReason).toBe("Duplicate order");
const action = await db.pOAction.findFirst({ where: { poId, actionType: "CANCELLED" } });
expect(action?.note).toBe("Duplicate order");
});
it("cancels an already-APPROVED PO (cancellable from any state)", async () => {
const poId = await makePo("Approved", "MGR_APPROVED");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
const result = await cancelPo({ poId, reason: "Vendor backed out" });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(po.status).toBe("CANCELLED");
});
it("a cancelled PO drops out of the spend aggregation filter", async () => {
const poId = await makePo("Spend", "MGR_APPROVED");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
await cancelPo({ poId, reason: "Excluded from spend" });
expect(POST_APPROVAL_STATUSES as readonly string[]).not.toContain("CANCELLED");
const stillCounted = await db.purchaseOrder.findFirst({
where: { id: poId, status: { in: [...POST_APPROVAL_STATUSES] } },
});
expect(stillCounted).toBeNull();
});
it("requires a reason", async () => {
const poId = await makePo("NoReason", "DRAFT");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
const result = await cancelPo({ poId, reason: " " });
expect(result).toEqual({ error: "A cancellation reason is required." });
});
it("refuses a role without cancel_po (TECHNICAL)", async () => {
const poId = await makePo("Forbidden", "DRAFT");
mockedAuth.mockResolvedValue(makeSession(techId, "TECHNICAL") as never);
const result = await cancelPo({ poId, reason: "nope" });
expect(result).toHaveProperty("error");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(po.status).toBe("DRAFT");
});
it("refuses to cancel an already-cancelled PO", async () => {
const poId = await makePo("Twice", "DRAFT");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
await cancelPo({ poId, reason: "first" });
const result = await cancelPo({ poId, reason: "second" });
expect(result).toEqual({ error: "This purchase order is already cancelled." });
});
});
describe("supersedePo", () => {
it("links a cancelled PO to an existing replacement (reciprocal)", async () => {
const cancelledId = await makePo("Old", "DRAFT");
const replacementId = await makePo("New", "DRAFT");
const replacement = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId } });
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
await cancelPo({ poId: cancelledId, reason: "Replaced" });
const result = await supersedePo({ poId: cancelledId, replacementPoNumber: replacement.poNumber });
expect(result).toEqual({ ok: true });
const old = await db.purchaseOrder.findUniqueOrThrow({ where: { id: cancelledId } });
expect(old.supersededById).toBe(replacementId);
const repl = await db.purchaseOrder.findUniqueOrThrow({
where: { id: replacementId },
include: { supersedes: { select: { id: true } } },
});
expect(repl.supersedes.map((s) => s.id)).toContain(cancelledId);
});
it("refuses to supersede a PO that is not cancelled", async () => {
const poId = await makePo("NotCancelled", "DRAFT");
const otherId = await makePo("Other", "DRAFT");
const other = await db.purchaseOrder.findUniqueOrThrow({ where: { id: otherId } });
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
const result = await supersedePo({ poId, replacementPoNumber: other.poNumber });
expect(result).toEqual({ error: "Only a cancelled purchase order can be superseded." });
});
it("rejects an unknown replacement PO number", async () => {
const poId = await makePo("Unknown", "DRAFT");
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
await cancelPo({ poId, reason: "x" });
const result = await supersedePo({ poId, replacementPoNumber: "PMS/ZZZ/0000/2000-01" });
expect(result).toHaveProperty("error");
});
it("rejects self-supersede", async () => {
const poId = await makePo("Self", "DRAFT");
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
await cancelPo({ poId, reason: "x" });
const result = await supersedePo({ poId, replacementPoNumber: po.poNumber });
expect(result).toEqual({ error: "A purchase order cannot supersede itself." });
});
});