/** * Booking Picker * * Async-search dropdown that lets admins select a booking by typing its * booking code (reference), customer name, or email. Used by forms that * need to associate a record with a booking — e.g. recording a new * payment from the admin Payments screen — instead of asking the user to * type a raw numeric booking ID. * * Backend: hits `GET /yatra/v1/bookings?search=…` which already searches * across `b.reference`, contact email, name, and phone. */ import React, { useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, Loader2, Search, X } from "lucide-react"; import { __ } from "../../lib/i18n"; import { apiService } from "../../lib/api-client"; import { Input } from "../ui/input"; export interface BookingPickerBooking { id: number; booking_number?: string; reference?: string; customer_name?: string; customer_email?: string; trip_title?: string; total_amount?: number | string; currency?: string; booking_status?: string; payment_status?: string; } export interface BookingPickerProps { /** The selected booking id, as a string. Empty string means "no selection". */ value: string; /** * Called whenever the selection changes. Receives the new id (string, * empty when cleared) and, when available, the booking object. */ onChange: (id: string, booking?: BookingPickerBooking | null) => void; placeholder?: string; searchPlaceholder?: string; className?: string; error?: boolean; disabled?: boolean; /** How many bookings to fetch per query. */ perPage?: number; /** ms to wait after the last keystroke before firing a request. */ debounceMs?: number; /** Maximum dropdown height in pixels (default 320). */ maxHeight?: number; } const formatBookingLabel = (b: BookingPickerBooking): string => { const code = b.booking_number || b.reference || `#${b.id}`; const customer = b.customer_name?.trim(); return customer ? `${code} — ${customer}` : code; }; const formatBookingMeta = (b: BookingPickerBooking): string => { const parts: string[] = []; if (b.trip_title) parts.push(b.trip_title); if (b.customer_email) parts.push(b.customer_email); return parts.join(" · "); }; export const BookingPicker: React.FC = ({ value, onChange, placeholder = __("Select a booking…", "yatra"), searchPlaceholder = __("Search by booking code, name, or email…", "yatra"), className = "", error = false, disabled = false, perPage = 20, debounceMs = 300, maxHeight = 320, }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [debouncedTerm, setDebouncedTerm] = useState(""); const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(""); // The currently selected booking object, used to render the closed-state // label. We keep this separate from `results` so a selection persists even // after the user clears the search box. const [selected, setSelected] = useState(null); const containerRef = useRef(null); const searchInputRef = useRef(null); const requestIdRef = useRef(0); // Debounce the search input so we don't issue a request per keystroke. useEffect(() => { const timer = setTimeout(() => { setDebouncedTerm(searchTerm.trim()); }, debounceMs); return () => clearTimeout(timer); }, [searchTerm, debounceMs]); // Issue a search whenever the dropdown is open and the debounced term // changes (including the empty string, which surfaces the most-recent // bookings as a useful default list). useEffect(() => { if (!isOpen) return; const reqId = ++requestIdRef.current; setIsLoading(true); setLoadError(""); apiService .getBookings({ search: debouncedTerm, per_page: perPage, page: 1 }) .then((response: any) => { if (reqId !== requestIdRef.current) return; const list: BookingPickerBooking[] = Array.isArray(response?.data) ? response.data : Array.isArray(response?.data?.data) ? response.data.data : []; setResults(list); }) .catch((err: any) => { if (reqId !== requestIdRef.current) return; setLoadError(err?.message || __("Failed to load bookings.", "yatra")); setResults([]); }) .finally(() => { if (reqId === requestIdRef.current) { setIsLoading(false); } }); }, [isOpen, debouncedTerm, perPage]); // When the value changes externally (e.g. edit mode loads existing // booking_id), resolve the booking so we can show a useful label. useEffect(() => { if (!value) { setSelected(null); return; } if (selected && String(selected.id) === String(value)) { return; } let cancelled = false; apiService .getBooking(value) .then((response: any) => { if (cancelled) return; const data = response?.data ?? response; if (data && typeof data === "object" && data.id) { setSelected(data as BookingPickerBooking); } }) .catch(() => { // Silent — if we can't resolve, the dropdown still works; the user // simply sees the raw id until they pick a new booking. }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); // Close on outside click. useEffect(() => { if (!isOpen) return; const handler = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) ) { setIsOpen(false); setSearchTerm(""); } }; document.addEventListener("mousedown", handler); const focusTimer = setTimeout(() => searchInputRef.current?.focus(), 80); return () => { document.removeEventListener("mousedown", handler); clearTimeout(focusTimer); }; }, [isOpen]); const handleToggle = () => { if (disabled) return; setIsOpen((prev) => !prev); }; const handleSelect = (booking: BookingPickerBooking) => { setSelected(booking); setIsOpen(false); setSearchTerm(""); onChange(String(booking.id), booking); }; const handleClear = (e: React.MouseEvent) => { e.stopPropagation(); setSelected(null); onChange("", null); }; const closedLabel = useMemo(() => { if (selected) return formatBookingLabel(selected); if (value) return `#${value}`; return ""; }, [selected, value]); const closedMeta = useMemo( () => (selected ? formatBookingMeta(selected) : ""), [selected], ); return (
{isOpen && (
setSearchTerm(e.target.value)} className="pl-8 h-8" onClick={(e) => e.stopPropagation()} /> {isLoading && ( )}
{loadError ? (
{loadError}
) : isLoading && results.length === 0 ? (
{__("Loading bookings…", "yatra")}
) : results.length === 0 ? (
{debouncedTerm ? __("No bookings match this search.", "yatra") : __("No bookings found.", "yatra")}
) : ( results.map((booking) => { const isActive = String(booking.id) === String(value); return ( ); }) )}
)}
); }; export default BookingPicker;