feat: searchable accounting code picker + cost centres grouped by site

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>
This commit is contained in:
Hardik 2026-05-30 17:54:43 +05:30
parent 0e3a79ecd4
commit 565f9d5833
11 changed files with 418 additions and 204 deletions

View file

@ -7,7 +7,8 @@ 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 { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
type SerializedLineItem = {
id: string;
@ -34,7 +35,7 @@ type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
interface Props {
po: PoFull;
costCentres: CostCentreOption[];
costCentres: CostCentreGroup[];
initialCostCentreRef: string;
accounts: AccountGroup[];
vendors: Vendor[];
@ -44,6 +45,12 @@ 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);
@ -154,27 +161,25 @@ export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accou
<div>
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
<optgroup label="Vessels">
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<optgroup label="Sites">
{costCentres.filter((c) => c.group === "Sites").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<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>
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
{accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select>
<ManagerAccountSelect
accountId={po.accountId}
accounts={accounts}
/>
</div>
<div>
<label className={LABEL}>Project Code</label>

View file

@ -5,7 +5,7 @@ import { notFound, redirect } from "next/navigation";
import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next";
interface Props {
@ -44,8 +44,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
receipt: true,
},
}),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
@ -57,20 +57,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
if (!po) notFound();
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
];
const costCentres = buildCostCentreGroups(vessels, sites);
const accountGroups = buildAccountGroups(accounts);
const initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
const serializedPo = {

View file

@ -4,8 +4,9 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePo } from "./actions";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
@ -35,7 +36,7 @@ type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
interface Props {
po: PoWithItems;
costCentres: CostCentreOption[];
costCentres: CostCentreGroup[];
initialCostCentreRef: string;
accounts: AccountGroup[];
vendors: Vendor[];
@ -132,16 +133,16 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
</label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT_CLS}>
<option value="">Select cost centre</option>
<optgroup label="Vessels">
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<optgroup label="Sites">
{costCentres.filter((c) => c.group === "Sites").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
{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>
@ -159,20 +160,14 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
Multiple accounts
</label>
</div>
<select
<SearchableSelect
name="accountId"
required
value={defaultAccountId}
onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS}
>
<option value="">Select accounting code</option>
{accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select>
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>
@ -231,7 +226,7 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
items={lineItems}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
/>
</section>

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next";
interface Props {
@ -26,13 +26,12 @@ export default async function EditPoPage({ params }: Props) {
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`);
const canEdit =
po.submitterId === session.user.id || session.user.role === "SUPERUSER";
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`);
const [vessels, sites, accounts, vendors, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
const [vessels, sites, leafAccounts, vendors, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
@ -48,20 +47,8 @@ export default async function EditPoPage({ params }: Props) {
: Promise.resolve(null),
]);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
];
const costCentres = buildCostCentreGroups(vessels, sites);
const accountGroups = buildAccountGroups(leafAccounts);
const initialCostCentreRef = po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "";
const serializedPo = {
@ -82,7 +69,14 @@ export default async function EditPoPage({ params }: Props) {
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
</div>
<EditPoForm po={serializedPo} costCentres={costCentres} initialCostCentreRef={initialCostCentreRef} accounts={accountGroups} vendors={vendors} managerNoteAuthor={noteAction?.actor.name ?? null} />
<EditPoForm
po={serializedPo}
costCentres={costCentres}
initialCostCentreRef={initialCostCentreRef}
accounts={accountGroups}
vendors={vendors}
managerNoteAuthor={noteAction?.actor.name ?? null}
/>
</div>
);
}

View file

@ -3,7 +3,8 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import type { Vendor } from "@prisma/client";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { importPo } from "./actions";
import type { ParsedImport } from "@/app/api/po/import/route";
import { formatCurrency } from "@/lib/utils";
@ -12,7 +13,7 @@ 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 {
costCentres: CostCentreOption[];
costCentres: CostCentreGroup[];
accounts: AccountGroup[];
vendors: Vendor[];
}
@ -65,7 +66,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
title: parsed.vendorName
? `${parsed.vendorName} — Import`
: "Imported Purchase Order",
costCentreRef: costCentres[0]?.ref ?? "",
costCentreRef: costCentres[0]?.siteRef ?? costCentres[0]?.vessels[0]?.ref ?? "",
accountId: accounts[0]?.items[0]?.id ?? "",
vendorId: matchedVendor?.id ?? "",
});
@ -189,35 +190,30 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
className={INPUT_CLS}
>
<option value="">Select cost centre</option>
<optgroup label="Vessels">
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<optgroup label="Sites">
{costCentres.filter((c) => c.group === "Sites").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
{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="block text-sm font-medium text-neutral-700 mb-1.5">
Accounting Code <span className="text-danger">*</span>
</label>
<select
<SearchableSelect
name="__import_account"
value={preview.accountId}
onChange={(e) => setPreview({ ...preview, accountId: e.target.value })}
onChange={(v) => setPreview({ ...preview, accountId: v })}
groups={accounts}
placeholder="Search accounting code…"
required
className={INPUT_CLS}
>
<option value="">Select accounting code</option>
{accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select>
/>
</div>
</div>
<div>

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { ImportForm } from "./import-form";
import type { CostCentreOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Import Purchase Order" };
@ -14,9 +14,9 @@ export default async function ImportPoPage() {
const { role } = session.user;
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
const [vessels, sites, accounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
const [vessels, sites, leafAccounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
@ -25,20 +25,8 @@ export default async function ImportPoPage() {
db.vendor.findMany({ orderBy: { name: "asc" } }),
]);
const accountGroupMap = new Map<string, typeof accounts>();
for (const a of accounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accountGroups: AccountGroup[] = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code}${s.name}`, group: "Sites" as const })),
];
const costCentres = buildCostCentreGroups(vessels, sites);
const accounts = buildAccountGroups(leafAccounts);
return (
<div className="max-w-6xl">
@ -49,7 +37,7 @@ export default async function ImportPoPage() {
You then select the cost centre, accounting code, and confirm before saving as a draft.
</p>
</div>
<ImportForm costCentres={costCentres} accounts={accountGroups} vendors={vendors} />
<ImportForm costCentres={costCentres} accounts={accounts} vendors={vendors} />
</div>
);
}

View file

@ -6,11 +6,20 @@ 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 CostCentreOption = { ref: string; label: string; group: "Vessels" | "Sites" };
// Cost centres grouped by site: the site itself is selectable, vessels are listed under it
export type CostCentreGroup = {
siteId: string | null; // null = "Unassigned Vessels" fallback group
siteName: string;
siteRef: string | null; // "s:siteId" — selectable; null = no site option
vessels: { ref: string; label: string }[];
};
// Accounting codes grouped by sub-category
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
const INPUT_CLS =
@ -19,7 +28,7 @@ const INPUT_CLS =
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
interface Props {
costCentres: CostCentreOption[];
costCentres: CostCentreGroup[];
accounts: AccountGroup[];
vendors: Vendor[];
initialLineItems?: LineItemInput[];
@ -86,24 +95,28 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
</label>
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
</div>
{/* Cost Centre — grouped by site */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Cost Centre <span className="text-danger">*</span>
</label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef ?? ""} className={INPUT_CLS}>
<option value="">Select cost centre</option>
<optgroup label="Vessels">
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
<optgroup label="Sites">
{costCentres.filter((c) => c.group === "Sites").map((c) => (
<option key={c.ref} value={c.ref}>{c.label}</option>
))}
</optgroup>
{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>
{/* Accounting Code — searchable */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium text-neutral-700">
@ -116,24 +129,19 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
onChange={(e) => setMultiAccount(e.target.checked)}
className="rounded border-neutral-300"
/>
Multiple accounts
Per-item codes
</label>
</div>
<select
<SearchableSelect
name="accountId"
required
value={defaultAccountId}
onChange={(e) => setDefaultAccountId(e.target.value)}
className={INPUT_CLS}
>
<option value="">Select accounting code</option>
{accounts.map(({ group, items }) => (
<optgroup key={group} label={group}>
{items.map((a) => <option key={a.id} value={a.id}>{a.code} {a.name}</option>)}
</optgroup>
))}
</select>
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" />
@ -196,7 +204,7 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
items={lineItems}
onChange={setLineItems}
multiAccount={multiAccount}
accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
/>
</section>
@ -241,22 +249,13 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
<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}
/>
<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}
/>
<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}

View file

@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form";
import type { CostCentreOption } from "./new-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -40,7 +40,6 @@ export default async function NewPoPage({ searchParams }: Props) {
gstRate: 0.18,
productId: item.productId,
}));
// Pre-fill vendor only when all items share the same vendor
const vendorIds = [...new Set(cartItems.map((i) => i.vendorId).filter(Boolean))];
if (vendorIds.length === 1) initialVendorId = vendorIds[0];
}
@ -50,39 +49,18 @@ export default async function NewPoPage({ searchParams }: Props) {
}
const [vessels, sites, leafAccounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" },
select: {
id: true, code: true, name: true,
parent: {
select: {
name: true, code: true,
parent: { select: { name: true, code: true } },
},
},
},
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]);
// Build grouped account list for optgroups: "TOP CAT > Sub Cat" → items
const accountGroupMap = new Map<string, typeof leafAccounts>();
for (const a of leafAccounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const groupKey = `${topLabel}${subLabel}`;
if (!accountGroupMap.has(groupKey)) accountGroupMap.set(groupKey, []);
accountGroupMap.get(groupKey)!.push(a);
}
const accounts = Array.from(accountGroupMap.entries()).map(([group, items]) => ({ group, items }));
const costCentres: CostCentreOption[] = [
...vessels.map((v) => ({ ref: `v:${v.id}` as const, label: `${v.code}${v.name}`, group: "Vessels" as const })),
...sites.map((s) => ({ ref: `s:${s.id}` as const, label: `${s.code}${s.name}`, group: "Sites" as const })),
];
const costCentres = buildCostCentreGroups(vessels, sites);
const accounts = buildAccountGroups(leafAccounts);
return (
<div className="max-w-6xl">
@ -92,7 +70,14 @@ export default async function NewPoPage({ searchParams }: Props) {
Fill in the details below. You can save as draft or submit directly for approval.
</p>
</div>
<NewPoForm costCentres={costCentres} accounts={accounts} vendors={vendors} initialLineItems={initialLineItems} initialVendorId={initialVendorId} initialCostCentreRef={initialCostCentreRef} />
<NewPoForm
costCentres={costCentres}
accounts={accounts}
vendors={vendors}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialCostCentreRef={initialCostCentreRef}
/>
</div>
);
}

View file

@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from "react";
import { Plus, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/utils";
import type { LineItemInput } from "@/lib/validations/po";
import { SearchableSelect } from "@/components/ui/searchable-select";
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
const UOM_OPTIONS = [
{ value: "pc", label: "pc — Piece" },
@ -44,8 +46,9 @@ interface Props {
originalItemsLabel?: string;
/** When true, show per-row account selector */
multiAccount?: boolean;
accounts?: AccountOption[];
/** The PO-level default account id — pre-selected in each row dropdown */
/** Grouped accounts for the searchable per-row selector */
accounts?: AccountGroup[];
/** The PO-level default account id — pre-selected in each row */
defaultAccountId?: string;
}
@ -246,7 +249,9 @@ export function LineItemsEditor({
updateRows(rows.filter((_, i) => i !== index));
}
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
const accountMap = Object.fromEntries(
(accounts ?? []).flatMap((g) => g.items).map((a) => [a.id, a])
);
// ── Read-only view ───────────────────────────────────────────────────────────
if (readOnly) {
@ -363,7 +368,7 @@ export function LineItemsEditor({
<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;
const gstR = row.gstRate !== "" && row.gstRate != null ? parseFloat(row.gstRate) : 0.18;
return (
<tr key={i}>
<td className="py-2 pr-4">
@ -424,16 +429,15 @@ export function LineItemsEditor({
</select>
</td>
{multiAccount && (
<td className="py-2 pl-4">
<select
<td className="py-2 pl-4 min-w-[200px]">
<SearchableSelect
name={`__account_row_${i}`}
value={row.accountId ?? defaultAccountId ?? ""}
onChange={(e) => update(i, "accountId", e.target.value)}
className="w-36 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name} ({a.code})</option>
))}
</select>
onChange={(v) => update(i, "accountId", v)}
groups={accounts ?? []}
placeholder="Select code…"
size="compact"
/>
</td>
)}
<td className="py-2 pl-4 text-right text-sm">

View file

@ -0,0 +1,201 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronDown, Search, X } from "lucide-react";
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
interface Props {
name: string;
value: string;
onChange: (value: string) => void;
groups: AccountGroup[];
placeholder?: string;
required?: boolean;
/** "default" for the main PO field, "compact" for the per-line-item table cell */
size?: "default" | "compact";
}
export function SearchableSelect({
name,
value,
onChange,
groups,
placeholder = "Select accounting code…",
required,
size = "default",
}: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("keydown", handleKey);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("keydown", handleKey);
document.removeEventListener("mousedown", handleClick);
};
}, [open]);
// Auto-focus search input when opened
useEffect(() => {
if (open) searchRef.current?.focus();
}, [open]);
// Find the display label for the current value
const selectedItem = groups.flatMap((g) => g.items).find((i) => i.id === value);
const selectedLabel = selectedItem ? `${selectedItem.code}${selectedItem.name}` : "";
// Filter by query (code or name, case-insensitive)
const q = query.trim().toLowerCase();
const filtered = q
? groups
.map((g) => ({
...g,
items: g.items.filter(
(i) =>
i.code.toLowerCase().includes(q) ||
i.name.toLowerCase().includes(q)
),
}))
.filter((g) => g.items.length > 0)
: groups;
const handleSelect = useCallback(
(id: string) => {
onChange(id);
setOpen(false);
setQuery("");
},
[onChange]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange("");
},
[onChange]
);
const isCompact = size === "compact";
return (
<div ref={containerRef} className="relative w-full">
{/* Hidden input for form submission */}
<input type="hidden" name={name} value={value} />
{required && !value && (
/* Invisible trick to trigger native "required" validation on submit */
<input
tabIndex={-1}
required
value={value}
onChange={() => {}}
className="absolute opacity-0 w-0 h-0 pointer-events-none"
aria-hidden
/>
)}
{/* Trigger */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
bg-white text-left transition-colors
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20
${isCompact ? "px-2 py-1.5 text-xs" : "px-3 py-2.5 text-sm"}`}
>
<span
className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}
>
{selectedLabel || placeholder}
</span>
<span className="flex items-center gap-1 shrink-0">
{value && (
<span
role="button"
tabIndex={0}
onClick={handleClear}
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
className="text-neutral-300 hover:text-neutral-500 transition-colors"
>
<X className={isCompact ? "h-3 w-3" : "h-4 w-4"} />
</span>
)}
<ChevronDown
className={`text-neutral-400 transition-transform ${open ? "rotate-180" : ""} ${isCompact ? "h-3 w-3" : "h-4 w-4"}`}
/>
</span>
</button>
{/* Dropdown panel */}
{open && (
<div
className={`absolute z-50 left-0 right-0 mt-1 rounded-lg border border-neutral-200 bg-white shadow-xl
${isCompact ? "min-w-[280px]" : ""}`}
style={{ maxWidth: isCompact ? "360px" : undefined }}
>
{/* Search input */}
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type code or name…"
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
/>
{query && (
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Options */}
<div className="max-h-64 overflow-y-auto overscroll-contain">
{filtered.length === 0 ? (
<p className="px-3 py-5 text-sm text-center text-neutral-400">No codes match "{query}"</p>
) : (
filtered.map((group) => (
<div key={group.group}>
{/* Group header */}
<div className="sticky top-0 px-3 py-1 text-xs font-semibold text-neutral-500 bg-neutral-50 border-b border-neutral-100">
{group.group}
</div>
{/* Items */}
{group.items.map((item) => (
<button
key={item.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); handleSelect(item.id); }}
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === item.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
>
<span className="font-mono text-xs text-neutral-400 shrink-0 w-14">{item.code}</span>
<span className="flex-1 leading-snug">{item.name}</span>
</button>
))}
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,59 @@
import type { CostCentreGroup } from "@/app/(portal)/po/new/new-po-form";
type VesselLike = { id: string; name: string; code: string; siteId: string | null };
type SiteLike = { id: string; name: string };
/**
* Builds the grouped cost-centre list used by PO form dropdowns.
* Each group = one site (as an optgroup), with the site itself as a selectable
* option followed by its vessels.
* Vessels with no site appear under an "Unassigned Vessels" group at the end.
*/
export function buildCostCentreGroups(
vessels: VesselLike[],
sites: SiteLike[]
): CostCentreGroup[] {
const groups: CostCentreGroup[] = sites.map((s) => ({
siteId: s.id,
siteName: s.name,
siteRef: `s:${s.id}`,
vessels: vessels
.filter((v) => v.siteId === s.id)
.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}` })),
}));
const unassigned = vessels.filter((v) => !v.siteId);
if (unassigned.length > 0) {
groups.push({
siteId: null,
siteName: "Unassigned Vessels",
siteRef: null,
vessels: unassigned.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}` })),
});
}
return groups;
}
/**
* Builds grouped accounting codes for the SearchableSelect component.
* Only returns leaf items (no children), grouped by sub-category.
*/
export function buildAccountGroups(
leafAccounts: {
id: string;
code: string;
name: string;
parent: { name: string; code: string; parent: { name: string; code: string } | null } | null;
}[]
) {
const map = new Map<string, { id: string; code: string; name: string }[]>();
for (const a of leafAccounts) {
const subLabel = a.parent ? `${a.parent.code}${a.parent.name}` : "Uncategorised";
const topLabel = a.parent?.parent ? `${a.parent.parent.name} ` : "";
const key = `${topLabel}${subLabel}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push({ id: a.id, code: a.code, name: a.name });
}
return Array.from(map.entries()).map(([group, items]) => ({ group, items }));
}