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 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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
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