pelagia-portal/App/app/(portal)/approvals/[id]/approval-actions.tsx
Hardik 99c928213b
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
feat(po): manager sets advance payment on approval (issue #92)
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>
2026-06-24 01:40:20 +05:30

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