The theme only defines danger / danger-50 / danger-100 / danger-700, so bg-danger-600 / danger-500 / danger-200 generated no CSS — the modal confirm button was white-on-nothing (invisible) and the header button wasn't red. - Header "Cancel PO" button → solid red (bg-danger text-white hover:bg-danger-700) - Modal "Cancel this PO" confirm button → bg-danger (now visible) - Inputs/asterisk/banner border → defined danger tokens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
5.9 KiB
TypeScript
158 lines
5.9 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 bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 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">*</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 focus:outline-none focus:ring-2 focus:ring-danger/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 focus:outline-none focus:ring-2 focus:ring-danger/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 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>
|
|
);
|
|
}
|