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