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>
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
import type { POStatus, Role } from "@prisma/client";
|
|
|
|
export type POAction =
|
|
| "submit"
|
|
| "approve"
|
|
| "approve_with_note"
|
|
| "reject"
|
|
| "request_edits"
|
|
| "request_vendor_id"
|
|
| "provide_vendor_id"
|
|
| "process_payment"
|
|
| "mark_paid"
|
|
| "mark_partial_payment"
|
|
| "confirm_receipt"
|
|
| "confirm_partial_receipt";
|
|
|
|
export type SideEffect =
|
|
| "EMAIL_MANAGER"
|
|
| "EMAIL_SUBMITTER"
|
|
| "EMAIL_ACCOUNTS"
|
|
| "EMAIL_SUBMITTER_AND_MANAGER";
|
|
|
|
interface Transition {
|
|
to: POStatus;
|
|
allowedRoles: Role[];
|
|
requiresNote: boolean;
|
|
sideEffects: SideEffect[];
|
|
}
|
|
|
|
type TransitionMap = Partial<Record<POAction, Transition>>;
|
|
|
|
const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
|
|
DRAFT: {
|
|
submit: {
|
|
to: "SUBMITTED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_MANAGER"],
|
|
},
|
|
},
|
|
SUBMITTED: {
|
|
// Auto-advances to MGR_REVIEW in the server action immediately after SUBMITTED
|
|
},
|
|
MGR_REVIEW: {
|
|
approve: {
|
|
to: "MGR_APPROVED",
|
|
allowedRoles: ["MANAGER", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"],
|
|
},
|
|
approve_with_note: {
|
|
to: "MGR_APPROVED",
|
|
allowedRoles: ["MANAGER", "SUPERUSER"],
|
|
requiresNote: true,
|
|
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_ACCOUNTS"],
|
|
},
|
|
reject: {
|
|
to: "REJECTED",
|
|
allowedRoles: ["MANAGER", "SUPERUSER"],
|
|
requiresNote: true,
|
|
sideEffects: ["EMAIL_SUBMITTER"],
|
|
},
|
|
request_edits: {
|
|
to: "EDITS_REQUESTED",
|
|
allowedRoles: ["MANAGER", "SUPERUSER"],
|
|
requiresNote: true,
|
|
sideEffects: ["EMAIL_SUBMITTER"],
|
|
},
|
|
request_vendor_id: {
|
|
to: "VENDOR_ID_PENDING",
|
|
allowedRoles: ["MANAGER", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_SUBMITTER"],
|
|
},
|
|
},
|
|
VENDOR_ID_PENDING: {
|
|
provide_vendor_id: {
|
|
to: "MGR_REVIEW",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "ACCOUNTS", "MANAGER", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_MANAGER"],
|
|
},
|
|
},
|
|
EDITS_REQUESTED: {
|
|
submit: {
|
|
to: "SUBMITTED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_MANAGER"],
|
|
},
|
|
},
|
|
MGR_APPROVED: {
|
|
process_payment: {
|
|
to: "SENT_FOR_PAYMENT",
|
|
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_SUBMITTER_AND_MANAGER"],
|
|
},
|
|
},
|
|
SENT_FOR_PAYMENT: {
|
|
mark_paid: {
|
|
to: "PAID_DELIVERED",
|
|
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"],
|
|
},
|
|
mark_partial_payment: {
|
|
to: "PARTIALLY_PAID",
|
|
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
},
|
|
PARTIALLY_PAID: {
|
|
mark_paid: {
|
|
to: "PAID_DELIVERED",
|
|
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
mark_partial_payment: {
|
|
to: "PARTIALLY_PAID",
|
|
allowedRoles: ["ACCOUNTS", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
confirm_receipt: {
|
|
to: "CLOSED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
confirm_partial_receipt: {
|
|
to: "PARTIALLY_PAID",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
},
|
|
PAID_DELIVERED: {
|
|
confirm_receipt: {
|
|
to: "CLOSED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
|
},
|
|
confirm_partial_receipt: {
|
|
to: "PARTIALLY_CLOSED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
},
|
|
PARTIALLY_CLOSED: {
|
|
confirm_receipt: {
|
|
to: "CLOSED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: ["EMAIL_MANAGER", "EMAIL_ACCOUNTS"],
|
|
},
|
|
confirm_partial_receipt: {
|
|
to: "PARTIALLY_CLOSED",
|
|
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"],
|
|
requiresNote: false,
|
|
sideEffects: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
export function getTransition(from: POStatus, action: POAction): Transition | null {
|
|
return TRANSITIONS[from]?.[action] ?? null;
|
|
}
|
|
|
|
export function canPerformAction(from: POStatus, action: POAction, role: Role): boolean {
|
|
const transition = getTransition(from, action);
|
|
return transition?.allowedRoles.includes(role) ?? false;
|
|
}
|
|
|
|
export function getAvailableActions(status: POStatus, role: Role): POAction[] {
|
|
const map = TRANSITIONS[status];
|
|
if (!map) return [];
|
|
return (Object.keys(map) as POAction[]).filter((action) =>
|
|
canPerformAction(status, action, role)
|
|
);
|
|
}
|
|
|
|
export function requiresNote(from: POStatus, action: POAction): boolean {
|
|
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);
|
|
}
|