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:
parent
0e3a79ecd4
commit
565f9d5833
11 changed files with 418 additions and 204 deletions
|
|
@ -7,7 +7,8 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
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 = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -34,7 +35,7 @@ type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
po: PoFull;
|
po: PoFull;
|
||||||
costCentres: CostCentreOption[];
|
costCentres: CostCentreGroup[];
|
||||||
initialCostCentreRef: string;
|
initialCostCentreRef: string;
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
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";
|
"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";
|
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) {
|
export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
@ -154,27 +161,25 @@ export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accou
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
|
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
|
||||||
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
|
||||||
<optgroup label="Vessels">
|
<option value="">Select cost centre…</option>
|
||||||
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
{costCentres.map((group) => (
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}>
|
||||||
))}
|
{group.siteRef && (
|
||||||
</optgroup>
|
<option value={group.siteRef}>{group.siteName} (Site)</option>
|
||||||
<optgroup label="Sites">
|
)}
|
||||||
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
{group.vessels.map((v) => (
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
<option key={v.ref} value={v.ref}>{v.label}</option>
|
||||||
))}
|
))}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
|
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
|
||||||
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
|
<ManagerAccountSelect
|
||||||
{accounts.map(({ group, items }) => (
|
accountId={po.accountId}
|
||||||
<optgroup key={group} label={group}>
|
accounts={accounts}
|
||||||
{items.map((a) => <option key={a.id} value={a.id}>{a.code} — {a.name}</option>)}
|
/>
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Project Code</label>
|
<label className={LABEL}>Project Code</label>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { notFound, redirect } from "next/navigation";
|
||||||
import { ApprovalActions } from "./approval-actions";
|
import { ApprovalActions } from "./approval-actions";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
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";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -44,8 +44,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
receipt: true,
|
receipt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.vessel.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, code: true } }),
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
orderBy: { code: "asc" },
|
orderBy: { code: "asc" },
|
||||||
|
|
@ -57,20 +57,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
const accountGroupMap = new Map<string, typeof accounts>();
|
const costCentres = buildCostCentreGroups(vessels, sites);
|
||||||
for (const a of accounts) {
|
const accountGroups = buildAccountGroups(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 initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
|
const initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { updatePo } from "./actions";
|
import { updatePo } from "./actions";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
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 { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } 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 {
|
interface Props {
|
||||||
po: PoWithItems;
|
po: PoWithItems;
|
||||||
costCentres: CostCentreOption[];
|
costCentres: CostCentreGroup[];
|
||||||
initialCostCentreRef: string;
|
initialCostCentreRef: string;
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
|
|
@ -132,16 +133,16 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
|
||||||
</label>
|
</label>
|
||||||
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT_CLS}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT_CLS}>
|
||||||
<option value="">Select cost centre…</option>
|
<option value="">Select cost centre…</option>
|
||||||
<optgroup label="Vessels">
|
{costCentres.map((group) => (
|
||||||
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
{group.siteRef && (
|
||||||
))}
|
<option value={group.siteRef}>{group.siteName} (Site)</option>
|
||||||
</optgroup>
|
)}
|
||||||
<optgroup label="Sites">
|
{group.vessels.map((v) => (
|
||||||
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
<option key={v.ref} value={v.ref}>{v.label}</option>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
))}
|
||||||
))}
|
</optgroup>
|
||||||
</optgroup>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -159,20 +160,14 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
|
||||||
Multiple accounts
|
Multiple accounts
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
required
|
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={(e) => setDefaultAccountId(e.target.value)}
|
onChange={setDefaultAccountId}
|
||||||
className={INPUT_CLS}
|
groups={accounts}
|
||||||
>
|
placeholder="Search accounting code…"
|
||||||
<option value="">Select accounting code…</option>
|
required
|
||||||
{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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<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}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={setLineItems}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { EditPoForm } from "./edit-po-form";
|
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";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -26,13 +26,12 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
|
|
||||||
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`);
|
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const canEdit =
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const [vessels, sites, accounts, vendors, noteAction] = await Promise.all([
|
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 } }),
|
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, code: true } }),
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
orderBy: { code: "asc" },
|
orderBy: { code: "asc" },
|
||||||
|
|
@ -48,20 +47,8 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accountGroupMap = new Map<string, typeof accounts>();
|
const costCentres = buildCostCentreGroups(vessels, sites);
|
||||||
for (const a of accounts) {
|
const accountGroups = buildAccountGroups(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 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 initialCostCentreRef = po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "";
|
const initialCostCentreRef = po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "";
|
||||||
|
|
||||||
const serializedPo = {
|
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>
|
<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>
|
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Vendor } from "@prisma/client";
|
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 { importPo } from "./actions";
|
||||||
import type { ParsedImport } from "@/app/api/po/import/route";
|
import type { ParsedImport } from "@/app/api/po/import/route";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
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";
|
"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 {
|
interface Props {
|
||||||
costCentres: CostCentreOption[];
|
costCentres: CostCentreGroup[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +66,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
|
||||||
title: parsed.vendorName
|
title: parsed.vendorName
|
||||||
? `${parsed.vendorName} — Import`
|
? `${parsed.vendorName} — Import`
|
||||||
: "Imported Purchase Order",
|
: "Imported Purchase Order",
|
||||||
costCentreRef: costCentres[0]?.ref ?? "",
|
costCentreRef: costCentres[0]?.siteRef ?? costCentres[0]?.vessels[0]?.ref ?? "",
|
||||||
accountId: accounts[0]?.items[0]?.id ?? "",
|
accountId: accounts[0]?.items[0]?.id ?? "",
|
||||||
vendorId: matchedVendor?.id ?? "",
|
vendorId: matchedVendor?.id ?? "",
|
||||||
});
|
});
|
||||||
|
|
@ -189,35 +190,30 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
>
|
>
|
||||||
<option value="">Select cost centre…</option>
|
<option value="">Select cost centre…</option>
|
||||||
<optgroup label="Vessels">
|
{costCentres.map((group) => (
|
||||||
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
{group.siteRef && (
|
||||||
))}
|
<option value={group.siteRef}>{group.siteName} (Site)</option>
|
||||||
</optgroup>
|
)}
|
||||||
<optgroup label="Sites">
|
{group.vessels.map((v) => (
|
||||||
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
<option key={v.ref} value={v.ref}>{v.label}</option>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
))}
|
||||||
))}
|
</optgroup>
|
||||||
</optgroup>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Accounting Code <span className="text-danger">*</span>
|
Accounting Code <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<SearchableSelect
|
||||||
|
name="__import_account"
|
||||||
value={preview.accountId}
|
value={preview.accountId}
|
||||||
onChange={(e) => setPreview({ ...preview, accountId: e.target.value })}
|
onChange={(v) => setPreview({ ...preview, accountId: v })}
|
||||||
|
groups={accounts}
|
||||||
|
placeholder="Search accounting code…"
|
||||||
required
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ImportForm } from "./import-form";
|
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";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Import Purchase Order" };
|
export const metadata: Metadata = { title: "Import Purchase Order" };
|
||||||
|
|
@ -14,9 +14,9 @@ export default async function ImportPoPage() {
|
||||||
const { role } = session.user;
|
const { role } = session.user;
|
||||||
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
|
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
|
||||||
|
|
||||||
const [vessels, sites, accounts, vendors] = await Promise.all([
|
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.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, code: true } }),
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
orderBy: { code: "asc" },
|
orderBy: { code: "asc" },
|
||||||
|
|
@ -25,20 +25,8 @@ export default async function ImportPoPage() {
|
||||||
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accountGroupMap = new Map<string, typeof accounts>();
|
const costCentres = buildCostCentreGroups(vessels, sites);
|
||||||
for (const a of accounts) {
|
const accounts = buildAccountGroups(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 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 })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<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.
|
You then select the cost centre, accounting code, and confirm before saving as a draft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ImportForm costCentres={costCentres} accounts={accountGroups} vendors={vendors} />
|
<ImportForm costCentres={costCentres} accounts={accounts} vendors={vendors} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,20 @@ import { createPo } from "./actions";
|
||||||
import type { Vendor } from "@prisma/client";
|
import type { Vendor } from "@prisma/client";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } 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 }[] };
|
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
||||||
|
|
||||||
const INPUT_CLS =
|
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 };
|
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
costCentres: CostCentreOption[];
|
costCentres: CostCentreGroup[];
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
|
|
@ -86,24 +95,28 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
|
||||||
</label>
|
</label>
|
||||||
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
|
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Centre — grouped by site */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Cost Centre <span className="text-danger">*</span>
|
Cost Centre <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="costCentreRef" required defaultValue={initialCostCentreRef ?? ""} className={INPUT_CLS}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef ?? ""} className={INPUT_CLS}>
|
||||||
<option value="">Select cost centre…</option>
|
<option value="">Select cost centre…</option>
|
||||||
<optgroup label="Vessels">
|
{costCentres.map((group) => (
|
||||||
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
{group.siteRef && (
|
||||||
))}
|
<option value={group.siteRef}>{group.siteName} (Site)</option>
|
||||||
</optgroup>
|
)}
|
||||||
<optgroup label="Sites">
|
{group.vessels.map((v) => (
|
||||||
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
<option key={v.ref} value={v.ref}>{v.label}</option>
|
||||||
<option key={c.ref} value={c.ref}>{c.label}</option>
|
))}
|
||||||
))}
|
</optgroup>
|
||||||
</optgroup>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Accounting Code — searchable */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-sm font-medium text-neutral-700">
|
<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)}
|
onChange={(e) => setMultiAccount(e.target.checked)}
|
||||||
className="rounded border-neutral-300"
|
className="rounded border-neutral-300"
|
||||||
/>
|
/>
|
||||||
Multiple accounts
|
Per-item codes
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
required
|
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={(e) => setDefaultAccountId(e.target.value)}
|
onChange={setDefaultAccountId}
|
||||||
className={INPUT_CLS}
|
groups={accounts}
|
||||||
>
|
placeholder="Search accounting code…"
|
||||||
<option value="">Select accounting code…</option>
|
required
|
||||||
{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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
||||||
|
|
@ -196,7 +204,7 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={setLineItems}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts.flatMap((g) => g.items.map((a) => ({ id: a.id, name: a.name, code: a.code })))}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -241,22 +249,13 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
|
||||||
<div key={name} className="flex items-center gap-3">
|
<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>
|
<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>
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
|
||||||
<input
|
<input name={name} defaultValue={TC_DEFAULTS[key]} className={INPUT_CLS} />
|
||||||
name={name}
|
|
||||||
defaultValue={TC_DEFAULTS[key]}
|
|
||||||
className={INPUT_CLS}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<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>
|
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
|
||||||
<textarea
|
<textarea name="tcOthers" rows={2} defaultValue="" className={INPUT_CLS} />
|
||||||
name="tcOthers"
|
|
||||||
rows={2}
|
|
||||||
defaultValue=""
|
|
||||||
className={INPUT_CLS}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
|
<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}
|
<span className="font-medium text-neutral-600">8.</span> {TC_FIXED_LINE_2}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { NewPoForm } from "./new-po-form";
|
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 { Metadata } from "next";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { CartItem } from "@/lib/cart";
|
import type { CartItem } from "@/lib/cart";
|
||||||
|
|
@ -40,7 +40,6 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
gstRate: 0.18,
|
gstRate: 0.18,
|
||||||
productId: item.productId,
|
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))];
|
const vendorIds = [...new Set(cartItems.map((i) => i.vendorId).filter(Boolean))];
|
||||||
if (vendorIds.length === 1) initialVendorId = vendorIds[0];
|
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([
|
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.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, code: true } }),
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
orderBy: { code: "asc" },
|
orderBy: { code: "asc" },
|
||||||
select: {
|
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
||||||
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" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build grouped account list for optgroups: "TOP CAT > Sub Cat" → items
|
const costCentres = buildCostCentreGroups(vessels, sites);
|
||||||
const accountGroupMap = new Map<string, typeof leafAccounts>();
|
const accounts = buildAccountGroups(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 })),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<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.
|
Fill in the details below. You can save as draft or submit directly for approval.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
import { formatCurrency } from "@/lib/utils";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
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 = [
|
const UOM_OPTIONS = [
|
||||||
{ value: "pc", label: "pc — Piece" },
|
{ value: "pc", label: "pc — Piece" },
|
||||||
|
|
@ -44,8 +46,9 @@ interface Props {
|
||||||
originalItemsLabel?: string;
|
originalItemsLabel?: string;
|
||||||
/** When true, show per-row account selector */
|
/** When true, show per-row account selector */
|
||||||
multiAccount?: boolean;
|
multiAccount?: boolean;
|
||||||
accounts?: AccountOption[];
|
/** Grouped accounts for the searchable per-row selector */
|
||||||
/** The PO-level default account id — pre-selected in each row dropdown */
|
accounts?: AccountGroup[];
|
||||||
|
/** The PO-level default account id — pre-selected in each row */
|
||||||
defaultAccountId?: string;
|
defaultAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +249,9 @@ export function LineItemsEditor({
|
||||||
updateRows(rows.filter((_, i) => i !== index));
|
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 ───────────────────────────────────────────────────────────
|
// ── Read-only view ───────────────────────────────────────────────────────────
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
|
|
@ -363,7 +368,7 @@ export function LineItemsEditor({
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const taxableAmt = (parseFloat(row.quantity) || 0) * (parseFloat(row.unitPrice) || 0);
|
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 (
|
return (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="py-2 pr-4">
|
<td className="py-2 pr-4">
|
||||||
|
|
@ -424,16 +429,15 @@ export function LineItemsEditor({
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
{multiAccount && (
|
{multiAccount && (
|
||||||
<td className="py-2 pl-4">
|
<td className="py-2 pl-4 min-w-[200px]">
|
||||||
<select
|
<SearchableSelect
|
||||||
|
name={`__account_row_${i}`}
|
||||||
value={row.accountId ?? defaultAccountId ?? ""}
|
value={row.accountId ?? defaultAccountId ?? ""}
|
||||||
onChange={(e) => update(i, "accountId", e.target.value)}
|
onChange={(v) => update(i, "accountId", v)}
|
||||||
className="w-36 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
groups={accounts ?? []}
|
||||||
>
|
placeholder="Select code…"
|
||||||
{accounts.map((a) => (
|
size="compact"
|
||||||
<option key={a.id} value={a.id}>{a.name} ({a.code})</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="py-2 pl-4 text-right text-sm">
|
<td className="py-2 pl-4 text-right text-sm">
|
||||||
|
|
|
||||||
201
App/components/ui/searchable-select.tsx
Normal file
201
App/components/ui/searchable-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
App/lib/cost-centre-groups.ts
Normal file
59
App/lib/cost-centre-groups.ts
Normal 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 }));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue