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. -

- -
- -