The approving Manager decides how much of the PO is paid first, via a 0–100% slider on the approval card (default 100% = full). The slider is convenience only — the resolved ABSOLUTE amount is stored on PurchaseOrder.suggestedAdvancePayment (Decimal(12,2), nullable). - schema + migration: add suggestedAdvancePayment (null = no explicit advance ⇒ full payment, preserves legacy behaviour). - approvePo(): accepts the amount, clamps to [0, totalAmount], persists it, records it on the APPROVED audit row. Set once at approval; never edited afterwards. - approval-actions.tsx: whole-percent slider showing the resolved ₹ amount + remaining balance; value sent with Approve / Approve-with-Remarks. - Accounts surface: payment queue + PO detail show the advance; it prefills the FIRST payment amount (only when nothing is paid yet and it is a true partial). Balance runs the normal PARTIALLY_PAID loop. - Not shown on the exported PO/invoice (po-export-layout untouched). - Tests: persist + audit metadata + clamp-to-total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
7.3 KiB
TypeScript
171 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
|
|
import { formatCurrency } from "@/lib/utils";
|
|
import type { POStatus } from "@prisma/client";
|
|
|
|
// Resolve the slider percent (whole number) into an absolute advance amount.
|
|
// 100% is the exact total (no rounding loss on paise); partial advances are
|
|
// rounded to whole rupees — the slider is convenience, the amount is the record.
|
|
function advanceAmount(total: number, percent: number): number {
|
|
if (percent >= 100) return total;
|
|
if (percent <= 0) return 0;
|
|
return Math.round((total * percent) / 100);
|
|
}
|
|
|
|
export function ApprovalActions({
|
|
poId,
|
|
poStatus,
|
|
totalAmount = 0,
|
|
currency = "INR",
|
|
}: {
|
|
poId: string;
|
|
poStatus: POStatus;
|
|
totalAmount?: number;
|
|
currency?: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const [note, setNote] = useState("");
|
|
const [advancePercent, setAdvancePercent] = useState(100);
|
|
const [activeAction, setActiveAction] = useState<string | null>(null);
|
|
const [pending, setPending] = useState<string | null>(null);
|
|
const [error, setError] = useState("");
|
|
|
|
const advance = advanceAmount(totalAmount, advancePercent);
|
|
|
|
async function dispatch(action: string, requireNote = false) {
|
|
if (requireNote && !note.trim()) {
|
|
setError("A note is required for this action.");
|
|
return;
|
|
}
|
|
setPending(action);
|
|
setError("");
|
|
let result: { ok: true } | { error: string } | undefined;
|
|
// Approvals carry the Manager's advance decision (resolved amount, not %).
|
|
if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance });
|
|
else if (action === "approve_note")
|
|
result = await approvePo({ poId, note, withNote: true, suggestedAdvancePayment: advance });
|
|
else if (action === "reject") result = await rejectPo({ poId, note });
|
|
else if (action === "request_edits") result = await requestEdits({ poId, note });
|
|
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
|
|
|
|
if (result && "error" in result) {
|
|
setError(result.error);
|
|
setPending(null);
|
|
} else {
|
|
router.push("/approvals");
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
|
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
|
|
|
|
{/* Advance payment (issue #92) — Manager decides how much Accounts pays
|
|
first. 100% = full payment; lower values seed the first part-payment. */}
|
|
<div className="mb-5 rounded-lg border border-neutral-200 bg-neutral-50 p-3.5">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label htmlFor="advance-slider" className="text-sm font-medium text-neutral-700">
|
|
Advance payment
|
|
</label>
|
|
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
|
{advancePercent}% · {formatCurrency(advance, currency)}
|
|
</span>
|
|
</div>
|
|
<input
|
|
id="advance-slider"
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={advancePercent}
|
|
onChange={(e) => setAdvancePercent(Number(e.target.value))}
|
|
className="w-full accent-primary-600"
|
|
/>
|
|
<p className="mt-1.5 text-xs text-neutral-500">
|
|
{advancePercent >= 100
|
|
? "Full payment — Accounts will be prompted to pay the whole PO value."
|
|
: `Accounts will be prompted to pay ${formatCurrency(advance, currency)} first; the balance of ${formatCurrency(
|
|
Math.max(totalAmount - advance, 0),
|
|
currency
|
|
)} follows the usual part-payment flow.`}
|
|
</p>
|
|
</div>
|
|
|
|
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
|
Note {activeAction !== "approve_note" ? "(required)" : "(optional)"}
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none"
|
|
placeholder={
|
|
activeAction === "reject"
|
|
? "Reason for rejection…"
|
|
: activeAction === "request_edits"
|
|
? "What needs to be changed…"
|
|
: "Optional note for the submitter…"
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mb-4 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
|
)}
|
|
|
|
<div className="flex flex-col sm:flex-row flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setActiveAction("reject");
|
|
if (activeAction === "reject") dispatch("reject", true);
|
|
}}
|
|
disabled={!!pending}
|
|
className="w-full sm:w-auto rounded-lg border border-danger bg-danger-50 px-4 py-2.5 text-sm font-medium text-danger-700 hover:bg-danger-100 disabled:opacity-60 transition-colors"
|
|
>
|
|
{pending === "reject" ? "Rejecting…" : activeAction === "reject" ? "Confirm Reject" : "Reject"}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setActiveAction("request_edits");
|
|
if (activeAction === "request_edits") dispatch("request_edits", true);
|
|
}}
|
|
disabled={!!pending}
|
|
className="w-full sm:w-auto rounded-lg border border-warning bg-warning-50 px-4 py-2.5 text-sm font-medium text-warning-700 hover:bg-warning-100 disabled:opacity-60 transition-colors"
|
|
>
|
|
{pending === "request_edits" ? "Sending…" : activeAction === "request_edits" ? "Send Edit Request" : "Request Edits"}
|
|
</button>
|
|
<button
|
|
onClick={() => dispatch("request_vendor_id")}
|
|
disabled={!!pending}
|
|
className="w-full sm:w-auto rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
|
|
>
|
|
{pending === "request_vendor_id" ? "Sending…" : "Request Vendor ID"}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setActiveAction("approve_note");
|
|
if (activeAction === "approve_note") dispatch("approve_note");
|
|
}}
|
|
disabled={!!pending}
|
|
className="w-full sm:w-auto rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
|
|
>
|
|
{pending === "approve_note" ? "Approving…" : activeAction === "approve_note" ? "Confirm Approve with Remarks" : "Approve with Remarks"}
|
|
</button>
|
|
<button
|
|
onClick={() => dispatch("approve")}
|
|
disabled={!!pending}
|
|
className="w-full sm:w-auto rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
|
>
|
|
{pending === "approve" ? "Approving…" : "Approve"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|