diff --git a/App/app/(portal)/admin/sites/[id]/page.tsx b/App/app/(portal)/admin/sites/[id]/page.tsx index a80239c..c1a3b84 100644 --- a/App/app/(portal)/admin/sites/[id]/page.tsx +++ b/App/app/(portal)/admin/sites/[id]/page.tsx @@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) { const STATUS_LABELS: Record = { DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed", - SUBMITTED: "Submitted", REJECTED: "Rejected", + SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled", }; return ( diff --git a/App/app/(portal)/admin/vendors/[id]/page.tsx b/App/app/(portal)/admin/vendors/[id]/page.tsx index 8e9de87..d7ce567 100644 --- a/App/app/(portal)/admin/vendors/[id]/page.tsx +++ b/App/app/(portal)/admin/vendors/[id]/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise { const STATUS_LABELS: Record = { DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review", 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", }; diff --git a/App/app/(portal)/admin/vessels/[id]/page.tsx b/App/app/(portal)/admin/vessels/[id]/page.tsx index 5468416..25bea60 100644 --- a/App/app/(portal)/admin/vessels/[id]/page.tsx +++ b/App/app/(portal)/admin/vessels/[id]/page.tsx @@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) { const STATUS_LABELS: Record = { DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review", 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") diff --git a/App/app/(portal)/history/history-filters.tsx b/App/app/(portal)/history/history-filters.tsx index 931b4ea..54e7f72 100644 --- a/App/app/(portal)/history/history-filters.tsx +++ b/App/app/(portal)/history/history-filters.tsx @@ -14,6 +14,7 @@ const STATUSES = [ { value: "PAID_DELIVERED", label: "Paid / Delivered" }, { value: "CLOSED", label: "Closed" }, { value: "REJECTED", label: "Rejected" }, + { value: "CANCELLED", label: "Cancelled" }, ]; interface Props { diff --git a/App/app/(portal)/history/page.tsx b/App/app/(portal)/history/page.tsx index 89f94d9..56b91bf 100644 --- a/App/app/(portal)/history/page.tsx +++ b/App/app/(portal)/history/page.tsx @@ -115,7 +115,10 @@ export default async function HistoryPage({ searchParams }: Props) { {orders.map((po) => ( - + {po.poNumber} diff --git a/App/app/(portal)/po/[id]/actions.ts b/App/app/(portal)/po/[id]/actions.ts index 0c9f3e5..6c51e13 100644 --- a/App/app/(portal)/po/[id]/actions.ts +++ b/App/app/(portal)/po/[id]/actions.ts @@ -2,7 +2,8 @@ import { auth } from "@/auth"; 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 { revalidatePath } from "next/cache"; @@ -113,3 +114,118 @@ export async function discardDraftPo( revalidatePath("/dashboard"); 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 }; +} diff --git a/App/app/(portal)/po/[id]/page.tsx b/App/app/(portal)/po/[id]/page.tsx index 4884165..e61d47b 100644 --- a/App/app/(portal)/po/[id]/page.tsx +++ b/App/app/(portal)/po/[id]/page.tsx @@ -32,6 +32,8 @@ export default async function PoDetailPage({ params }: Props) { documents: { orderBy: { uploadedAt: "desc" } }, actions: { include: { actor: true }, orderBy: { createdAt: "asc" } }, receipt: true, + supersededBy: { select: { id: true, poNumber: true } }, + supersedes: { select: { id: true, poNumber: true } }, }, }); diff --git a/App/app/api/po/[id]/export/route.ts b/App/app/api/po/[id]/export/route.ts index f7cc069..96213ed 100644 --- a/App/app/api/po/[id]/export/route.ts +++ b/App/app/api/po/[id]/export/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; import ExcelJS from "exceljs"; import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; 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) ────── @@ -65,9 +66,11 @@ export async function GET(request: NextRequest, { params }: Props) { 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. - 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)) { return NextResponse.json( { 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 }); 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 ───────────────────────────────────────────────────────── const buf = await wb.xlsx.writeBuffer(); const slug = po.poNumber.replace(/\//g, "-"); @@ -665,6 +678,24 @@ export async function GET(request: NextRequest, { params }: Props) { 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 { .no-print { display: none; } body { margin: 8mm 10mm; } @@ -674,6 +705,8 @@ export async function GET(request: NextRequest, { params }: Props) { +${isCancelled ? `
CANCELLED
` : ""} +
+ + {open && ( +
+
e.stopPropagation()} + > +

Cancel {poNumber}?

+

+ This marks the purchase order as cancelled and removes its value from + all spend trackers and graphs. This cannot be undone. +

+ + +