diff --git a/App/pelagia-portal/app/(portal)/po/new/actions.ts b/App/pelagia-portal/app/(portal)/po/new/actions.ts new file mode 100644 index 0000000..15e4f1c --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/new/actions.ts @@ -0,0 +1,147 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { requirePermission } from "@/lib/permissions"; +import { createPoSchema } from "@/lib/validations/po"; +import { generatePoNumber } from "@/lib/utils"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +export async function createPo( + formData: FormData +): Promise<{ id: string } | { error: string }> { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + try { + requirePermission(session.user.role, "create_po"); + } catch { + return { error: "You do not have permission to create purchase orders." }; + } + + const intent = formData.get("intent") as "draft" | "submit"; + + const lineItems: Array<{ + description: string; + quantity: number; + unit: string; + size?: string; + unitPrice: number; + gstRate: number; + }> = []; + let i = 0; + while (formData.has(`lineItems[${i}].description`)) { + lineItems.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++; + } + + 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, + }); + + if (!parsed.success) { + return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + } + + const data = parsed.data; + // totalAmount = grand total including GST + const total = data.lineItems.reduce( + (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), + 0 + ); + + const po = await db.purchaseOrder.create({ + data: { + poNumber: generatePoNumber(), + title: data.title, + status: intent === "submit" ? "SUBMITTED" : "DRAFT", + totalAmount: total, + currency: data.currency, + 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, + submitterId: session.user.id, + submittedAt: intent === "submit" ? new Date() : null, + lineItems: { + 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: { + actionType: "CREATED", + actorId: session.user.id, + }, + }, + }, + }); + + if (intent === "submit") { + await db.purchaseOrder.update({ + where: { id: po.id }, + data: { + status: "MGR_REVIEW", + actions: { + create: { actionType: "SUBMITTED", actorId: session.user.id }, + }, + }, + }); + + const [fullPo, managers] = await Promise.all([ + db.purchaseOrder.findUnique({ where: { id: po.id }, 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("/dashboard"); + revalidatePath("/approvals"); + return { id: po.id }; +} diff --git a/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx new file mode 100644 index 0000000..b64f890 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { createPo } from "./actions"; +import type { Vessel, Account, Vendor } from "@prisma/client"; +import { LineItemsEditor } from "@/components/po/po-line-items-editor"; +import { FileUploader } from "@/components/po/file-uploader"; +import { uploadAndLinkFiles } from "@/lib/upload-files"; +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"; + +interface Props { + vessels: Vessel[]; + accounts: Account[]; + vendors: Vendor[]; +} + +export function NewPoForm({ vessels, accounts, vendors }: Props) { + const router = useRouter(); + const [lineItems, setLineItems] = useState([ + { description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 }, + ]); + const [files, setFiles] = useState([]); + const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); + const [error, setError] = useState(""); + + async function handleSubmit(intent: "draft" | "submit") { + setSubmitting(intent); + setError(""); + const form = document.getElementById("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 createPo(data); + if ("error" in result) { + setError(result.error); + setSubmitting(null); + return; + } + if (files.length > 0) { + const uploadErr = await uploadAndLinkFiles(result.id, files); + if (uploadErr) { + setError(uploadErr.error); + setSubmitting(null); + return; + } + } + router.push(`/po/${result.id}`); + } + + return ( +
e.preventDefault()}> + {/* Order Information */} +
+

Order Information

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

Quotation Reference

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

Requisition

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

Delivery

+
+ +