126 lines
5.1 KiB
TypeScript
126 lines
5.1 KiB
TypeScript
import { Input } from '@/Components/ui/input';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Search, Loader2 } from 'lucide-react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
interface MaterialItem {
|
|
id: number; ulid: string; name: string; unit: string; unit_cost: string;
|
|
}
|
|
|
|
interface PurchaseOrderResult {
|
|
id: number; ulid: string; document_number: string; supplier?: string;
|
|
items: { id: number; material: MaterialItem; quantity: string; unit_cost: string }[];
|
|
}
|
|
|
|
interface Props {
|
|
projectUlid: string;
|
|
onSelect: (po: PurchaseOrderResult) => void;
|
|
selectedPo?: PurchaseOrderResult | null;
|
|
className?: string;
|
|
}
|
|
|
|
export default function PurchaseOrderLookup({ projectUlid, onSelect, selectedPo, className }: Props) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<PurchaseOrderResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
const fetchResults = useCallback(async (q: string) => {
|
|
if (!projectUlid) return;
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ project_ulid: projectUlid });
|
|
if (q) params.set('q', q);
|
|
const response = await fetch(`${route('purchase-orders.search')}?${params}`, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-TOKEN': document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || '',
|
|
},
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setResults(data);
|
|
}
|
|
} catch { /* silently fail */ } finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectUlid]);
|
|
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (!query && !open) return;
|
|
debounceRef.current = setTimeout(() => fetchResults(query), 300);
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
}, [query, fetchResults]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, []);
|
|
|
|
const handleSelect = (po: PurchaseOrderResult) => {
|
|
setQuery(po.document_number);
|
|
setOpen(false);
|
|
onSelect(po);
|
|
};
|
|
|
|
const handleFocus = () => {
|
|
setOpen(true);
|
|
if (results.length === 0) fetchResults(query);
|
|
};
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={`relative ${className || ''}`}>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<Input
|
|
value={selectedPo ? selectedPo.document_number : query}
|
|
onChange={e => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
if (selectedPo) onSelect(null as unknown as PurchaseOrderResult);
|
|
}}
|
|
onFocus={handleFocus}
|
|
placeholder="Search by PO number (e.g. PO-2026)..."
|
|
className="pl-10 max-w-lg"
|
|
/>
|
|
{loading && <Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-gray-400" />}
|
|
</div>
|
|
|
|
{open && results.length > 0 && !selectedPo && (
|
|
<div className="absolute z-50 mt-1 w-full max-w-lg rounded-md border bg-white shadow-lg max-h-48 overflow-y-auto">
|
|
{results.map(po => (
|
|
<button
|
|
key={po.ulid}
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left hover:bg-gray-50 flex items-center justify-between text-sm border-b last:border-b-0"
|
|
onClick={() => handleSelect(po)}
|
|
>
|
|
<div>
|
|
<span className="font-mono font-medium">{po.document_number}</span>
|
|
{po.supplier && <span className="text-gray-500 ml-2">— {po.supplier}</span>}
|
|
</div>
|
|
<Badge variant="outline">{po.items.length} items</Badge>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{open && results.length === 0 && !loading && query && (
|
|
<div className="absolute z-50 mt-1 w-full max-w-lg rounded-md border bg-white shadow-lg p-3 text-sm text-gray-500">
|
|
No approved purchase orders found.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|