| 1 |
/** |
| 2 |
* Booking Picker |
| 3 |
* |
| 4 |
* Async-search dropdown that lets admins select a booking by typing its |
| 5 |
* booking code (reference), customer name, or email. Used by forms that |
| 6 |
* need to associate a record with a booking — e.g. recording a new |
| 7 |
* payment from the admin Payments screen — instead of asking the user to |
| 8 |
* type a raw numeric booking ID. |
| 9 |
* |
| 10 |
* Backend: hits `GET /yatra/v1/bookings?search=…` which already searches |
| 11 |
* across `b.reference`, contact email, name, and phone. |
| 12 |
*/ |
| 13 |
|
| 14 |
import React, { useEffect, useMemo, useRef, useState } from "react"; |
| 15 |
import { ChevronDown, Loader2, Search, X } from "lucide-react"; |
| 16 |
import { __ } from "../../lib/i18n"; |
| 17 |
import { apiService } from "../../lib/api-client"; |
| 18 |
import { Input } from "../ui/input"; |
| 19 |
|
| 20 |
export interface BookingPickerBooking { |
| 21 |
id: number; |
| 22 |
booking_number?: string; |
| 23 |
reference?: string; |
| 24 |
customer_name?: string; |
| 25 |
customer_email?: string; |
| 26 |
trip_title?: string; |
| 27 |
total_amount?: number | string; |
| 28 |
currency?: string; |
| 29 |
booking_status?: string; |
| 30 |
payment_status?: string; |
| 31 |
} |
| 32 |
|
| 33 |
export interface BookingPickerProps { |
| 34 |
/** The selected booking id, as a string. Empty string means "no selection". */ |
| 35 |
value: string; |
| 36 |
/** |
| 37 |
* Called whenever the selection changes. Receives the new id (string, |
| 38 |
* empty when cleared) and, when available, the booking object. |
| 39 |
*/ |
| 40 |
onChange: (id: string, booking?: BookingPickerBooking | null) => void; |
| 41 |
placeholder?: string; |
| 42 |
searchPlaceholder?: string; |
| 43 |
className?: string; |
| 44 |
error?: boolean; |
| 45 |
disabled?: boolean; |
| 46 |
/** How many bookings to fetch per query. */ |
| 47 |
perPage?: number; |
| 48 |
/** ms to wait after the last keystroke before firing a request. */ |
| 49 |
debounceMs?: number; |
| 50 |
/** Maximum dropdown height in pixels (default 320). */ |
| 51 |
maxHeight?: number; |
| 52 |
} |
| 53 |
|
| 54 |
const formatBookingLabel = (b: BookingPickerBooking): string => { |
| 55 |
const code = b.booking_number || b.reference || `#${b.id}`; |
| 56 |
const customer = b.customer_name?.trim(); |
| 57 |
return customer ? `${code} — ${customer}` : code; |
| 58 |
}; |
| 59 |
|
| 60 |
const formatBookingMeta = (b: BookingPickerBooking): string => { |
| 61 |
const parts: string[] = []; |
| 62 |
if (b.trip_title) parts.push(b.trip_title); |
| 63 |
if (b.customer_email) parts.push(b.customer_email); |
| 64 |
return parts.join(" · "); |
| 65 |
}; |
| 66 |
|
| 67 |
export const BookingPicker: React.FC<BookingPickerProps> = ({ |
| 68 |
value, |
| 69 |
onChange, |
| 70 |
placeholder = __("Select a booking…", "yatra"), |
| 71 |
searchPlaceholder = __("Search by booking code, name, or email…", "yatra"), |
| 72 |
className = "", |
| 73 |
error = false, |
| 74 |
disabled = false, |
| 75 |
perPage = 20, |
| 76 |
debounceMs = 300, |
| 77 |
maxHeight = 320, |
| 78 |
}) => { |
| 79 |
const [isOpen, setIsOpen] = useState(false); |
| 80 |
const [searchTerm, setSearchTerm] = useState(""); |
| 81 |
const [debouncedTerm, setDebouncedTerm] = useState(""); |
| 82 |
const [results, setResults] = useState<BookingPickerBooking[]>([]); |
| 83 |
const [isLoading, setIsLoading] = useState(false); |
| 84 |
const [loadError, setLoadError] = useState<string>(""); |
| 85 |
|
| 86 |
// The currently selected booking object, used to render the closed-state |
| 87 |
// label. We keep this separate from `results` so a selection persists even |
| 88 |
// after the user clears the search box. |
| 89 |
const [selected, setSelected] = useState<BookingPickerBooking | null>(null); |
| 90 |
|
| 91 |
const containerRef = useRef<HTMLDivElement>(null); |
| 92 |
const searchInputRef = useRef<HTMLInputElement>(null); |
| 93 |
const requestIdRef = useRef(0); |
| 94 |
|
| 95 |
// Debounce the search input so we don't issue a request per keystroke. |
| 96 |
useEffect(() => { |
| 97 |
const timer = setTimeout(() => { |
| 98 |
setDebouncedTerm(searchTerm.trim()); |
| 99 |
}, debounceMs); |
| 100 |
return () => clearTimeout(timer); |
| 101 |
}, [searchTerm, debounceMs]); |
| 102 |
|
| 103 |
// Issue a search whenever the dropdown is open and the debounced term |
| 104 |
// changes (including the empty string, which surfaces the most-recent |
| 105 |
// bookings as a useful default list). |
| 106 |
useEffect(() => { |
| 107 |
if (!isOpen) return; |
| 108 |
|
| 109 |
const reqId = ++requestIdRef.current; |
| 110 |
setIsLoading(true); |
| 111 |
setLoadError(""); |
| 112 |
|
| 113 |
apiService |
| 114 |
.getBookings({ search: debouncedTerm, per_page: perPage, page: 1 }) |
| 115 |
.then((response: any) => { |
| 116 |
if (reqId !== requestIdRef.current) return; |
| 117 |
const list: BookingPickerBooking[] = Array.isArray(response?.data) |
| 118 |
? response.data |
| 119 |
: Array.isArray(response?.data?.data) |
| 120 |
? response.data.data |
| 121 |
: []; |
| 122 |
setResults(list); |
| 123 |
}) |
| 124 |
.catch((err: any) => { |
| 125 |
if (reqId !== requestIdRef.current) return; |
| 126 |
setLoadError(err?.message || __("Failed to load bookings.", "yatra")); |
| 127 |
setResults([]); |
| 128 |
}) |
| 129 |
.finally(() => { |
| 130 |
if (reqId === requestIdRef.current) { |
| 131 |
setIsLoading(false); |
| 132 |
} |
| 133 |
}); |
| 134 |
}, [isOpen, debouncedTerm, perPage]); |
| 135 |
|
| 136 |
// When the value changes externally (e.g. edit mode loads existing |
| 137 |
// booking_id), resolve the booking so we can show a useful label. |
| 138 |
useEffect(() => { |
| 139 |
if (!value) { |
| 140 |
setSelected(null); |
| 141 |
return; |
| 142 |
} |
| 143 |
|
| 144 |
if (selected && String(selected.id) === String(value)) { |
| 145 |
return; |
| 146 |
} |
| 147 |
|
| 148 |
let cancelled = false; |
| 149 |
apiService |
| 150 |
.getBooking(value) |
| 151 |
.then((response: any) => { |
| 152 |
if (cancelled) return; |
| 153 |
const data = response?.data ?? response; |
| 154 |
if (data && typeof data === "object" && data.id) { |
| 155 |
setSelected(data as BookingPickerBooking); |
| 156 |
} |
| 157 |
}) |
| 158 |
.catch(() => { |
| 159 |
// Silent — if we can't resolve, the dropdown still works; the user |
| 160 |
// simply sees the raw id until they pick a new booking. |
| 161 |
}); |
| 162 |
|
| 163 |
return () => { |
| 164 |
cancelled = true; |
| 165 |
}; |
| 166 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 167 |
}, [value]); |
| 168 |
|
| 169 |
// Close on outside click. |
| 170 |
useEffect(() => { |
| 171 |
if (!isOpen) return; |
| 172 |
|
| 173 |
const handler = (event: MouseEvent) => { |
| 174 |
if ( |
| 175 |
containerRef.current && |
| 176 |
!containerRef.current.contains(event.target as Node) |
| 177 |
) { |
| 178 |
setIsOpen(false); |
| 179 |
setSearchTerm(""); |
| 180 |
} |
| 181 |
}; |
| 182 |
|
| 183 |
document.addEventListener("mousedown", handler); |
| 184 |
const focusTimer = setTimeout(() => searchInputRef.current?.focus(), 80); |
| 185 |
|
| 186 |
return () => { |
| 187 |
document.removeEventListener("mousedown", handler); |
| 188 |
clearTimeout(focusTimer); |
| 189 |
}; |
| 190 |
}, [isOpen]); |
| 191 |
|
| 192 |
const handleToggle = () => { |
| 193 |
if (disabled) return; |
| 194 |
setIsOpen((prev) => !prev); |
| 195 |
}; |
| 196 |
|
| 197 |
const handleSelect = (booking: BookingPickerBooking) => { |
| 198 |
setSelected(booking); |
| 199 |
setIsOpen(false); |
| 200 |
setSearchTerm(""); |
| 201 |
onChange(String(booking.id), booking); |
| 202 |
}; |
| 203 |
|
| 204 |
const handleClear = (e: React.MouseEvent) => { |
| 205 |
e.stopPropagation(); |
| 206 |
setSelected(null); |
| 207 |
onChange("", null); |
| 208 |
}; |
| 209 |
|
| 210 |
const closedLabel = useMemo(() => { |
| 211 |
if (selected) return formatBookingLabel(selected); |
| 212 |
if (value) return `#${value}`; |
| 213 |
return ""; |
| 214 |
}, [selected, value]); |
| 215 |
|
| 216 |
const closedMeta = useMemo( |
| 217 |
() => (selected ? formatBookingMeta(selected) : ""), |
| 218 |
[selected], |
| 219 |
); |
| 220 |
|
| 221 |
return ( |
| 222 |
<div |
| 223 |
ref={containerRef} |
| 224 |
className={`relative ${className}`} |
| 225 |
style={{ zIndex: isOpen ? 9999 : "auto" }} |
| 226 |
> |
| 227 |
<div |
| 228 |
className={`flex min-h-[2.75rem] w-full rounded-md border-2 ${ |
| 229 |
error ? "border-red-500" : "border-gray-300 dark:border-gray-600" |
| 230 |
} bg-white dark:bg-gray-800 px-4 py-2 text-base ring-offset-white focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:ring-offset-gray-900 dark:focus-within:ring-blue-400 transition-colors`} |
| 231 |
> |
| 232 |
<button |
| 233 |
type="button" |
| 234 |
onClick={handleToggle} |
| 235 |
disabled={disabled} |
| 236 |
className={`flex-1 flex items-center justify-between text-left ${ |
| 237 |
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" |
| 238 |
}`} |
| 239 |
aria-haspopup="listbox" |
| 240 |
aria-expanded={isOpen} |
| 241 |
> |
| 242 |
<span |
| 243 |
className={ |
| 244 |
closedLabel |
| 245 |
? "text-gray-900 dark:text-white" |
| 246 |
: "text-gray-500 dark:text-gray-400" |
| 247 |
} |
| 248 |
> |
| 249 |
{closedLabel ? ( |
| 250 |
<span className="flex flex-col"> |
| 251 |
<span className="font-medium">{closedLabel}</span> |
| 252 |
{closedMeta && ( |
| 253 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 254 |
{closedMeta} |
| 255 |
</span> |
| 256 |
)} |
| 257 |
</span> |
| 258 |
) : ( |
| 259 |
placeholder |
| 260 |
)} |
| 261 |
</span> |
| 262 |
<span className="flex items-center gap-1 ml-2"> |
| 263 |
{value && !disabled && ( |
| 264 |
<span |
| 265 |
role="button" |
| 266 |
tabIndex={-1} |
| 267 |
onClick={handleClear} |
| 268 |
className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" |
| 269 |
aria-label={__("Clear selection", "yatra")} |
| 270 |
> |
| 271 |
<X className="w-4 h-4 text-gray-400" /> |
| 272 |
</span> |
| 273 |
)} |
| 274 |
<ChevronDown |
| 275 |
className={`w-4 h-4 text-gray-400 transition-transform ${ |
| 276 |
isOpen ? "rotate-180" : "" |
| 277 |
}`} |
| 278 |
/> |
| 279 |
</span> |
| 280 |
</button> |
| 281 |
</div> |
| 282 |
|
| 283 |
{isOpen && ( |
| 284 |
<div |
| 285 |
className="absolute z-[9999] w-full mt-1 bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-600 rounded-md shadow-lg overflow-hidden" |
| 286 |
style={{ maxHeight }} |
| 287 |
> |
| 288 |
<div className="p-2 border-b border-gray-200 dark:border-gray-700"> |
| 289 |
<div className="relative"> |
| 290 |
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> |
| 291 |
<Input |
| 292 |
ref={searchInputRef} |
| 293 |
type="text" |
| 294 |
placeholder={searchPlaceholder} |
| 295 |
value={searchTerm} |
| 296 |
onChange={(e) => setSearchTerm(e.target.value)} |
| 297 |
className="pl-8 h-8" |
| 298 |
onClick={(e) => e.stopPropagation()} |
| 299 |
/> |
| 300 |
{isLoading && ( |
| 301 |
<Loader2 className="absolute right-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 animate-spin" /> |
| 302 |
)} |
| 303 |
</div> |
| 304 |
</div> |
| 305 |
|
| 306 |
<div |
| 307 |
className="overflow-y-auto" |
| 308 |
style={{ maxHeight: maxHeight - 56 }} |
| 309 |
> |
| 310 |
{loadError ? ( |
| 311 |
<div className="px-3 py-3 text-sm text-red-600 dark:text-red-400"> |
| 312 |
{loadError} |
| 313 |
</div> |
| 314 |
) : isLoading && results.length === 0 ? ( |
| 315 |
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"> |
| 316 |
{__("Loading bookings…", "yatra")} |
| 317 |
</div> |
| 318 |
) : results.length === 0 ? ( |
| 319 |
<div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"> |
| 320 |
{debouncedTerm |
| 321 |
? __("No bookings match this search.", "yatra") |
| 322 |
: __("No bookings found.", "yatra")} |
| 323 |
</div> |
| 324 |
) : ( |
| 325 |
results.map((booking) => { |
| 326 |
const isActive = String(booking.id) === String(value); |
| 327 |
return ( |
| 328 |
<button |
| 329 |
key={booking.id} |
| 330 |
type="button" |
| 331 |
onClick={() => handleSelect(booking)} |
| 332 |
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${ |
| 333 |
isActive |
| 334 |
? "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400" |
| 335 |
: "text-gray-900 dark:text-white" |
| 336 |
}`} |
| 337 |
> |
| 338 |
<div className="flex items-center justify-between gap-3"> |
| 339 |
<div className="flex flex-col min-w-0"> |
| 340 |
<span className="font-medium truncate"> |
| 341 |
{formatBookingLabel(booking)} |
| 342 |
</span> |
| 343 |
{formatBookingMeta(booking) && ( |
| 344 |
<span className="text-xs text-gray-500 dark:text-gray-400 truncate"> |
| 345 |
{formatBookingMeta(booking)} |
| 346 |
</span> |
| 347 |
)} |
| 348 |
</div> |
| 349 |
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"> |
| 350 |
ID: {booking.id} |
| 351 |
</span> |
| 352 |
</div> |
| 353 |
</button> |
| 354 |
); |
| 355 |
}) |
| 356 |
)} |
| 357 |
</div> |
| 358 |
</div> |
| 359 |
)} |
| 360 |
</div> |
| 361 |
); |
| 362 |
}; |
| 363 |
|
| 364 |
export default BookingPicker; |
| 365 |
|