pelagia-portal/App/app/(portal)/approvals/[id]/actions.ts
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

202 lines
5.8 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { approvePoSchema } from "@/lib/validations/po";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
export async function approvePo({
poId,
note,
withNote = false,
suggestedAdvancePayment,
}: {
poId: string;
note?: string;
withNote?: boolean;
// Absolute advance the Manager wants paid first (issue #92). Whole amount,
// resolved from the approval slider client-side. Omitted ⇒ full payment.
suggestedAdvancePayment?: number;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true, lineItems: true },
});
if (!po) return { error: "PO not found" };
const action = withNote ? "approve_with_note" : "approve";
if (!canPerformAction(po.status, action, session.user.role)) {
return { error: "You cannot approve this PO." };
}
if (!po.vendorId) {
return { error: "A vendor must be assigned before approving this PO." };
}
// Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance
// (full payment, current default behaviour). The slider always sends a value,
// but a malformed/over-total amount is clamped rather than rejected.
const total = Number(po.totalAmount);
const advance =
parsed.data.suggestedAdvancePayment === undefined
? null
: Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total);
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "MGR_APPROVED",
approvedAt: new Date(),
managerNote: note ?? null,
suggestedAdvancePayment: advance,
actions: {
create: {
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
note: note ?? null,
actorId: session.user.id,
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
},
},
},
});
// Add line items to site inventory immediately on approval (not on closure)
const siteId = po.siteId ?? null;
if (siteId) {
for (const li of po.lineItems) {
if (!li.productId) continue;
await db.itemInventory.upsert({
where: { productId_siteId: { productId: li.productId, siteId } },
update: { quantity: { increment: Number(li.quantity) } },
create: { productId: li.productId, siteId, quantity: Number(li.quantity) },
});
}
revalidatePath(`/admin/sites/${siteId}`);
}
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
po,
recipients: [po.submitter, ...accounts],
note,
});
revalidatePath("/approvals");
revalidatePath(`/po/${poId}`);
return { ok: true };
}
export async function rejectPo({
poId,
note,
}: {
poId: string;
note: string;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (!canPerformAction(po.status, "reject", session.user.role)) {
return { error: "You cannot reject this PO." };
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "REJECTED",
managerNote: note,
actions: {
create: { actionType: "REJECTED", note, actorId: session.user.id },
},
},
});
await notify({ event: "PO_REJECTED", po, recipients: [po.submitter], note });
revalidatePath("/approvals");
revalidatePath(`/po/${poId}`);
return { ok: true };
}
export async function requestEdits({
poId,
note,
}: {
poId: string;
note: string;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (!canPerformAction(po.status, "request_edits", session.user.role)) {
return { error: "You cannot request edits on this PO." };
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "EDITS_REQUESTED",
managerNote: note,
actions: {
create: { actionType: "EDITS_REQUESTED", note, actorId: session.user.id },
},
},
});
await notify({ event: "EDITS_REQUESTED", po, recipients: [po.submitter], note });
revalidatePath("/approvals");
revalidatePath(`/po/${poId}`);
return { ok: true };
}
export async function requestVendorId({ poId }: { poId: string }): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (!canPerformAction(po.status, "request_vendor_id", session.user.role)) {
return { error: "You cannot request a vendor ID for this PO." };
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "VENDOR_ID_PENDING",
actions: {
create: { actionType: "VENDOR_ID_REQUESTED", actorId: session.user.id },
},
},
});
await notify({ event: "VENDOR_ID_REQUESTED", po, recipients: [po.submitter] });
revalidatePath("/approvals");
revalidatePath(`/po/${poId}`);
return { ok: true };
}