diff --git a/App/pelagia-portal/app/(portal)/po/[id]/actions.ts b/App/pelagia-portal/app/(portal)/po/[id]/actions.ts new file mode 100644 index 0000000..0295202 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/actions.ts @@ -0,0 +1,49 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { canPerformAction } from "@/lib/po-state-machine"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +export async function provideVendorId({ + poId, + vendorId, +}: { + poId: string; + vendorId: string; +}): Promise<{ ok: true } | { error: string }> { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + if (!vendorId) return { error: "Please select a vendor with a verified ID." }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "provide_vendor_id", session.user.role)) { + return { error: "You cannot provide a vendor ID for this PO in its current state." }; + } + + const vendor = await db.vendor.findUnique({ where: { id: vendorId } }); + if (!vendor?.vendorId) return { error: "The selected vendor does not have a verified ID." }; + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + vendorId, + status: "MGR_REVIEW", + actions: { + create: { actionType: "VENDOR_ID_PROVIDED", actorId: session.user.id }, + }, + }, + }); + + const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } }); + await notify({ event: "VENDOR_ID_PROVIDED", po, recipients: managers }); + + revalidatePath(`/po/${poId}`); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts b/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts new file mode 100644 index 0000000..f8ef0a8 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/edit/actions.ts @@ -0,0 +1,134 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { createPoSchema } from "@/lib/validations/po"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +function parseLineItems(formData: FormData) { + const items = []; + let i = 0; + while (formData.has(`lineItems[${i}].description`)) { + items.push({ + description: formData.get(`lineItems[${i}].description`) as string, + quantity: Number(formData.get(`lineItems[${i}].quantity`)), + unit: formData.get(`lineItems[${i}].unit`) as string, + size: (formData.get(`lineItems[${i}].size`) as string) || undefined, + unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)), + gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18), + }); + i++; + } + return items; +} + +export async function updatePo( + poId: string, + formData: FormData +): Promise<{ id: string } | { error: string }> { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ where: { id: poId } }); + if (!po) return { error: "PO not found" }; + if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) { + return { error: "This PO cannot be edited in its current state." }; + } + if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") { + return { error: "You can only edit your own purchase orders." }; + } + + const intent = formData.get("intent") as "save" | "resubmit"; + + const parsed = createPoSchema.safeParse({ + title: formData.get("title"), + vesselId: formData.get("vesselId"), + accountId: formData.get("accountId"), + projectCode: formData.get("projectCode") || undefined, + dateRequired: formData.get("dateRequired") || undefined, + vendorId: formData.get("vendorId") || undefined, + piQuotationNo: formData.get("piQuotationNo") || undefined, + piQuotationDate: formData.get("piQuotationDate") || undefined, + requisitionNo: formData.get("requisitionNo") || undefined, + requisitionDate: formData.get("requisitionDate") || undefined, + placeOfDelivery: formData.get("placeOfDelivery") || undefined, + tcDelivery: formData.get("tcDelivery") || undefined, + tcDispatch: formData.get("tcDispatch") || undefined, + tcInspection: formData.get("tcInspection") || undefined, + tcTransitInsurance: formData.get("tcTransitInsurance") || undefined, + tcPaymentTerms: formData.get("tcPaymentTerms") || undefined, + tcOthers: formData.get("tcOthers") || undefined, + lineItems: parseLineItems(formData), + }); + + if (!parsed.success) { + return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + } + + const data = parsed.data; + const total = data.lineItems.reduce( + (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), + 0 + ); + + const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED"; + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + title: data.title, + vesselId: data.vesselId, + accountId: data.accountId, + vendorId: data.vendorId ?? null, + projectCode: data.projectCode ?? null, + dateRequired: data.dateRequired ? new Date(data.dateRequired) : null, + piQuotationNo: data.piQuotationNo ?? null, + piQuotationDate: data.piQuotationDate ? new Date(data.piQuotationDate) : null, + requisitionNo: data.requisitionNo ?? null, + requisitionDate: data.requisitionDate ? new Date(data.requisitionDate) : null, + placeOfDelivery: data.placeOfDelivery ?? null, + tcDelivery: data.tcDelivery ?? null, + tcDispatch: data.tcDispatch ?? null, + tcInspection: data.tcInspection ?? null, + tcTransitInsurance: data.tcTransitInsurance ?? null, + tcPaymentTerms: data.tcPaymentTerms ?? null, + tcOthers: data.tcOthers ?? null, + totalAmount: total, + status: isResubmit ? "MGR_REVIEW" : "DRAFT", + submittedAt: isResubmit ? new Date() : po.submittedAt, + lineItems: { + deleteMany: {}, + create: data.lineItems.map((item, idx) => ({ + description: item.description, + quantity: item.quantity, + unit: item.unit, + size: item.size ?? null, + unitPrice: item.unitPrice, + totalPrice: item.quantity * item.unitPrice, + gstRate: item.gstRate, + sortOrder: idx, + })), + }, + actions: { + create: isResubmit + ? { actionType: "SUBMITTED", actorId: session.user.id } + : undefined, + }, + }, + }); + + if (isResubmit) { + const [fullPo, managers] = await Promise.all([ + db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }), + db.user.findMany({ where: { role: "MANAGER", isActive: true } }), + ]); + if (fullPo) { + await notify({ event: "PO_SUBMITTED", po: fullPo, recipients: [fullPo.submitter, ...managers] }); + } + } + + revalidatePath(`/po/${poId}`); + revalidatePath("/dashboard"); + return { id: poId }; +} diff --git a/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx new file mode 100644 index 0000000..681c187 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { updatePo } from "./actions"; +import type { Vessel, Account, Vendor, PurchaseOrder, POLineItem } from "@prisma/client"; +import { LineItemsEditor } from "@/components/po/po-line-items-editor"; +import type { LineItemInput } from "@/lib/validations/po"; +import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po"; + +const INPUT_CLS = + "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"; + +type PoWithItems = PurchaseOrder & { lineItems: POLineItem[] }; + +interface Props { + po: PoWithItems; + vessels: Vessel[]; + accounts: Account[]; + vendors: Vendor[]; +} + +export function EditPoForm({ po, vessels, accounts, vendors }: Props) { + const router = useRouter(); + const [lineItems, setLineItems] = useState( + po.lineItems.map((li) => ({ + description: li.description, + quantity: Number(li.quantity), + unit: li.unit, + size: li.size ?? undefined, + unitPrice: Number(li.unitPrice), + gstRate: Number((li as POLineItem & { gstRate: unknown }).gstRate ?? 0.18), + })) + ); + const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null); + const [error, setError] = useState(""); + + const canResubmit = po.status === "EDITS_REQUESTED"; + + async function handleSubmit(intent: "save" | "resubmit") { + setSubmitting(intent); + setError(""); + const form = document.getElementById("edit-po-form") as HTMLFormElement; + const data = new FormData(form); + data.set("intent", intent); + lineItems.forEach((item, i) => { + data.set(`lineItems[${i}].description`, item.description); + data.set(`lineItems[${i}].quantity`, String(item.quantity)); + data.set(`lineItems[${i}].unit`, item.unit); + data.set(`lineItems[${i}].size`, item.size ?? ""); + data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice)); + data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18)); + }); + + const result = await updatePo(po.id, data); + if ("error" in result) { + setError(result.error); + setSubmitting(null); + } else { + router.push(`/po/${result.id}`); + } + } + + const dateValue = po.dateRequired + ? new Date(po.dateRequired).toISOString().split("T")[0] + : ""; + const piDateValue = (po as PurchaseOrder & { piQuotationDate: Date | null }).piQuotationDate + ? new Date((po as PurchaseOrder & { piQuotationDate: Date | null }).piQuotationDate!).toISOString().split("T")[0] + : ""; + const reqDateValue = (po as PurchaseOrder & { requisitionDate: Date | null }).requisitionDate + ? new Date((po as PurchaseOrder & { requisitionDate: Date | null }).requisitionDate!).toISOString().split("T")[0] + : ""; + + const extPo = po as PurchaseOrder & { + piQuotationNo: string | null; + requisitionNo: string | null; + placeOfDelivery: string | null; + tcDelivery: string | null; + tcDispatch: string | null; + tcInspection: string | null; + tcTransitInsurance: string | null; + tcPaymentTerms: string | null; + tcOthers: string | null; + }; + + return ( +
e.preventDefault()}> + {canResubmit && ( +
+

Edits requested

+ {po.managerNote && ( +

"{po.managerNote}"

+ )} +
+ )} + + {/* Order Information */} +
+

Order Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Quotation Reference */} +
+

Quotation Reference

+
+
+ + +
+
+ + +
+
+
+ + {/* Requisition */} +
+

Requisition

+
+
+ + +
+
+ + +
+
+
+ + {/* Delivery */} +
+

Delivery

+
+ +