All checks were successful
PR checks / checks (pull_request) Successful in 32s
Managers and superusers can cancel a PO from any state via a confirmation modal that requires typing "cancel" and a mandatory reason. A cancelled PO becomes a terminal CANCELLED state and drops out of every spend tracker/graph (those filter on POST_APPROVAL_STATUSES / explicit whitelists, none of which include CANCELLED). A cancelled PO may optionally be linked to the existing PO that supersedes it (by PO number); the replacement shows the reciprocal "supersedes" link. No vessel/account/vendor match is enforced and the link can be added any time. Cancelled POs remain visible (greyed in history) and exportable, with a diagonal "CANCELLED" watermark on both the PDF and XLSX exports. - schema: POStatus CANCELLED; cancelledAt/cancellationReason; self-referential supersededById relation; ActionType CANCELLED/SUPERSEDED (+ migration) - state machine canCancel(); cancel_po permission (MANAGER + SUPERUSER) - cancelPo / supersedePo server actions + PO_CANCELLED notification - cancel modal + supersede form; cancelled banner with reciprocal links - exhaustive CANCELLED entries in all status label/variant maps - diagonal CANCELLED watermark embedded for PDF (CSS) and XLSX (image) - integration tests (cancel from any state, reason/role guards, supersede) Inventory reversal on cancel is deferred to #55 (inventory is feature-flagged off). Closes #53 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { canPerformAction, canCancel } from "@/lib/po-state-machine";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { notify } from "@/lib/notifier";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
export async function provideVendorId({
|
|
poId,
|
|
vendorId,
|
|
}: {
|
|
poId: string;
|
|
vendorId: string;
|
|
}): Promise<{ ok: true } | { error: string }> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
if (!vendorId) return { error: "Please select a vendor with a verified ID." };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { submitter: true },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (!canPerformAction(po.status, "provide_vendor_id", session.user.role)) {
|
|
return { error: "You cannot provide a vendor ID for this PO in its current state." };
|
|
}
|
|
|
|
const vendor = await db.vendor.findUnique({ where: { id: vendorId } });
|
|
if (!vendor?.vendorId) return { error: "The selected vendor does not have a verified ID." };
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
vendorId,
|
|
status: "MGR_REVIEW",
|
|
actions: {
|
|
create: { actionType: "VENDOR_ID_PROVIDED", actorId: session.user.id },
|
|
},
|
|
},
|
|
});
|
|
|
|
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
|
await notify({ event: "VENDOR_ID_PROVIDED", po, recipients: managers });
|
|
|
|
revalidatePath(`/po/${poId}`);
|
|
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: managers });
|
|
|
|
revalidatePath(`/po/${poId}`);
|
|
revalidatePath("/dashboard");
|
|
revalidatePath("/my-orders");
|
|
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 };
|
|
}
|
|
|
|
// ── 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 };
|
|
}
|