PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / common / BookingPicker.tsx

BookingPicker.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/components/common/BookingPicker.tsx

365 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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