From 565f9d58338493ec401e0de998db9a3cf9048fdd Mon Sep 17 00:00:00 2001 From: Hardik Date: Sat, 30 May 2026 17:54:43 +0530 Subject: [PATCH] feat: searchable accounting code picker + cost centres grouped by site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - - {costCentres.filter((c) => c.group === "Vessels").map((c) => ( - - ))} - - - {costCentres.filter((c) => c.group === "Sites").map((c) => ( - - ))} - + + {costCentres.map((group) => ( + + {group.siteRef && ( + + )} + {group.vessels.map((v) => ( + + ))} + + ))}
- +
diff --git a/App/app/(portal)/approvals/[id]/page.tsx b/App/app/(portal)/approvals/[id]/page.tsx index 189d028..62ef9c2 100644 --- a/App/app/(portal)/approvals/[id]/page.tsx +++ b/App/app/(portal)/approvals/[id]/page.tsx @@ -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(); - 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 = { diff --git a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx index 24f93b3..e4ea9ac 100644 --- a/App/app/(portal)/po/[id]/edit/edit-po-form.tsx +++ b/App/app/(portal)/po/[id]/edit/edit-po-form.tsx @@ -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 & { 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
@@ -159,20 +160,14 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve Multiple accounts
- + onChange={setDefaultAccountId} + groups={accounts} + placeholder="Search accounting code…" + required + />
@@ -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} /> diff --git a/App/app/(portal)/po/[id]/edit/page.tsx b/App/app/(portal)/po/[id]/edit/page.tsx index 89f0e77..19a83b4 100644 --- a/App/app/(portal)/po/[id]/edit/page.tsx +++ b/App/app/(portal)/po/[id]/edit/page.tsx @@ -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(); - 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) {

Edit Purchase Order

{po.poNumber}

- + ); } diff --git a/App/app/(portal)/po/import/import-form.tsx b/App/app/(portal)/po/import/import-form.tsx index 42e1f2d..64e151f 100644 --- a/App/app/(portal)/po/import/import-form.tsx +++ b/App/app/(portal)/po/import/import-form.tsx @@ -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} > - - {costCentres.filter((c) => c.group === "Vessels").map((c) => ( - - ))} - - - {costCentres.filter((c) => c.group === "Sites").map((c) => ( - - ))} - + {costCentres.map((group) => ( + + {group.siteRef && ( + + )} + {group.vessels.map((v) => ( + + ))} + + ))}
- + />
diff --git a/App/app/(portal)/po/import/page.tsx b/App/app/(portal)/po/import/page.tsx index 5f2af25..13b7b8c 100644 --- a/App/app/(portal)/po/import/page.tsx +++ b/App/app/(portal)/po/import/page.tsx @@ -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(); - 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 (
@@ -49,7 +37,7 @@ export default async function ImportPoPage() { You then select the cost centre, accounting code, and confirm before saving as a draft.

- +
); } diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index b28bdeb..dd6720b 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -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 + + {/* Cost Centre — grouped by site */}
+ + {/* Accounting Code — searchable */}
- + onChange={setDefaultAccountId} + groups={accounts} + placeholder="Search accounting code…" + required + />
+
@@ -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} /> @@ -241,22 +249,13 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
{n}. - +
))}
7. -