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>
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { processPayment, markPaid } from "./actions";
|
|
import type { POStatus } from "@prisma/client";
|
|
|
|
interface Props {
|
|
poId: string;
|
|
poStatus: POStatus;
|
|
totalAmount?: number;
|
|
paidAmount?: number;
|
|
// Manager's advance decision (issue #92) — absolute amount. Prefills the FIRST
|
|
// payment's amount field; ignored once any payment has been recorded.
|
|
suggestedAdvancePayment?: number | null;
|
|
}
|
|
|
|
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
|
|
function todayLocal(): string {
|
|
const d = new Date();
|
|
const off = d.getTimezoneOffset();
|
|
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function PaymentActions({
|
|
poId,
|
|
poStatus,
|
|
totalAmount = 0,
|
|
paidAmount = 0,
|
|
suggestedAdvancePayment = null,
|
|
}: Props) {
|
|
const router = useRouter();
|
|
const remaining = totalAmount - paidAmount;
|
|
|
|
// Prefill the first payment with the Manager's advance, when it's a genuine
|
|
// partial of the (untouched) total. Nothing paid yet ⇒ first payment; a full
|
|
// (>= total) advance leaves the field blank so "Confirm Full Payment" is used.
|
|
const advancePrefill =
|
|
paidAmount === 0 &&
|
|
suggestedAdvancePayment != null &&
|
|
suggestedAdvancePayment > 0 &&
|
|
suggestedAdvancePayment < remaining
|
|
? String(suggestedAdvancePayment)
|
|
: "";
|
|
|
|
const [ref, setRef] = useState("");
|
|
const [amount, setAmount] = useState<string>(advancePrefill);
|
|
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const today = todayLocal();
|
|
|
|
async function handleProcessPayment() {
|
|
setPending(true);
|
|
setError("");
|
|
const result = await processPayment({ poId });
|
|
if ("error" in result) { setError(result.error); setPending(false); }
|
|
else { setPending(false); router.refresh(); }
|
|
}
|
|
|
|
async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
|
|
e.preventDefault();
|
|
if (!ref.trim()) { setError("Payment reference is required."); return; }
|
|
if (!paymentDate) { setError("Payment date is required."); return; }
|
|
if (paymentDate > today) { setError("Payment date cannot be in the future."); return; }
|
|
|
|
const paymentAmount = forceFullPayment ? remaining : (parseFloat(amount) || undefined);
|
|
|
|
if (paymentAmount !== undefined && paymentAmount <= 0) {
|
|
setError("Payment amount must be greater than 0.");
|
|
return;
|
|
}
|
|
if (paymentAmount !== undefined && paymentAmount > remaining) {
|
|
setError(`Payment amount cannot exceed the remaining balance of ${remaining.toFixed(2)}.`);
|
|
return;
|
|
}
|
|
|
|
setPending(true);
|
|
setError("");
|
|
const result = await markPaid({ poId, paymentRef: ref, paymentAmount, paymentDate });
|
|
if ("error" in result) { setError(result.error); setPending(false); }
|
|
else { setPending(false); router.refresh(); }
|
|
}
|
|
|
|
if (poStatus === "MGR_APPROVED") {
|
|
return (
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
{error && <span className="text-xs text-danger-700">{error}</span>}
|
|
<button
|
|
onClick={handleProcessPayment}
|
|
disabled={pending}
|
|
className="w-full sm:w-auto rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
|
>
|
|
{pending ? "Processing…" : "Start Payment Processing"}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
poStatus === "SENT_FOR_PAYMENT" ||
|
|
poStatus === "PARTIALLY_PAID" ||
|
|
poStatus === "PARTIALLY_CLOSED"
|
|
) {
|
|
const parsedAmount = parseFloat(amount);
|
|
const isPartialPayment =
|
|
!isNaN(parsedAmount) && parsedAmount > 0 && parsedAmount < remaining;
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => handleMarkPaid(e)}
|
|
className="flex flex-col gap-2 w-full sm:w-auto"
|
|
>
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Payment reference / transaction ID"
|
|
value={ref}
|
|
onChange={(e) => setRef(e.target.value)}
|
|
className="flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
/>
|
|
<input
|
|
type="date"
|
|
aria-label="Payment date"
|
|
title="Payment date"
|
|
value={paymentDate}
|
|
max={today}
|
|
required
|
|
onChange={(e) => setPaymentDate(e.target.value)}
|
|
className="w-full sm:w-40 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
/>
|
|
<input
|
|
type="number"
|
|
placeholder={`Amount (max ${remaining.toFixed(2)})`}
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
min={0.01}
|
|
max={remaining}
|
|
step="0.01"
|
|
className="w-full sm:w-36 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
|
/>
|
|
</div>
|
|
{advancePrefill && (
|
|
<span className="text-xs text-primary-700">
|
|
Manager set an advance of {Number(suggestedAdvancePayment).toFixed(2)} — prefilled below; adjust if needed.
|
|
</span>
|
|
)}
|
|
{error && <span className="text-xs text-danger-700">{error}</span>}
|
|
<div className="flex gap-2 justify-end">
|
|
{isPartialPayment && (
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className="rounded-lg bg-warning px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
|
>
|
|
{pending ? "Confirming…" : "Confirm Partial Payment"}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={pending}
|
|
onClick={(e) => handleMarkPaid(e as unknown as React.FormEvent, true)}
|
|
className="rounded-lg bg-success px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
|
>
|
|
{pending ? "Confirming…" : "Confirm Full Payment"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|