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:
Hardik 2026-05-05 23:25:06 +05:30
parent 94774ca96b
commit 5a1db32cee
7 changed files with 873 additions and 0 deletions

View 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 };
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;