Schema: - New Company model (name, gstNumber, address, telephone, mobile, email, invoiceAddress, isActive) - PurchaseOrder.companyId FK (optional, SET NULL on company delete) - Migration: 20260530000003_add_company Admin: - /admin/companies page with full CRUD (create, edit, deactivate, delete) - Companies table shows name, GST, contact details, status - Companies link added to Admin section of sidebar (Briefcase icon) PO forms (new / edit / import / manager-edit): - Company dropdown appears at the top of Order Information when companies exist - Pre-populated with first active company; selection persisted to DB via companyId Import form: - parseSheet() now extracts companyName from Excel row 1 (col A) - Import preview auto-matches detected company name against known companies - Shows detected name as a hint; user can override before saving Export (PDF + XLSX): - Company constants (CO_NAME, CO_ADDR, CO_TEL, INV_ADDR, INV_GST) are now derived from the linked Company record when present, falling back to the original Pelagia Marine hardcoded defaults when no company is set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { createPo } from "./actions";
|
|
import type { Vendor } from "@prisma/client";
|
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
|
import { FileUploader } from "@/components/po/file-uploader";
|
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
|
import type { LineItemInput } from "@/lib/validations/po";
|
|
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
|
|
|
export type VesselOption = { id: string; code: string; name: string };
|
|
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
|
export type CompanyOption = { id: string; name: string };
|
|
|
|
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";
|
|
|
|
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
|
|
|
|
interface Props {
|
|
vessels: VesselOption[];
|
|
accounts: AccountGroup[];
|
|
vendors: Vendor[];
|
|
companies: CompanyOption[];
|
|
initialLineItems?: LineItemInput[];
|
|
initialVendorId?: string;
|
|
initialVesselId?: string;
|
|
initialCompanyId?: string;
|
|
}
|
|
|
|
export function NewPoForm({ vessels, accounts, vendors, companies, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
|
const router = useRouter();
|
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
|
);
|
|
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [multiAccount, setMultiAccount] = useState(false);
|
|
const [defaultAccountId, setDefaultAccountId] = 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}].name`, item.name);
|
|
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));
|
|
if (item.productId) data.set(`lineItems[${i}].productId`, item.productId);
|
|
if (multiAccount && item.accountId) data.set(`lineItems[${i}].accountId`, item.accountId);
|
|
});
|
|
|
|
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">
|
|
{companies.length > 0 && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
|
Company <span className="text-danger">*</span>
|
|
</label>
|
|
<select name="companyId" required defaultValue={initialCompanyId ?? ""} className={INPUT_CLS}>
|
|
<option value="">Select company…</option>
|
|
{companies.map((c) => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className={companies.length > 0 ? "" : "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>
|
|
|
|
{/* Cost Centre — vessels only */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
|
Cost Centre <span className="text-danger">*</span>
|
|
</label>
|
|
<select name="vesselId" required defaultValue={initialVesselId ?? ""} className={INPUT_CLS}>
|
|
<option value="">Select cost centre…</option>
|
|
{vessels.map((v) => (
|
|
<option key={v.id} value={v.id}>{v.code} — {v.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Accounting Code — searchable */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className="text-sm font-medium text-neutral-700">
|
|
{multiAccount ? "Default Accounting Code" : "Accounting Code"} <span className="text-danger">*</span>
|
|
</label>
|
|
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={multiAccount}
|
|
onChange={(e) => setMultiAccount(e.target.checked)}
|
|
className="rounded border-neutral-300"
|
|
/>
|
|
Per-item codes
|
|
</label>
|
|
</div>
|
|
<SearchableSelect
|
|
name="accountId"
|
|
value={defaultAccountId}
|
|
onChange={setDefaultAccountId}
|
|
groups={accounts}
|
|
placeholder="Search accounting code…"
|
|
required
|
|
/>
|
|
</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">Cost Centre / 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}
|
|
multiAccount={multiAccount}
|
|
accounts={accounts}
|
|
defaultAccountId={defaultAccountId || undefined}
|
|
/>
|
|
</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"
|
|
value={vendorId}
|
|
onChange={(e) => setVendorId(e.target.value)}
|
|
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="" className={INPUT_CLS} />
|
|
</div>
|
|
<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">8.</span> {TC_FIXED_LINE_2}
|
|
</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>
|
|
);
|
|
}
|