pelagia-portal/App/app/(portal)/po/new/new-po-form.tsx
2026-06-27 19:47:26 +00:00

304 lines
14 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 { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { ProjectCodeField } from "@/components/po/project-code-field";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import type { LineItemInput } 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; code: string | null };
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[];
deliveryOptions: string[];
projectCodeOptions: string[];
termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
// Duplicate-PO prefill (issue #142) — copy editable order fields onto a new draft.
initialTitle?: string;
initialAccountId?: string;
initialMultiAccount?: boolean;
initialProjectCode?: string | null;
initialPlaceOfDelivery?: string | null;
initialDateRequired?: string;
initialPiQuotationNo?: string;
initialPiQuotationDate?: string;
initialRequisitionNo?: string;
initialRequisitionDate?: string;
initialTerms?: PoTerm[];
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId, initialTitle, initialAccountId, initialMultiAccount, initialProjectCode, initialPlaceOfDelivery, initialDateRequired, initialPiQuotationNo, initialPiQuotationDate, initialRequisitionNo, initialRequisitionDate, initialTerms }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
);
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(
initialTerms && initialTerms.length > 0 ? initialTerms : defaultTerms
);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
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);
data.set("termsJson", JSON.stringify(terms));
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 uploadPoDocuments(result.id, files);
if (uploadErr) {
setError(uploadErr.error);
setSubmitting(null);
return;
}
}
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
return (
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
{/* 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 defaultValue={initialTitle ?? ""} 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={(v) => { setDefaultAccountId(v); markDirty(); }}
groups={accounts}
placeholder="Search accounting code…"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PO Date</label>
<input name="poDate" type="date" className={INPUT_CLS} />
<p className="mt-1 text-xs text-neutral-400">Optional can be back-dated or forward-dated. Defaults to the approved date if left blank.</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
<ProjectCodeField options={projectCodeOptions} current={initialProjectCode} className={INPUT_CLS} />
{projectCodeOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No project codes configured yet a Manager can add them under Administration Project Codes.
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
<input name="dateRequired" type="date" defaultValue={initialDateRequired ?? ""} 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" defaultValue={initialPiQuotationNo ?? ""} 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" defaultValue={initialPiQuotationDate ?? ""} 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" defaultValue={initialRequisitionNo ?? ""} 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" defaultValue={initialRequisitionDate ?? ""} 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>
<DeliveryLocationField options={deliveryOptions} current={initialPlaceOfDelivery} className={INPUT_CLS} />
{deliveryOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No delivery locations configured yet a Manager can add them under Administration Delivery Locations.
</p>
)}
</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={(v) => { setLineItems(v); markDirty(); }}
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>
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
</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-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration Terms &amp; Conditions.</p>
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
</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={(v) => { setFiles(v); markDirty(); }} 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>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("draft")}
saving={submitting === "draft"}
/>
</form>
);
}