From 3b3a26eafec0ed265ffdeaf89d6c854f4b24c582 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 16 May 2026 16:02:44 +0530 Subject: [PATCH] feat(receipt): allow partial receipt confirmation with per-item delivery tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submitters can now mark individual item quantities as received when confirming delivery, rather than treating a PO as all-or-nothing. Schema (migration: 20260516103013_partial_receipt): - POStatus: new PARTIALLY_CLOSED value between PAID_DELIVERED and CLOSED - ActionType: new PARTIAL_RECEIPT_CONFIRMED value - POLineItem: new deliveredQuantity Decimal? field — accumulates delivered qty across multiple receipt events State machine: - PAID_DELIVERED → confirm_partial_receipt → PARTIALLY_CLOSED (new) - PARTIALLY_CLOSED → confirm_receipt → CLOSED (all delivered) - PARTIALLY_CLOSED → confirm_partial_receipt → PARTIALLY_CLOSED (more partial) Receipt page / form: - Loads line items with ordered qty, previously delivered qty, and remaining qty - Per-row numeric input for "receiving now" defaulting to all remaining - "Mark all remaining" shortcut - Dynamic button: "Confirm Partial Receipt" vs "Confirm Receipt & Close PO" - Info banner telling user if the PO will stay open or close Receipt action: - Accumulates deliveredQuantity per line item - If all lines fully delivered → CLOSED + fires notifications + updates inventory - If any line still outstanding → PARTIALLY_CLOSED (no notifications yet) - Inventory auto-update runs per-event for the delivered quantities only Dashboard & PO detail: - Open Orders count now includes PARTIALLY_CLOSED - "Confirm Receipt" CTA in po-detail handles PARTIALLY_CLOSED with distinct amber styling and "Confirm Remaining" label - Activity log shows PARTIAL_RECEIPT_CONFIRMED with appropriate label - PARTIALLY_CLOSED gets warning (amber) badge variant Co-Authored-By: Claude Sonnet 4.6 --- .../app/(portal)/dashboard/page.tsx | 2 +- .../app/(portal)/po/[id]/receipt/actions.ts | 115 +++++++-- .../app/(portal)/po/[id]/receipt/page.tsx | 45 +++- .../(portal)/po/[id]/receipt/receipt-form.tsx | 220 ++++++++++++++---- .../components/po/po-detail.tsx | 25 +- App/pelagia-portal/lib/po-state-machine.ts | 23 +- App/pelagia-portal/lib/utils.ts | 2 + .../migration.sql | 8 + App/pelagia-portal/prisma/schema.prisma | 31 +-- 9 files changed, 378 insertions(+), 93 deletions(-) create mode 100644 App/pelagia-portal/prisma/migrations/20260516103013_partial_receipt/migration.sql diff --git a/App/pelagia-portal/app/(portal)/dashboard/page.tsx b/App/pelagia-portal/app/(portal)/dashboard/page.tsx index dc5ebc3..2c4f4ac 100644 --- a/App/pelagia-portal/app/(portal)/dashboard/page.tsx +++ b/App/pelagia-portal/app/(portal)/dashboard/page.tsx @@ -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"] } }, + where: { submitterId: userId, status: { in: ["DRAFT", "SUBMITTED", "MGR_REVIEW", "VENDOR_ID_PENDING", "EDITS_REQUESTED", "PARTIALLY_CLOSED"] } }, }), db.purchaseOrder.count({ where: { submitterId: userId, status: "MGR_REVIEW" }, diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts b/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts index b27f80e..a1c506e 100644 --- a/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/actions.ts @@ -9,10 +9,17 @@ import { revalidatePath } from "next/cache"; export async function confirmReceipt({ poId, notes, + deliveries, }: { poId: string; notes?: string; -}): Promise<{ ok: true } | { error: string }> { + /** + * Per-line delivery quantities for this receipt event. + * Key = line item id, value = quantity delivered now (not cumulative). + * If omitted or empty, all items are treated as fully delivered. + */ + deliveries?: Record; +}): Promise<{ ok: true; partial: boolean } | { error: string }> { const session = await auth(); if (!session?.user) return { error: "Unauthorized" }; @@ -25,44 +32,110 @@ export async function confirmReceipt({ }, }); if (!po) return { error: "PO not found" }; - if (!canPerformAction(po.status, "confirm_receipt", session.user.role)) { - return { error: "You cannot confirm receipt on this PO." }; + + const isAllowedStatus = + po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED"; + if (!isAllowedStatus) { + return { error: "You cannot confirm receipt on this PO in its current state." }; + } + if ( + !canPerformAction(po.status, "confirm_receipt", session.user.role) && + !canPerformAction(po.status, "confirm_partial_receipt", session.user.role) + ) { + return { error: "You do not have permission to confirm receipt on this PO." }; + } + if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") { + return { error: "You can only confirm receipt on your own purchase orders." }; } + // Compute the updated deliveredQuantity for each line item + const lineUpdates = po.lineItems.map((li) => { + const prevDelivered = Number(li.deliveredQuantity ?? 0); + const nowDelivered = deliveries ? (deliveries[li.id] ?? 0) : Number(li.quantity); + const totalDelivered = prevDelivered + nowDelivered; + const ordered = Number(li.quantity); + return { + id: li.id, + productId: li.productId, + quantity: ordered, + deliveredQuantity: Math.min(totalDelivered, ordered), + nowDelivered: Math.min(nowDelivered, ordered - prevDelivered), + }; + }); + + // 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; + + // Persist delivery quantities + await Promise.all( + lineUpdates.map((u) => + db.pOLineItem.update({ + where: { id: u.id }, + data: { deliveredQuantity: u.deliveredQuantity }, + }) + ) + ); + + // Update PO status and log action await db.purchaseOrder.update({ where: { id: poId }, data: { - status: "CLOSED", - closedAt: new Date(), - receipt: notes ? { create: { storageKey: "", fileName: "no-file", notes } } : undefined, + status: newStatus, + closedAt: allDelivered ? new Date() : undefined, + receipt: notes + ? { create: { storageKey: "", fileName: "no-file", notes } } + : undefined, actions: { - create: { actionType: "RECEIPT_CONFIRMED", actorId: session.user.id, note: notes ?? null }, + create: { + actionType: isPartial ? "PARTIAL_RECEIPT_CONFIRMED" : "RECEIPT_CONFIRMED", + actorId: session.user.id, + note: notes ?? null, + metadata: isPartial + ? { + deliveries: lineUpdates.map((u) => ({ + lineItemId: u.id, + deliveredNow: u.nowDelivered, + totalDelivered: u.deliveredQuantity, + ordered: u.quantity, + })), + } + : undefined, + }, }, }, }); - // Auto-update inventory: use PO siteId, fall back to vessel's home site - const siteId = (po as typeof po & { siteId?: string | null }).siteId ?? po.vessel?.site?.id ?? null; + // Auto-update inventory for delivered quantities + const siteId = + (po as typeof po & { siteId?: string | null }).siteId ?? + po.vessel?.site?.id ?? + null; + if (siteId) { - for (const li of po.lineItems) { - if (!li.productId) continue; - const qty = Number(li.quantity); + for (const u of lineUpdates) { + if (!u.productId || u.nowDelivered <= 0) continue; await db.itemInventory.upsert({ - where: { productId_siteId: { productId: li.productId, siteId } }, - update: { quantity: { increment: qty } }, - create: { productId: li.productId, siteId, quantity: qty }, + where: { productId_siteId: { productId: u.productId, siteId } }, + update: { quantity: { increment: u.nowDelivered } }, + create: { productId: u.productId, siteId, quantity: u.nowDelivered }, }); } revalidatePath(`/admin/sites/${siteId}`); } - const [managers, accounts] = await Promise.all([ - db.user.findMany({ where: { role: "MANAGER", isActive: true } }), - db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }), - ]); - await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] }); + // Notify on full close only + if (allDelivered) { + const [managers, accounts] = await Promise.all([ + db.user.findMany({ where: { role: "MANAGER", isActive: true } }), + db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }), + ]); + await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] }); + } revalidatePath(`/po/${poId}`); revalidatePath("/dashboard"); - return { ok: true }; + revalidatePath("/my-orders"); + return { ok: true, partial: isPartial }; } diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx b/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx index 2d0abd1..77247d4 100644 --- a/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/page.tsx @@ -18,24 +18,59 @@ export default async function ReceiptPage({ params }: Props) { const po = await db.purchaseOrder.findUnique({ where: { id }, - select: { id: true, poNumber: true, title: true, status: true, submitterId: true }, + select: { + id: true, + poNumber: true, + title: true, + status: true, + submitterId: true, + lineItems: { + orderBy: { sortOrder: "asc" }, + select: { + id: true, + name: true, + quantity: true, + unit: true, + deliveredQuantity: true, + }, + }, + }, }); if (!po) notFound(); - if (po.status !== "PAID_DELIVERED") redirect(`/po/${id}`); + if (po.status !== "PAID_DELIVERED" && po.status !== "PARTIALLY_CLOSED") { + redirect(`/po/${id}`); + } if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") { redirect(`/po/${id}`); } + const lineItems = po.lineItems.map((li) => ({ + id: li.id, + name: li.name, + quantity: Number(li.quantity), + unit: li.unit, + deliveredQuantity: li.deliveredQuantity !== null ? Number(li.deliveredQuantity) : null, + })); + + const isPartiallyReceived = po.status === "PARTIALLY_CLOSED"; + return ( -
+
-

Confirm Receipt

+

+ {isPartiallyReceived ? "Confirm Remaining Receipt" : "Confirm Receipt"} +

{po.poNumber} — {po.title}

+ {isPartiallyReceived && ( +

+ This PO has been partially received. Mark the quantities delivered in this batch. +

+ )}
- +
); } diff --git a/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx b/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx index 923ca92..f6f879d 100644 --- a/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx +++ b/App/pelagia-portal/app/(portal)/po/[id]/receipt/receipt-form.tsx @@ -6,18 +6,57 @@ import { confirmReceipt } from "./actions"; import { FileUploader } from "@/components/po/file-uploader"; import { uploadAndLinkFiles } from "@/lib/upload-files"; -export function ReceiptForm({ poId }: { poId: string }) { +interface LineItem { + id: string; + name: string; + quantity: number; + unit: string; + deliveredQuantity: number | null; +} + +interface Props { + poId: string; + lineItems: LineItem[]; + isPartiallyReceived: boolean; +} + +export function ReceiptForm({ poId, lineItems, isPartiallyReceived }: Props) { const router = useRouter(); const [notes, setNotes] = useState(""); const [files, setFiles] = useState([]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + // Per-item: how many are being delivered in this batch + const [deliveries, setDeliveries] = useState>(() => { + const init: Record = {}; + for (const li of lineItems) { + const remaining = li.quantity - (li.deliveredQuantity ?? 0); + init[li.id] = remaining; // default: all remaining + } + return init; + }); + + function setDelivery(id: string, value: number) { + const li = lineItems.find((l) => l.id === id)!; + const remaining = li.quantity - (li.deliveredQuantity ?? 0); + setDeliveries((prev) => ({ ...prev, [id]: Math.max(0, Math.min(value, remaining)) })); + } + + function markAllRemaining() { + const all: Record = {}; + for (const li of lineItems) { + all[li.id] = li.quantity - (li.deliveredQuantity ?? 0); + } + setDeliveries(all); + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSubmitting(true); setError(""); - const result = await confirmReceipt({ poId, notes }); + + const result = await confirmReceipt({ poId, notes, deliveries }); if ("error" in result) { setError(result.error); setSubmitting(false); @@ -35,55 +74,146 @@ export function ReceiptForm({ poId }: { poId: string }) { router.refresh(); } + const allFullyDelivered = lineItems.every( + (li) => (li.deliveredQuantity ?? 0) + (deliveries[li.id] ?? 0) >= li.quantity + ); + const nothingDelivered = Object.values(deliveries).every((v) => v === 0); + return ( -
-
-

- Confirming receipt will close this purchase order. Please verify that all - items have been received before proceeding. -

- -
- -