Accounting Code search (new/edit/import/manager-edit PO forms):
- New SearchableSelect component (components/ui/searchable-select.tsx):
type-to-filter by code or name, results grouped by sub-category with
sticky headers, highlighted selected item, clear button, Escape/outside-click
to dismiss
- Replaces the plain <select> for the main Accounting Code field on all PO forms
- LineItemsEditor per-row account column also uses SearchableSelect (compact size)
when multi-account mode is active
Cost Centre dropdown reorganised by site:
- New type CostCentreGroup replaces flat CostCentreOption
- Each site becomes an <optgroup> label (unselectable); the site itself is the
first selectable option inside ("Haldia (Site)"), followed by its vessels
- Vessels with no site assigned appear under an "Unassigned Vessels" group
- Shared helpers buildCostCentreGroups() and buildAccountGroups() in
lib/cost-centre-groups.ts — used by all four PO form pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { managerEditPo } from "./manager-po-edit-actions";
|
||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||
import type { LineItemInput } from "@/lib/validations/po";
|
||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
|
||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||
|
||
type SerializedLineItem = {
|
||
id: string;
|
||
poId: string;
|
||
name: string;
|
||
description: string | null;
|
||
quantity: number;
|
||
unit: string;
|
||
size: string | null;
|
||
unitPrice: number;
|
||
totalPrice: number;
|
||
gstRate: number;
|
||
sortOrder: number;
|
||
productId: string | null;
|
||
};
|
||
|
||
type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
|
||
totalAmount: number;
|
||
lineItems: SerializedLineItem[];
|
||
vessel: { id: string; name: string } | null;
|
||
account: { id: string; name: string; code: string };
|
||
vendor: { id: string; name: string; vendorId: string | null } | null;
|
||
};
|
||
|
||
interface Props {
|
||
po: PoFull;
|
||
costCentres: CostCentreGroup[];
|
||
initialCostCentreRef: string;
|
||
accounts: AccountGroup[];
|
||
vendors: Vendor[];
|
||
}
|
||
|
||
const INPUT =
|
||
"w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30 placeholder:text-neutral-400";
|
||
const LABEL = "block text-xs font-semibold text-amber-800 mb-1";
|
||
|
||
/** Controlled account picker so SearchableSelect can be used inside the uncontrolled manager form */
|
||
function ManagerAccountSelect({ accountId, accounts }: { accountId: string; accounts: AccountGroup[] }) {
|
||
const [value, setValue] = useState(accountId);
|
||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||
}
|
||
|
||
export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors }: Props) {
|
||
const router = useRouter();
|
||
const [editing, setEditing] = useState(false);
|
||
const [pending, setPending] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [saved, setSaved] = useState(false);
|
||
|
||
const extPo = po as typeof po & {
|
||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||
requisitionNo?: string | null; requisitionDate?: Date | null;
|
||
placeOfDelivery?: string | null;
|
||
tcDelivery?: string | null; tcDispatch?: string | null;
|
||
tcInspection?: string | null; tcTransitInsurance?: string | null;
|
||
tcPaymentTerms?: string | null; tcOthers?: string | null;
|
||
};
|
||
|
||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||
po.lineItems.map((li) => ({
|
||
name: li.name,
|
||
description: li.description ?? undefined,
|
||
quantity: li.quantity,
|
||
unit: li.unit,
|
||
size: li.size ?? undefined,
|
||
unitPrice: li.unitPrice,
|
||
gstRate: li.gstRate,
|
||
}))
|
||
);
|
||
|
||
function isoDate(d: Date | null | undefined) {
|
||
if (!d) return "";
|
||
return new Date(d).toISOString().split("T")[0];
|
||
}
|
||
|
||
async function handleSave() {
|
||
setPending(true);
|
||
setError("");
|
||
const form = document.getElementById("mgr-edit-po-form") as HTMLFormElement;
|
||
const data = new FormData(form);
|
||
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));
|
||
});
|
||
|
||
const result = await managerEditPo(po.id, data);
|
||
if ("error" in result) {
|
||
setError(result.error);
|
||
setPending(false);
|
||
} else {
|
||
setSaved(true);
|
||
setEditing(false);
|
||
setPending(false);
|
||
router.refresh();
|
||
}
|
||
}
|
||
|
||
if (!editing) {
|
||
return (
|
||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-6 py-4">
|
||
{saved && (
|
||
<p className="mb-3 text-xs text-amber-700 bg-amber-100 border border-amber-200 rounded px-3 py-1.5">
|
||
PO updated. Original values are preserved in the activity trail.
|
||
</p>
|
||
)}
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="text-sm text-amber-700 hover:text-amber-900 font-semibold underline underline-offset-2"
|
||
>
|
||
✏️ Edit PO (Manager)
|
||
</button>
|
||
<p className="mt-1 text-xs text-amber-600">
|
||
Edit any field on this PO. Original values will be saved to the audit trail.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mt-2 rounded-lg border-2 border-amber-400 bg-amber-50">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-amber-200">
|
||
<div>
|
||
<p className="text-sm font-bold text-amber-800 uppercase tracking-wide">Manager — Editing PO</p>
|
||
<p className="text-xs text-amber-600 mt-0.5">All changes will be saved to the audit trail with the original values.</p>
|
||
</div>
|
||
<button
|
||
onClick={() => { setEditing(false); setError(""); }}
|
||
className="text-xs text-amber-700 hover:text-amber-900 underline"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
<form id="mgr-edit-po-form" className="p-6 space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||
|
||
{/* Order Information */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Order Information</h3>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div className="sm:col-span-2">
|
||
<label className={LABEL}>Title <span className="text-danger">*</span></label>
|
||
<input name="title" required defaultValue={po.title} className={INPUT} />
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
|
||
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
|
||
<option value="">Select cost centre…</option>
|
||
{costCentres.map((group) => (
|
||
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}>
|
||
{group.siteRef && (
|
||
<option value={group.siteRef}>{group.siteName} (Site)</option>
|
||
)}
|
||
{group.vessels.map((v) => (
|
||
<option key={v.ref} value={v.ref}>{v.label}</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
|
||
<ManagerAccountSelect
|
||
accountId={po.accountId}
|
||
accounts={accounts}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>Project Code</label>
|
||
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>Delivery Date Required</label>
|
||
<input name="dateRequired" type="date" defaultValue={isoDate(po.dateRequired)} className={INPUT} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Quotation Reference */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Quotation Reference</h3>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className={LABEL}>PI / Quotation No.</label>
|
||
<input name="piQuotationNo" defaultValue={extPo.piQuotationNo ?? ""} className={INPUT} placeholder="e.g. Verbal" />
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>PI / Quotation Date</label>
|
||
<input name="piQuotationDate" type="date" defaultValue={isoDate(extPo.piQuotationDate)} className={INPUT} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Requisition */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Requisition</h3>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className={LABEL}>Vessel / Office Requisition No.</label>
|
||
<input name="requisitionNo" defaultValue={extPo.requisitionNo ?? ""} className={INPUT} placeholder="Optional" />
|
||
</div>
|
||
<div>
|
||
<label className={LABEL}>Requisition Date</label>
|
||
<input name="requisitionDate" type="date" defaultValue={isoDate(extPo.requisitionDate)} className={INPUT} />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Delivery */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||
<label className={LABEL}>Place of Delivery</label>
|
||
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
||
</section>
|
||
|
||
{/* Vendor */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
||
<label className={LABEL}>Vendor</label>
|
||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
|
||
<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>
|
||
</section>
|
||
|
||
{/* Line Items */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Line Items</h3>
|
||
<div className="rounded-lg border border-amber-200 bg-white p-4">
|
||
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||
</div>
|
||
</section>
|
||
|
||
{/* Terms & Conditions */}
|
||
<section>
|
||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms & Conditions</h3>
|
||
<div className="space-y-2.5">
|
||
<div className="rounded-lg bg-amber-100 border border-amber-200 px-3 py-2 text-xs text-amber-700 select-none">
|
||
<span className="font-semibold">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-xs font-semibold text-amber-700 text-right">{n}.</span>
|
||
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800">{label}</label>
|
||
<input
|
||
name={name}
|
||
defaultValue={(extPo[key] ?? TC_DEFAULTS[key]) as string}
|
||
className={INPUT}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div className="flex items-start gap-3">
|
||
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right mt-2">7.</span>
|
||
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800 mt-2">Others</label>
|
||
<textarea
|
||
name="tcOthers"
|
||
rows={2}
|
||
defaultValue={(extPo.tcOthers ?? TC_DEFAULTS.tcOthers) as string}
|
||
className={INPUT}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</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 pt-2 border-t border-amber-200">
|
||
<button
|
||
type="button"
|
||
onClick={handleSave}
|
||
disabled={pending}
|
||
className="rounded-lg bg-amber-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-60 transition-colors"
|
||
>
|
||
{pending ? "Saving…" : "Save Changes"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setEditing(false); setError(""); }}
|
||
disabled={pending}
|
||
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"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|