pelagia-portal/App/components/ui/searchable-select.tsx
Hardik 565f9d5833 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>
2026-05-30 17:54:43 +05:30

201 lines
6.8 KiB
TypeScript

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