feat(po): new PO form with line items, GST rate, structured T&C and file uploads
Form sections: order info, quotation reference (PI No/Date), requisition (No/Date), place of delivery, line items (UoM dropdown, size, GST% per item), vendor, T&C, attachments. Line items editor: add/remove rows, GST dropdown (0/5/12/18/28%, default 18%), live taxable/GST/grand-total breakdown. T&C: fixed line 1, individual inputs for Delivery, Dispatch, Inspection, Transit Insurance, Payment Terms, Others. Save as draft or submit directly for approval (→ MGR_REVIEW).
This commit is contained in:
parent
94774ca96b
commit
5a1db32cee
7 changed files with 873 additions and 0 deletions
147
App/pelagia-portal/app/(portal)/po/new/actions.ts
Normal file
147
App/pelagia-portal/app/(portal)/po/new/actions.ts
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
239
App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx
Normal file
239
App/pelagia-portal/app/(portal)/po/new/new-po-form.tsx
Normal file
|
|
@ -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<LineItemInput[]>([
|
||||||
|
{ description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 },
|
||||||
|
]);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
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 (
|
||||||
|
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
{/* Order Information */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Title <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Vessel <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="vesselId" required className={INPUT_CLS}>
|
||||||
|
<option value="">Select vessel…</option>
|
||||||
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Account / Cost Centre <span className="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="accountId" required className={INPUT_CLS}>
|
||||||
|
<option value="">Select account…</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
|
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
<input name="dateRequired" type="date" className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quotation Reference */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Quotation Reference</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
|
||||||
|
<input name="piQuotationNo" className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
|
||||||
|
<input name="piQuotationDate" type="date" className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Requisition */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Requisition</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Vessel / Office Requisition No.</label>
|
||||||
|
<input name="requisitionNo" className={INPUT_CLS} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
|
||||||
|
<input name="requisitionDate" type="date" className={INPUT_CLS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
|
<textarea
|
||||||
|
name="placeOfDelivery"
|
||||||
|
rows={2}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
|
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Vendor */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
Vendor (optional — can be added later)
|
||||||
|
</label>
|
||||||
|
<select name="vendorId" className={INPUT_CLS}>
|
||||||
|
<option value="">No vendor selected</option>
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Terms & Conditions */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms & Conditions</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
|
||||||
|
<span className="font-medium text-neutral-600">1.</span> {TC_FIXED_LINE}
|
||||||
|
</div>
|
||||||
|
{([
|
||||||
|
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
|
||||||
|
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
|
||||||
|
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
|
||||||
|
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
|
||||||
|
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
|
||||||
|
] as const).map(({ n, label, name, key }) => (
|
||||||
|
<div key={name} className="flex items-center gap-3">
|
||||||
|
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right">{n}.</span>
|
||||||
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
defaultValue={TC_DEFAULTS[key]}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
|
||||||
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
|
||||||
|
<textarea
|
||||||
|
name="tcOthers"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={TC_DEFAULTS.tcOthers}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
|
||||||
|
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSubmit("draft")}
|
||||||
|
disabled={!!submitting}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{submitting === "draft" ? "Saving…" : "Save as Draft"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSubmit("submit")}
|
||||||
|
disabled={!!submitting}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
App/pelagia-portal/app/(portal)/po/new/page.tsx
Normal file
35
App/pelagia-portal/app/(portal)/po/new/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NewPoForm } from "./new-po-form";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "New Purchase Order" };
|
||||||
|
|
||||||
|
export default async function NewPoPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
if (!hasPermission(session.user.role, "create_po")) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [vessels, accounts, vendors] = await Promise.all([
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">New Purchase Order</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Fill in the details below. You can save as draft or submit directly for approval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
App/pelagia-portal/components/po/file-uploader.tsx
Normal file
78
App/pelagia-portal/components/po/file-uploader.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onChange: (files: File[]) => void;
|
||||||
|
files: File[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploader({ onChange, files, disabled }: Props) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
function addFiles(incoming: FileList | null) {
|
||||||
|
if (!incoming) return;
|
||||||
|
const next = [...files];
|
||||||
|
for (const file of Array.from(incoming)) {
|
||||||
|
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
|
||||||
|
next.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(files.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}
|
||||||
|
onClick={() => !disabled && inputRef.current?.click()}
|
||||||
|
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 text-sm transition-colors ${
|
||||||
|
dragOver ? "border-primary-400 bg-primary-50" : "border-neutral-300 hover:border-neutral-400"
|
||||||
|
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-neutral-700">Drop files here or click to browse</span>
|
||||||
|
<span className="mt-1 text-xs text-neutral-400">PDF, images up to 10 MB each</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => addFiles(e.target.files)}
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg,.gif,.webp"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm">
|
||||||
|
<span className="font-medium text-neutral-800 truncate max-w-xs">{file.name}</span>
|
||||||
|
<div className="flex items-center gap-3 ml-3 shrink-0">
|
||||||
|
<span className="text-xs text-neutral-400">{(file.size / 1024).toFixed(0)} KB</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); remove(i); }}
|
||||||
|
className="text-neutral-400 hover:text-danger-700 transition-colors"
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
App/pelagia-portal/components/po/po-line-items-editor.tsx
Normal file
296
App/pelagia-portal/components/po/po-line-items-editor.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
const UOM_OPTIONS = [
|
||||||
|
{ value: "pc", label: "pc — Piece" },
|
||||||
|
{ value: "set", label: "set — Set" },
|
||||||
|
{ value: "pk", label: "pk — Pack" },
|
||||||
|
{ value: "box", label: "box — Box" },
|
||||||
|
{ value: "pair", label: "pair — Pair" },
|
||||||
|
{ value: "roll", label: "roll — Roll" },
|
||||||
|
{ value: "kg", label: "kg — Kilogram" },
|
||||||
|
{ value: "g", label: "g — Gram" },
|
||||||
|
{ value: "L", label: "L — Litre" },
|
||||||
|
{ value: "mL", label: "mL — Millilitre" },
|
||||||
|
{ value: "m", label: "m — Metre" },
|
||||||
|
{ value: "m2", label: "m² — Sq. Metre" },
|
||||||
|
{ value: "hr", label: "hr — Hour" },
|
||||||
|
{ value: "day", label: "day — Day" },
|
||||||
|
{ value: "lump", label: "lump — Lump Sum" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: LineItemInput[];
|
||||||
|
onChange?: (items: LineItemInput[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
originalItems?: LineItemInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditRow = {
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
size: string;
|
||||||
|
unitPrice: string;
|
||||||
|
gstRate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toEditRow(item: LineItemInput): EditRow {
|
||||||
|
return {
|
||||||
|
description: item.description,
|
||||||
|
quantity: String(item.quantity),
|
||||||
|
unit: item.unit,
|
||||||
|
size: item.size ?? "",
|
||||||
|
unitPrice: item.unitPrice ? String(item.unitPrice) : "",
|
||||||
|
gstRate: String(item.gstRate ?? 0.18),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLineItem(row: EditRow): LineItemInput {
|
||||||
|
return {
|
||||||
|
description: row.description,
|
||||||
|
quantity: parseFloat(row.quantity) || 0,
|
||||||
|
unit: row.unit,
|
||||||
|
size: row.size || undefined,
|
||||||
|
unitPrice: parseFloat(row.unitPrice) || 0,
|
||||||
|
gstRate: parseFloat(row.gstRate) || 0.18,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTotals(items: LineItemInput[]) {
|
||||||
|
const taxable = items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
|
||||||
|
const gst = items.reduce((s, i) => s + i.quantity * i.unitPrice * (i.gstRate ?? 0.18), 0);
|
||||||
|
return { taxable, gst, grand: taxable + gst };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) {
|
||||||
|
const [rows, setRows] = useState<EditRow[]>(() => items.map(toEditRow));
|
||||||
|
|
||||||
|
function updateRows(next: EditRow[]) {
|
||||||
|
setRows(next);
|
||||||
|
onChange?.(next.map(toLineItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, field: keyof EditRow, value: string) {
|
||||||
|
updateRows(rows.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
updateRows([...rows, { description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
updateRows(rows.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
const hasSize = items.some((item) => item.size);
|
||||||
|
const hasDiff = originalItems && originalItems.length > 0;
|
||||||
|
const { taxable, gst, grand } = calcTotals(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{hasDiff && (
|
||||||
|
<p className="mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
|
||||||
|
Line items were amended by manager. Current values shown; original values shown with strikethrough.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Description</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
|
||||||
|
{hasSize && <th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>}
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Taxable</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const orig = originalItems?.[i];
|
||||||
|
const qtyChanged = orig && Number(orig.quantity) !== item.quantity;
|
||||||
|
const priceChanged = orig && Number(orig.unitPrice) !== item.unitPrice;
|
||||||
|
const descChanged = orig && orig.description !== item.description;
|
||||||
|
const taxableAmt = item.quantity * item.unitPrice;
|
||||||
|
const gstAmt = taxableAmt * (item.gstRate ?? 0.18);
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{descChanged && <span className="block text-neutral-400 line-through text-xs">{orig.description}</span>}
|
||||||
|
<span className={descChanged ? "text-amber-700 font-medium" : "text-neutral-900"}>{item.description}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4 text-right">
|
||||||
|
{qtyChanged && <span className="block text-neutral-400 line-through text-xs font-mono">{Number(orig.quantity)}</span>}
|
||||||
|
<span className={`font-mono ${qtyChanged ? "text-amber-700 font-medium" : ""}`}>{item.quantity}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3 text-neutral-500">{item.unit}</td>
|
||||||
|
{hasSize && <td className="py-2 pl-3 text-neutral-500">{item.size ?? "—"}</td>}
|
||||||
|
<td className="py-2 pl-4 text-right">
|
||||||
|
{priceChanged && <span className="block text-neutral-400 line-through text-xs font-mono">{formatCurrency(Number(orig.unitPrice))}</span>}
|
||||||
|
<span className={`font-mono ${priceChanged ? "text-amber-700 font-medium" : ""}`}>{formatCurrency(item.unitPrice)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4 text-right font-mono">{formatCurrency(taxableAmt)}</td>
|
||||||
|
<td className="py-2 pl-4 text-right text-neutral-500">{Math.round((item.gstRate ?? 0.18) * 100)}%</td>
|
||||||
|
<td className="py-2 pl-4 text-right font-mono">{formatCurrency(taxableAmt + gstAmt)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-neutral-100">
|
||||||
|
<td colSpan={hasSize ? 5 : 4} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||||
|
<td className="pt-3 pl-4 text-right font-mono text-neutral-700" colSpan={3}>{formatCurrency(taxable)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={hasSize ? 5 : 4} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||||
|
<td className="py-0.5 pl-4 text-right font-mono text-neutral-700" colSpan={3}>{formatCurrency(gst)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-t border-neutral-200">
|
||||||
|
<td colSpan={hasSize ? 5 : 4} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||||
|
<td className="pt-2 pl-4 text-right font-mono font-semibold text-neutral-900" colSpan={3}>{formatCurrency(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveItems = rows.map(toLineItem);
|
||||||
|
const { taxable, gst, grand } = calcTotals(liveItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Description</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
|
||||||
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
|
||||||
|
<th className="pb-2 pl-4 w-8" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const taxableAmt = (parseFloat(row.quantity) || 0) * (parseFloat(row.unitPrice) || 0);
|
||||||
|
const gstR = parseFloat(row.gstRate) || 0.18;
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<input
|
||||||
|
value={row.description}
|
||||||
|
onChange={(e) => update(i, "description", e.target.value)}
|
||||||
|
className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="Item description"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={row.quantity}
|
||||||
|
onChange={(e) => update(i, "quantity", e.target.value)}
|
||||||
|
className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3">
|
||||||
|
<select
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => update(i, "unit", e.target.value)}
|
||||||
|
className="w-32 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{UOM_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-3">
|
||||||
|
<input
|
||||||
|
value={row.size}
|
||||||
|
onChange={(e) => update(i, "size", e.target.value)}
|
||||||
|
className="w-24 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
placeholder="e.g. 10mm"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={row.unitPrice}
|
||||||
|
onChange={(e) => update(i, "unitPrice", e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-28 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<select
|
||||||
|
value={row.gstRate}
|
||||||
|
onChange={(e) => update(i, "gstRate", e.target.value)}
|
||||||
|
className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="0">0%</option>
|
||||||
|
<option value="0.05">5%</option>
|
||||||
|
<option value="0.12">12%</option>
|
||||||
|
<option value="0.18">18%</option>
|
||||||
|
<option value="0.28">28%</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4 text-right font-mono text-sm">
|
||||||
|
{formatCurrency(taxableAmt * (1 + gstR))}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pl-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove line item"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
disabled={rows.length === 1}
|
||||||
|
className="text-neutral-400 hover:text-danger disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-neutral-100">
|
||||||
|
<td colSpan={6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||||
|
<td className="pt-3 pl-4 text-right font-mono text-neutral-700" colSpan={2}>{formatCurrency(taxable)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||||
|
<td className="py-0.5 pl-4 text-right font-mono text-neutral-700" colSpan={2}>{formatCurrency(gst)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-t border-neutral-200">
|
||||||
|
<td colSpan={6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||||
|
<td className="pt-2 pl-4 text-right font-mono font-semibold text-neutral-900" colSpan={2}>{formatCurrency(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
className="mt-3 flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add line item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
App/pelagia-portal/components/po/po-status-badge.tsx
Normal file
11
App/pelagia-portal/components/po/po-status-badge.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { PO_STATUS_LABELS, PO_STATUS_VARIANTS } from "@/lib/utils";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export function PoStatusBadge({ status }: { status: POStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={PO_STATUS_VARIANTS[status]}>
|
||||||
|
{PO_STATUS_LABELS[status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
App/pelagia-portal/lib/validations/po.ts
Normal file
67
App/pelagia-portal/lib/validations/po.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const lineItemSchema = z.object({
|
||||||
|
description: z.string().min(1, "Description is required"),
|
||||||
|
quantity: z.coerce.number().positive("Quantity must be positive"),
|
||||||
|
unit: z.string().min(1, "Unit is required"),
|
||||||
|
size: z.string().optional(),
|
||||||
|
unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"),
|
||||||
|
gstRate: z.coerce.number().min(0).max(1).default(0.18),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TC_FIXED_LINE =
|
||||||
|
"Please quote this purchase order no. for further communications and invoices pertaining to this indent.";
|
||||||
|
|
||||||
|
export const TC_DEFAULTS = {
|
||||||
|
tcDelivery: "Within 4 to 5 days",
|
||||||
|
tcDispatch: "To be transported to site address as above. Freight Supplier's A/C",
|
||||||
|
tcInspection: "NA",
|
||||||
|
tcTransitInsurance: "NA",
|
||||||
|
tcPaymentTerms: "Within 30 days from delivery.",
|
||||||
|
tcOthers: "We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPoSchema = z.object({
|
||||||
|
title: z.string().min(1, "Title is required").max(200),
|
||||||
|
vesselId: z.string().min(1, "Vessel is required"),
|
||||||
|
accountId: z.string().min(1, "Account is required"),
|
||||||
|
projectCode: z.string().optional(),
|
||||||
|
dateRequired: z.string().optional(),
|
||||||
|
vendorId: z.string().optional(),
|
||||||
|
currency: z.string().default("INR"),
|
||||||
|
piQuotationNo: z.string().optional(),
|
||||||
|
piQuotationDate: z.string().optional(),
|
||||||
|
requisitionNo: z.string().optional(),
|
||||||
|
requisitionDate: z.string().optional(),
|
||||||
|
placeOfDelivery: z.string().optional(),
|
||||||
|
tcDelivery: z.string().optional(),
|
||||||
|
tcDispatch: z.string().optional(),
|
||||||
|
tcInspection: z.string().optional(),
|
||||||
|
tcTransitInsurance: z.string().optional(),
|
||||||
|
tcPaymentTerms: z.string().optional(),
|
||||||
|
tcOthers: z.string().optional(),
|
||||||
|
lineItems: z.array(lineItemSchema).min(1, "At least one line item is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approvePoSchema = z.object({
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rejectPoSchema = z.object({
|
||||||
|
note: z.string().min(1, "A rejection reason is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestEditsSchema = z.object({
|
||||||
|
note: z.string().min(1, "Please specify what edits are needed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const processPaymentSchema = z.object({
|
||||||
|
paymentRef: z.string().min(1, "Payment reference is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const confirmReceiptSchema = z.object({
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePoInput = z.infer<typeof createPoSchema>;
|
||||||
|
export type LineItemInput = z.infer<typeof lineItemSchema>;
|
||||||
Loading…
Add table
Reference in a new issue