feat(payments): partial/advance payment support
Allow accounts to record partial/advance payments against a PO before full delivery. A new PARTIALLY_PAID status tracks in-progress payment; paidAmount accumulates across multiple markPaid calls. PO only closes when both paidAmount >= totalAmount AND all line items are delivered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7169d52885
commit
cf9ff40262
14 changed files with 285 additions and 74 deletions
|
|
@ -31,7 +31,7 @@ export default async function DashboardPage() {
|
|||
async function SubmitterDashboard({ userId }: { userId: string }) {
|
||||
const [openCount, pendingCount, closedCount, recentPos] = await Promise.all([
|
||||
db.purchaseOrder.count({
|
||||
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"] } },
|
||||
where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED", "PARTIALLY_PAID"] } },
|
||||
}),
|
||||
db.purchaseOrder.count({
|
||||
where: { submitterId: userId, status: "MGR_REVIEW" },
|
||||
|
|
@ -110,7 +110,7 @@ async function ManagerDashboard() {
|
|||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||
|
||||
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"] as const;
|
||||
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "CLOSED"] as const;
|
||||
|
||||
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
||||
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
||||
|
|
@ -120,7 +120,7 @@ async function ManagerDashboard() {
|
|||
where: { status: { in: [...approvedStatuses] } },
|
||||
}),
|
||||
db.purchaseOrder.findMany({
|
||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED"] } },
|
||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED"] } },
|
||||
orderBy: { approvedAt: "desc" },
|
||||
take: 8,
|
||||
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } },
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default async function MyOrdersPage() {
|
|||
});
|
||||
|
||||
const open = pos.filter((p) =>
|
||||
["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"].includes(p.status)
|
||||
["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED", "PARTIALLY_PAID"].includes(p.status)
|
||||
);
|
||||
const closed = pos.filter((p) =>
|
||||
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED", "REJECTED"].includes(p.status)
|
||||
|
|
|
|||
|
|
@ -127,18 +127,20 @@ export async function processPayment({ poId }: { poId: string }): Promise<Action
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
// Step 2: Accounts confirms payment sent — SENT_FOR_PAYMENT → PAID_DELIVERED
|
||||
// Step 2: Accounts confirms payment sent — SENT_FOR_PAYMENT / PARTIALLY_PAID → PAID_DELIVERED or PARTIALLY_PAID
|
||||
export async function markPaid({
|
||||
poId,
|
||||
paymentRef,
|
||||
paymentAmount,
|
||||
}: {
|
||||
poId: string;
|
||||
paymentRef: string;
|
||||
paymentAmount?: number; // if omitted, treat as full remaining amount
|
||||
}): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
|
||||
const parsed = processPaymentSchema.safeParse({ paymentRef });
|
||||
const parsed = processPaymentSchema.safeParse({ paymentRef, paymentAmount });
|
||||
if (!parsed.success) return { error: "Payment reference is required." };
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
|
|
@ -146,16 +148,28 @@ export async function markPaid({
|
|||
include: { submitter: true, lineItems: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (!canPerformAction(po.status, "mark_paid", session.user.role)) {
|
||||
|
||||
const canFullPay = canPerformAction(po.status, "mark_paid", session.user.role);
|
||||
const canPartialPay = canPerformAction(po.status, "mark_partial_payment", session.user.role);
|
||||
if (!canFullPay && !canPartialPay) {
|
||||
return { error: "You cannot confirm payment for this PO." };
|
||||
}
|
||||
|
||||
const alreadyPaid = Number(po.paidAmount ?? 0);
|
||||
const total = Number(po.totalAmount);
|
||||
const remaining = total - alreadyPaid;
|
||||
const paying = parsed.data.paymentAmount ?? remaining;
|
||||
const newPaidAmount = alreadyPaid + paying;
|
||||
const isFullyPaid = newPaidAmount >= total;
|
||||
|
||||
if (isFullyPaid) {
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
status: "PAID_DELIVERED",
|
||||
paidAt: new Date(),
|
||||
paymentRef: parsed.data.paymentRef,
|
||||
paidAmount: newPaidAmount,
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "PAYMENT_SENT",
|
||||
|
|
@ -178,6 +192,27 @@ export async function markPaid({
|
|||
}
|
||||
|
||||
await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter] });
|
||||
} else {
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
status: "PARTIALLY_PAID",
|
||||
paymentRef: parsed.data.paymentRef,
|
||||
paidAmount: newPaidAmount,
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "PARTIAL_PAYMENT_CONFIRMED",
|
||||
actorId: session.user.id,
|
||||
metadata: {
|
||||
paymentRef: parsed.data.paymentRef,
|
||||
paymentAmount: paying,
|
||||
totalPaid: newPaidAmount,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/payments");
|
||||
revalidatePath(`/po/${poId}`);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
|||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { formatCurrency, formatDate, PO_STATUS_LABELS } from "@/lib/utils";
|
||||
import { PaymentActions } from "./payment-actions";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ export default async function PaymentsPage() {
|
|||
if (!hasPermission(session.user.role, "process_payment")) redirect("/dashboard");
|
||||
|
||||
const queue = await db.purchaseOrder.findMany({
|
||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT"] } },
|
||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PARTIALLY_CLOSED"] } },
|
||||
include: { submitter: true, vessel: true, account: true, vendor: true },
|
||||
orderBy: { approvedAt: "asc" },
|
||||
});
|
||||
|
|
@ -75,14 +75,36 @@ export default async function PaymentsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-neutral-100 pt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<span className={`text-xs font-medium rounded-full px-2.5 py-0.5 ${
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`text-xs font-medium rounded-full px-2.5 py-0.5 self-start ${
|
||||
po.status === "SENT_FOR_PAYMENT"
|
||||
? "bg-primary-50 text-primary-700"
|
||||
: po.status === "PARTIALLY_PAID"
|
||||
? "bg-warning-50 text-warning-700"
|
||||
: po.status === "PARTIALLY_CLOSED"
|
||||
? "bg-warning-50 text-warning-700"
|
||||
: "bg-warning-50 text-warning-700"
|
||||
}`}>
|
||||
{po.status === "SENT_FOR_PAYMENT" ? "Processing — awaiting confirmation" : "Ready for payment"}
|
||||
{po.status === "SENT_FOR_PAYMENT"
|
||||
? "Processing — awaiting confirmation"
|
||||
: po.status === "PARTIALLY_PAID"
|
||||
? "Partially paid — additional payment needed"
|
||||
: po.status === "PARTIALLY_CLOSED"
|
||||
? "Partially received — awaiting more payments"
|
||||
: "Ready for payment"}
|
||||
</span>
|
||||
<PaymentActions poId={po.id} poStatus={po.status} />
|
||||
{(po.status === "PARTIALLY_PAID" || po.status === "PARTIALLY_CLOSED") && po.paidAmount != null && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PaymentActions
|
||||
poId={po.id}
|
||||
poStatus={po.status}
|
||||
totalAmount={Number(po.totalAmount)}
|
||||
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,22 @@ import { useRouter } from "next/navigation";
|
|||
import { processPayment, markPaid } from "./actions";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POStatus }) {
|
||||
interface Props {
|
||||
poId: string;
|
||||
poStatus: POStatus;
|
||||
totalAmount?: number;
|
||||
paidAmount?: number;
|
||||
}
|
||||
|
||||
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
|
||||
const router = useRouter();
|
||||
const [ref, setRef] = useState("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const remaining = totalAmount - paidAmount;
|
||||
|
||||
async function handleProcessPayment() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
|
|
@ -19,12 +29,24 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
|
|||
else { router.refresh(); }
|
||||
}
|
||||
|
||||
async function handleMarkPaid(e: React.FormEvent) {
|
||||
async function handleMarkPaid(e: React.FormEvent, forceFullPayment = false) {
|
||||
e.preventDefault();
|
||||
if (!ref.trim()) { setError("Payment reference is required."); 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 });
|
||||
const result = await markPaid({ poId, paymentRef: ref, paymentAmount });
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { router.refresh(); }
|
||||
}
|
||||
|
|
@ -44,9 +66,21 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
|
|||
);
|
||||
}
|
||||
|
||||
if (poStatus === "SENT_FOR_PAYMENT") {
|
||||
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={handleMarkPaid} className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full">
|
||||
<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"
|
||||
|
|
@ -54,14 +88,37 @@ export function PaymentActions({ poId, poStatus }: { poId: string; poStatus: POS
|
|||
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="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>
|
||||
{error && <span className="text-xs text-danger-700">{error}</span>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
{isPartialPayment && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="w-full sm:w-auto rounded-lg bg-success px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
||||
className="rounded-lg bg-warning-600 px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
|
||||
>
|
||||
{pending ? "Confirming…" : "Confirm Payment Sent"}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export async function confirmReceipt({
|
|||
if (!po) return { error: "PO not found" };
|
||||
|
||||
const isAllowedStatus =
|
||||
po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED";
|
||||
po.status === "PAID_DELIVERED" ||
|
||||
po.status === "PARTIALLY_CLOSED" ||
|
||||
po.status === "PARTIALLY_PAID";
|
||||
if (!isAllowedStatus) {
|
||||
return { error: "You cannot confirm receipt on this PO in its current state." };
|
||||
}
|
||||
|
|
@ -72,8 +74,23 @@ export async function confirmReceipt({
|
|||
|
||||
// Determine if all items are now fully delivered
|
||||
const allDelivered = lineUpdates.every((u) => u.deliveredQuantity >= u.quantity);
|
||||
const newStatus = allDelivered ? "CLOSED" : "PARTIALLY_CLOSED";
|
||||
const isPartial = !allDelivered;
|
||||
|
||||
// Re-fetch paidAmount for accurate check
|
||||
const updatedPo = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
select: { paidAmount: true, totalAmount: true },
|
||||
});
|
||||
const fullyPaid =
|
||||
Number(updatedPo?.paidAmount ?? 0) >= Number(updatedPo?.totalAmount ?? 0);
|
||||
|
||||
const newStatus: "CLOSED" | "PARTIALLY_CLOSED" | "PARTIALLY_PAID" =
|
||||
allDelivered && fullyPaid
|
||||
? "CLOSED"
|
||||
: !allDelivered && fullyPaid
|
||||
? "PARTIALLY_CLOSED"
|
||||
: "PARTIALLY_PAID";
|
||||
|
||||
const isPartial = newStatus !== "CLOSED";
|
||||
|
||||
// Persist delivery quantities
|
||||
await Promise.all(
|
||||
|
|
@ -90,7 +107,7 @@ export async function confirmReceipt({
|
|||
where: { id: poId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
closedAt: allDelivered ? new Date() : undefined,
|
||||
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
||||
receipt: notes
|
||||
? { create: { storageKey: "", fileName: "no-file", notes } }
|
||||
: undefined,
|
||||
|
|
@ -133,7 +150,7 @@ export async function confirmReceipt({
|
|||
}
|
||||
|
||||
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||
if (allDelivered) {
|
||||
if (newStatus === "CLOSED") {
|
||||
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||
await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export default async function ReceiptPage({ params }: Props) {
|
|||
title: true,
|
||||
status: true,
|
||||
submitterId: true,
|
||||
paidAmount: true,
|
||||
totalAmount: true,
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
|
|
@ -38,7 +40,11 @@ export default async function ReceiptPage({ params }: Props) {
|
|||
});
|
||||
|
||||
if (!po) notFound();
|
||||
if (po.status !== "PAID_DELIVERED" && po.status !== "PARTIALLY_CLOSED") {
|
||||
if (
|
||||
po.status !== "PAID_DELIVERED" &&
|
||||
po.status !== "PARTIALLY_CLOSED" &&
|
||||
po.status !== "PARTIALLY_PAID"
|
||||
) {
|
||||
redirect(`/po/${id}`);
|
||||
}
|
||||
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
||||
|
|
@ -54,6 +60,7 @@ export default async function ReceiptPage({ params }: Props) {
|
|||
}));
|
||||
|
||||
const isPartiallyReceived = po.status === "PARTIALLY_CLOSED";
|
||||
const isPartiallyPaid = po.status === "PARTIALLY_PAID";
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
|
|
@ -70,7 +77,14 @@ export default async function ReceiptPage({ params }: Props) {
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ReceiptForm poId={po.id} lineItems={lineItems} isPartiallyReceived={isPartiallyReceived} />
|
||||
<ReceiptForm
|
||||
poId={po.id}
|
||||
lineItems={lineItems}
|
||||
isPartiallyReceived={isPartiallyReceived}
|
||||
isPartiallyPaid={isPartiallyPaid}
|
||||
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : undefined}
|
||||
totalAmount={Number(po.totalAmount)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,12 @@ interface Props {
|
|||
poId: string;
|
||||
lineItems: LineItem[];
|
||||
isPartiallyReceived: boolean;
|
||||
isPartiallyPaid?: boolean;
|
||||
paidAmount?: number;
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) {
|
||||
export function ReceiptForm({ poId, lineItems, isPartiallyReceived, isPartiallyPaid, paidAmount, totalAmount }: Props) {
|
||||
const router = useRouter();
|
||||
const [notes, setNotes] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
|
@ -81,6 +84,19 @@ export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) {
|
|||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Partial payment warning banner */}
|
||||
{isPartiallyPaid && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-amber-800 mb-0.5">Payment not yet complete</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
You can confirm received items now, but the PO will not be closed until full payment is made.
|
||||
{paidAmount != null && totalAmount != null && (
|
||||
<> Currently {paidAmount.toLocaleString("en-IN", { style: "currency", currency: "INR" })} of {totalAmount.toLocaleString("en-IN", { style: "currency", currency: "INR" })} has been paid.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line items delivery tracker */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type PoWithRelations = {
|
|||
dateRequired: Date | null;
|
||||
managerNote: string | null;
|
||||
paymentRef: string | null;
|
||||
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||||
piQuotationNo?: string | null;
|
||||
piQuotationDate?: Date | null;
|
||||
requisitionNo?: string | null;
|
||||
|
|
@ -82,6 +83,7 @@ const ACTION_LABELS: Record<string, string> = {
|
|||
VENDOR_ID_REQUESTED: "Vendor ID requested",
|
||||
VENDOR_ID_PROVIDED: "Vendor ID provided",
|
||||
PAYMENT_SENT: "Payment confirmed",
|
||||
PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed",
|
||||
RECEIPT_CONFIRMED: "Receipt confirmed",
|
||||
PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed",
|
||||
CLOSED: "Closed",
|
||||
|
|
@ -142,7 +144,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
);
|
||||
|
||||
const canConfirmReceipt =
|
||||
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED") &&
|
||||
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
|
||||
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
|
||||
!readOnly;
|
||||
|
||||
|
|
@ -184,7 +186,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
<DiscardDraftButton poId={po.id} />
|
||||
)}
|
||||
{/* Export buttons — only available once the PO has been approved by a manager */}
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
|
||||
<a
|
||||
href={`/api/po/${po.id}/export?format=pdf`}
|
||||
target="_blank"
|
||||
|
|
@ -398,25 +400,31 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
|
||||
{/* Confirm receipt CTA */}
|
||||
{canConfirmReceipt && (
|
||||
<div className={`rounded-lg border p-5 flex items-center justify-between ${
|
||||
po.status === "PARTIALLY_CLOSED"
|
||||
<div className={`rounded-lg border p-5 flex items-center justify-between flex-wrap gap-3 ${
|
||||
po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID"
|
||||
? "border-warning-100 bg-warning-50"
|
||||
: "border-success-100 bg-success-50"
|
||||
}`}>
|
||||
<div>
|
||||
<p className={`font-medium ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}>
|
||||
{po.status === "PARTIALLY_CLOSED" ? "Partially received" : "Payment confirmed"}
|
||||
<p className={`font-medium ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
|
||||
{po.status === "PARTIALLY_CLOSED"
|
||||
? "Partially received"
|
||||
: po.status === "PARTIALLY_PAID"
|
||||
? "Advance payment received"
|
||||
: "Payment confirmed"}
|
||||
</p>
|
||||
<p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" ? "text-warning-700" : "text-success-700"}`}>
|
||||
<p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
|
||||
{po.status === "PARTIALLY_CLOSED"
|
||||
? "Some items are still outstanding. Confirm remaining deliveries."
|
||||
: po.status === "PARTIALLY_PAID"
|
||||
? `Advance payment received (${formatCurrency(Number(po.paidAmount ?? 0), po.currency)} of ${formatCurrency(Number(po.totalAmount), po.currency)}). Items can be received now — PO closes when fully paid and delivered.`
|
||||
: "Please confirm that you have received all items."}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/po/${po.id}/receipt`}
|
||||
className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 ${
|
||||
po.status === "PARTIALLY_CLOSED" ? "bg-warning-600" : "bg-success"
|
||||
po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "bg-warning-600" : "bg-success"
|
||||
}`}
|
||||
>
|
||||
{po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type POAction =
|
|||
| "provide_vendor_id"
|
||||
| "process_payment"
|
||||
| "mark_paid"
|
||||
| "mark_partial_payment"
|
||||
| "confirm_receipt"
|
||||
| "confirm_partial_receipt";
|
||||
|
||||
|
|
@ -103,6 +104,38 @@ const TRANSITIONS: Partial<Record<POStatus, TransitionMap>> = {
|
|||
requiresNote: false,
|
||||
sideEffects: ["EMAIL_SUBMITTER", "EMAIL_MANAGER"],
|
||||
},
|
||||
mark_partial_payment: {
|
||||
to: "PARTIALLY_PAID",
|
||||
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: [],
|
||||
},
|
||||
},
|
||||
PARTIALLY_PAID: {
|
||||
mark_paid: {
|
||||
to: "PAID_DELIVERED",
|
||||
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: [],
|
||||
},
|
||||
mark_partial_payment: {
|
||||
to: "PARTIALLY_PAID",
|
||||
allowedRoles: ["ACCOUNTS", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: [],
|
||||
},
|
||||
confirm_receipt: {
|
||||
to: "CLOSED",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: [],
|
||||
},
|
||||
confirm_partial_receipt: {
|
||||
to: "PARTIALLY_PAID",
|
||||
allowedRoles: ["TECHNICAL", "MANNING", "SUPERUSER"],
|
||||
requiresNote: false,
|
||||
sideEffects: [],
|
||||
},
|
||||
},
|
||||
PAID_DELIVERED: {
|
||||
confirm_receipt: {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
|||
REJECTED: "Rejected",
|
||||
MGR_APPROVED: "Approved",
|
||||
SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PARTIALLY_PAID: "Partially Paid",
|
||||
PAID_DELIVERED: "Paid",
|
||||
PARTIALLY_CLOSED: "Partially Received",
|
||||
CLOSED: "Closed",
|
||||
|
|
@ -69,6 +70,7 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
|||
REJECTED: "danger",
|
||||
MGR_APPROVED: "success",
|
||||
SENT_FOR_PAYMENT: "default",
|
||||
PARTIALLY_PAID: "warning",
|
||||
PAID_DELIVERED: "success",
|
||||
PARTIALLY_CLOSED: "warning",
|
||||
CLOSED: "secondary",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const requestEditsSchema = z.object({
|
|||
|
||||
export const processPaymentSchema = z.object({
|
||||
paymentRef: z.string().min(1, "Payment reference is required"),
|
||||
paymentAmount: z.number().positive("Payment amount must be greater than 0").optional(),
|
||||
});
|
||||
|
||||
export const confirmReceiptSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TYPE "POStatus" ADD VALUE 'PARTIALLY_PAID';
|
||||
ALTER TYPE "ActionType" ADD VALUE 'PARTIAL_PAYMENT_CONFIRMED';
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "paidAmount" DECIMAL(12,2);
|
||||
|
|
@ -26,6 +26,7 @@ enum POStatus {
|
|||
REJECTED
|
||||
MGR_APPROVED
|
||||
SENT_FOR_PAYMENT
|
||||
PARTIALLY_PAID
|
||||
PAID_DELIVERED
|
||||
PARTIALLY_CLOSED
|
||||
CLOSED
|
||||
|
|
@ -41,6 +42,7 @@ enum ActionType {
|
|||
VENDOR_ID_REQUESTED
|
||||
VENDOR_ID_PROVIDED
|
||||
PAYMENT_SENT
|
||||
PARTIAL_PAYMENT_CONFIRMED
|
||||
RECEIPT_CONFIRMED
|
||||
PARTIAL_RECEIPT_CONFIRMED
|
||||
CLOSED
|
||||
|
|
@ -229,6 +231,7 @@ model PurchaseOrder {
|
|||
projectCode String?
|
||||
managerNote String?
|
||||
paymentRef String?
|
||||
paidAmount Decimal? @db.Decimal(12, 2)
|
||||
piQuotationNo String?
|
||||
piQuotationDate DateTime?
|
||||
requisitionNo String?
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue