pelagia-portal/App/components/po/cancel-po-controls.tsx
Hardik 0b10ba5e54
All checks were successful
PR checks / checks (pull_request) Successful in 32s
feat(po): cancel POs (manager/superuser) + optional supersede link (#53)
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>
2026-06-21 12:20:54 +05:30

158 lines
6 KiB
TypeScript

"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>
);
}