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 / pages / Travelers.tsx

Travelers.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/Travelers.tsx

655 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Travelers Listing Page
3 * Display all travelers from bookings with dynamic columns from Form Builder
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Users,
10 Search,
11 Eye,
12 Mail,
13 Phone,
14 Calendar,
15 MapPin,
16 Trash2,
17 } from "lucide-react";
18 import { __ } from "../lib/i18n";
19 import { formatDate as formatDateUtil } from "../lib/dateFormat";
20 import { apiService } from "../lib/api-client";
21 import { usePermissions } from "../hooks/usePermissions";
22 import { Input } from "../components/ui/input";
23 import { Select } from "../components/ui/select";
24 import { PageHeader } from "../components/common/PageHeader";
25 import { Card, CardContent } from "../components/ui/card";
26 import {
27 Pagination,
28 Table as SharedTable,
29 BulkActionToolbar,
30 } from "../components/shared";
31 import { Skeleton } from "../components/ui/skeleton";
32 import { Badge } from "../components/ui/badge";
33 import { useToast } from "../components/ui/toast";
34 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
35 import { getErrorContext } from "../lib/errors";
36 import { getCountryName } from "../lib/countries";
37
38 interface FormField {
39 id: string;
40 type: string;
41 label: string;
42 placeholder?: string;
43 required: boolean;
44 enabled: boolean;
45 order: number;
46 width?: string;
47 locked?: boolean;
48 }
49
50 interface FormConfig {
51 traveler_form?: {
52 title: string;
53 description: string;
54 enabled: boolean;
55 fields: FormField[];
56 /** Pro: per-trip versions of the form, each with its own field list. */
57 conditions?: { fields?: FormField[] }[];
58 };
59 }
60
61 interface Traveler {
62 id: string | number;
63 booking_id: number;
64 booking_reference: string;
65 trip_id: number;
66 trip_title: string;
67 travel_date: string;
68 traveler_index: number;
69 is_lead: boolean;
70 // Dynamic fields
71 [key: string]: any;
72 }
73
74 // Fields to exclude from dynamic columns (already shown in fixed columns)
75 const EXCLUDED_DYNAMIC_FIELDS = [
76 "first_name",
77 "last_name",
78 "gender",
79 "nationality",
80 "phone",
81 "_traveller_id",
82 "_is_lead",
83 "_traveller_index",
84 "booking_id",
85 "booking_reference",
86 "trip_id",
87 "trip_title",
88 "travel_date",
89 "traveler_index",
90 "is_lead",
91 "id",
92 ];
93
94 const Travelers: React.FC = () => {
95 const queryClient = useQueryClient();
96 const { can } = usePermissions();
97 const { showToast } = useToast();
98 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
99 const [travelerToDelete, setTravelerToDelete] = useState<Traveler | null>(
100 null,
101 );
102 const [searchTerm, setSearchTerm] = useState("");
103 const [tripFilter, setTripFilter] = useState("");
104 const [page, setPage] = useState(1);
105 const perPage = 20;
106 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
107 const [bulkAction, setBulkAction] = useState("");
108 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
109 const [visibleColumns, setVisibleColumns] = useState(() => {
110 if (typeof window === "undefined") {
111 return {
112 traveler_info: true,
113 trip: true,
114 travel_date: true,
115 booking_reference: true,
116 };
117 }
118 const saved = window.localStorage.getItem(
119 "yatra-travelers-visible-columns",
120 );
121 return saved
122 ? JSON.parse(saved)
123 : {
124 traveler_info: true,
125 trip: true,
126 travel_date: true,
127 booking_reference: true,
128 };
129 });
130
131 // Fetch form configuration for dynamic columns. The global (unscoped)
132 // config, conditions included — this list spans every trip, so a column is
133 // shown for any traveler field ANY trip's form version can ask.
134 const { data: formConfigData } = useQuery({
135 queryKey: ["booking-form-config", "global"],
136 queryFn: async () => {
137 const result = await apiService.getBookingFormConfig();
138 return (
139 result?.data?.booking_form_config || result?.booking_form_config || null
140 );
141 },
142 });
143
144 // Store available trips from first page load
145 const [availableTrips, setAvailableTrips] = useState<
146 Array<{ id: number; title: string }>
147 >([]);
148
149 // Fetch travelers from API (includes trip info via JOIN, and available_trips in meta on first page)
150 const { data, isLoading, error, refetch } = useQuery({
151 queryKey: ["travelers", searchTerm, tripFilter, page],
152 queryFn: async () => {
153 // Check URL parameter for error simulation (for testing)
154 const urlParams = new URLSearchParams(window.location.search);
155 if (urlParams.get("simulate_error") === "true") {
156 throw new Error(
157 "Simulated API error for testing error UI functionality",
158 );
159 }
160
161 const paramsObj: Record<string, any> = {
162 page: page,
163 per_page: perPage,
164 };
165 if (searchTerm) paramsObj.search = searchTerm;
166 if (tripFilter) paramsObj.trip_id = tripFilter;
167
168 const response = await apiService.getTravelers(paramsObj);
169
170 // Store available trips from meta (returned on first page with no search filter)
171 if (response.meta?.available_trips?.length > 0) {
172 setAvailableTrips(response.meta.available_trips);
173 }
174
175 return response;
176 },
177 enabled: can("yatra_view_bookings"),
178 });
179
180 // Use available trips from state for filter dropdown
181 const tripsData = useMemo(() => ({ data: availableTrips }), [availableTrips]);
182
183 // Get dynamic columns from form config
184 const dynamicColumns = useMemo(() => {
185 const formConfig = formConfigData as FormConfig | null;
186 if (!formConfig?.traveler_form?.fields) {
187 return [];
188 }
189
190 // Global fields first (in their order), then any field that only exists
191 // in a per-trip version of the form (Pro conditions), de-duplicated by id.
192 const seen = new Set<string>();
193 const allFields: FormField[] = [];
194 const add = (fields?: FormField[]) =>
195 (fields || []).forEach((field) => {
196 if (!seen.has(field.id)) {
197 seen.add(field.id);
198 allFields.push(field);
199 }
200 });
201 add(
202 [...formConfig.traveler_form.fields].sort(
203 (a, b) => (a.order || 0) - (b.order || 0),
204 ),
205 );
206 (formConfig.traveler_form.conditions || []).forEach((c) => add(c.fields));
207
208 const enabledFields = allFields.filter(
209 (field) => field.enabled && !EXCLUDED_DYNAMIC_FIELDS.includes(field.id),
210 );
211
212 return enabledFields.map((field) => ({
213 id: field.id,
214 label: field.label,
215 type: field.type,
216 }));
217 }, [formConfigData]);
218
219 const travelers = data?.data || [];
220 const totalTravelers = data?.meta?.total || 0;
221 const totalPages = Math.ceil(totalTravelers / perPage);
222
223 // Enhanced error handling
224 const errorContext = getErrorContext(error);
225 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
226 const derivedErrorDetails =
227 errorContext.details ||
228 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
229 (error ? String(error?.message || error) : undefined);
230 const isTravelersError = !!error || !!apiErrorMessage;
231
232 const handleViewBooking = (bookingId: number) => {
233 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${bookingId}`;
234 };
235
236 // Use the shared formatter so traveler dates (Travel Date, Date of Birth)
237 // follow the global WP date-format setting — not the browser locale — and
238 // parse date-only values as local calendar days (no off-by-one).
239 const formatDate = (dateString: string) => {
240 if (!dateString) return "-";
241 return formatDateUtil(dateString);
242 };
243
244 // Bulk delete travelers
245 const bulkMutation = useMutation({
246 mutationFn: async ({
247 action,
248 ids,
249 }: {
250 action: string;
251 ids: (string | number)[];
252 }) => {
253 return await apiService.bulkTravelersAction(action, ids);
254 },
255 onSuccess: () => {
256 queryClient.invalidateQueries({ queryKey: ["travelers"] });
257 setSelectedIds([]);
258 setBulkAction("");
259 },
260 });
261
262 const handleBulkApply = () => {
263 if (!bulkAction || selectedIds.length === 0) {
264 return;
265 }
266
267 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
268 };
269
270 // Handle single traveler delete
271 const handleDelete = (traveler: Traveler) => {
272 setTravelerToDelete(traveler);
273 setDeleteDialogOpen(true);
274 };
275
276 const confirmDelete = async () => {
277 if (!travelerToDelete) return;
278
279 try {
280 await bulkMutation.mutateAsync({
281 action: "delete",
282 ids: [travelerToDelete.id],
283 });
284 showToast(__("Traveler deleted successfully", "yatra"), "success");
285 setDeleteDialogOpen(false);
286 setTravelerToDelete(null);
287 } catch (error: any) {
288 showToast(
289 error?.message || __("Failed to delete traveler", "yatra"),
290 "error",
291 );
292 }
293 };
294
295 // Toggle column visibility
296 const toggleColumn = (columnKey: string) => {
297 const newVisibleColumns = {
298 ...visibleColumns,
299 [columnKey]: !visibleColumns[columnKey],
300 };
301 setVisibleColumns(newVisibleColumns);
302 if (typeof window !== "undefined") {
303 window.localStorage.setItem(
304 "yatra-travelers-visible-columns",
305 JSON.stringify(newVisibleColumns),
306 );
307 }
308 };
309
310 // Column options for the columns dropdown
311 const columnOptions = [
312 {
313 key: "traveler_info",
314 label: __("Traveler", "yatra"),
315 visible: visibleColumns.traveler_info,
316 },
317 { key: "trip", label: __("Trip", "yatra"), visible: visibleColumns.trip },
318 {
319 key: "travel_date",
320 label: __("Travel Date", "yatra"),
321 visible: visibleColumns.travel_date,
322 },
323 {
324 key: "booking_reference",
325 label: __("Booking", "yatra"),
326 visible: visibleColumns.booking_reference,
327 },
328 ];
329
330 // Actions for the 3-dot menu
331 const actions = [
332 {
333 key: "view",
334 label: __("View Booking", "yatra"),
335 icon: <Eye className="w-4 h-4" />,
336 onClick: (traveler: Traveler) => handleViewBooking(traveler.booking_id),
337 },
338 {
339 key: "delete",
340 label: __("Delete", "yatra"),
341 icon: <Trash2 className="w-4 h-4" />,
342 onClick: (traveler: Traveler) => handleDelete(traveler),
343 variant: "destructive" as const,
344 condition: () => can("yatra_delete_bookings"),
345 },
346 ];
347
348 // Format cell value based on field type
349 const formatCellValue = (value: any, fieldType: string): React.ReactNode => {
350 if (value === undefined || value === null || value === "") {
351 return <span className="text-gray-400">-</span>;
352 }
353
354 switch (fieldType) {
355 case "date":
356 return formatDate(value);
357 case "email":
358 return (
359 <div className="flex items-center gap-1 text-sm">
360 <Mail className="w-3 h-3 text-gray-400" />
361 <span className="truncate max-w-[150px]">{value}</span>
362 </div>
363 );
364 case "tel":
365 return (
366 <div className="flex items-center gap-1 text-sm">
367 <Phone className="w-3 h-3 text-gray-400" />
368 <span>{value}</span>
369 </div>
370 );
371 case "country":
372 return (
373 <div className="flex items-center gap-1 text-sm">
374 <MapPin className="w-3 h-3 text-gray-400" />
375 <span>{getCountryName(value)}</span>
376 </div>
377 );
378 default:
379 return <span className="text-sm">{String(value)}</span>;
380 }
381 };
382
383 return (
384 <div className="space-y-3">
385 <PageHeader
386 title={__("Travelers", "yatra")}
387 description={__("View all travelers from bookings", "yatra")}
388 />
389
390 {/* Filters */}
391 <Card>
392 <CardContent className="p-4">
393 <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-center">
394 {/* Search Field - Takes most space */}
395 <div className="lg:col-span-8">
396 <div className="relative">
397 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
398 <Input
399 type="text"
400 placeholder={__("Search by name, email, phone...", "yatra")}
401 value={searchTerm}
402 onChange={(e) => {
403 setSearchTerm(e.target.value);
404 setPage(1);
405 }}
406 className="pl-10 w-full"
407 />
408 </div>
409 </div>
410
411 {/* Trip Filter */}
412 <div className="lg:col-span-4">
413 <Select
414 value={tripFilter}
415 onChange={(e) => {
416 setTripFilter(e.target.value);
417 setPage(1);
418 }}
419 className="w-full"
420 >
421 <option value="all">{__("All Trips", "yatra")}</option>
422 {tripsData?.data?.map((trip: any) => (
423 <option key={trip.id} value={trip.id}>
424 {trip.title}
425 </option>
426 ))}
427 </Select>
428 </div>
429 </div>
430 </CardContent>
431 </Card>
432
433 {/* Stats */}
434 <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
435 <Card>
436 <CardContent className="p-4 flex items-center gap-3">
437 <div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
438 <Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
439 </div>
440 <div>
441 <p className="text-sm text-gray-500 dark:text-gray-400">
442 {__("Total Travelers", "yatra")}
443 </p>
444 <p className="text-xl font-semibold text-gray-900 dark:text-white">
445 {isLoading ? <Skeleton className="h-6 w-16" /> : totalTravelers}
446 </p>
447 </div>
448 </CardContent>
449 </Card>
450 </div>
451
452 {/* Bulk actions toolbar */}
453 <BulkActionToolbar
454 selectedIds={selectedIds}
455 bulkAction={bulkAction}
456 setBulkAction={setBulkAction}
457 onApply={handleBulkApply}
458 onClearSelection={() => setSelectedIds([])}
459 statusFilter="all"
460 setStatusFilter={() => {}}
461 statusOptions={[
462 { key: "all", label: __("All", "yatra"), count: totalTravelers },
463 ]}
464 showColumnsDropdown={showColumnsDropdown}
465 setShowColumnsDropdown={setShowColumnsDropdown}
466 columnOptions={columnOptions}
467 onToggleColumn={toggleColumn}
468 bulkMutationPending={bulkMutation.isPending}
469 totalItems={travelers.length}
470 bulkActionOptions={[{ value: "delete", label: __("Delete", "yatra") }]}
471 />
472
473 {/* Travelers Table */}
474 <Card>
475 <CardContent className="p-0 overflow-x-auto">
476 <SharedTable
477 data={travelers}
478 isLoading={isLoading}
479 isError={isTravelersError}
480 errorText={__("Error loading travelers", "yatra")}
481 errorDescription={__(
482 "We couldn't connect to the travelers service. Please refresh or try again shortly.",
483 "yatra",
484 )}
485 errorDetails={derivedErrorDetails}
486 errorRequestInfo={errorContext.requestInfo}
487 onRetry={() => refetch()}
488 skeletonRows={5}
489 emptyText={
490 searchTerm || tripFilter
491 ? __("No travelers found matching your criteria", "yatra")
492 : __("No travelers found", "yatra")
493 }
494 emptyDescription={__(
495 "View and manage travelers collected from your bookings.",
496 "yatra",
497 )}
498 capability="yatra_view_bookings"
499 selectedItemIds={selectedIds}
500 onSelectItem={(id: string | number, checked: boolean) => {
501 if (checked) {
502 setSelectedIds((prev) =>
503 prev.includes(id) ? prev : [...prev, id],
504 );
505 } else {
506 setSelectedIds((prev) =>
507 prev.filter((existingId) => existingId !== id),
508 );
509 }
510 }}
511 onSelectAll={(checked: boolean) => {
512 if (checked) {
513 setSelectedIds(travelers.map((t: Traveler) => t.id));
514 } else {
515 setSelectedIds([]);
516 }
517 }}
518 isAllSelected={
519 travelers.length > 0 && selectedIds.length === travelers.length
520 }
521 getItemId={(traveler: Traveler) => traveler.id}
522 actions={actions}
523 columns={[
524 visibleColumns.traveler_info && {
525 key: "traveler_info",
526 label: __("Traveler", "yatra"),
527 render: (traveler: Traveler) => (
528 <div className="flex items-center gap-3">
529 <div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-300">
530 {(traveler.first_name?.[0] || "?").toUpperCase()}
531 {(traveler.last_name?.[0] || "").toUpperCase()}
532 </div>
533 <div>
534 <div className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
535 {[traveler.first_name, traveler.last_name]
536 .filter(Boolean)
537 .join(" ") || "N/A"}
538 {traveler.is_lead && (
539 <Badge variant="info" className="text-xs">
540 {__("Lead", "yatra")}
541 </Badge>
542 )}
543 </div>
544 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 space-y-0.5">
545 {traveler.phone && (
546 <div className="flex items-center gap-1">
547 <Phone className="w-3 h-3" />
548 <span>{traveler.phone}</span>
549 </div>
550 )}
551 <div className="flex items-center gap-2">
552 {traveler.gender && (
553 <span className="capitalize">
554 {traveler.gender}
555 </span>
556 )}
557 {traveler.nationality && (
558 <>
559 {traveler.gender && <span>•</span>}
560 <span>
561 {getCountryName(traveler.nationality)}
562 </span>
563 </>
564 )}
565 </div>
566 </div>
567 </div>
568 </div>
569 ),
570 },
571 ...dynamicColumns.map((col) => ({
572 key: `dynamic_${col.id}`,
573 label: col.label,
574 render: (traveler: Traveler) =>
575 formatCellValue(traveler[col.id], col.type),
576 })),
577 visibleColumns.trip && {
578 key: "trip",
579 label: __("Trip", "yatra"),
580 render: (traveler: Traveler) => (
581 <div
582 className="max-w-[200px] truncate"
583 title={traveler.trip_title}
584 >
585 {traveler.trip_title || `Trip #${traveler.trip_id}`}
586 </div>
587 ),
588 },
589 visibleColumns.travel_date && {
590 key: "travel_date",
591 label: __("Travel Date", "yatra"),
592 render: (traveler: Traveler) => (
593 <div className="flex items-center gap-1 text-sm">
594 <Calendar className="w-3 h-3 text-gray-400" />
595 {formatDate(traveler.travel_date)}
596 </div>
597 ),
598 },
599 visibleColumns.booking_reference && {
600 key: "booking_reference",
601 label: __("Booking", "yatra"),
602 render: (traveler: Traveler) => (
603 <a
604 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${traveler.booking_id}`}
605 className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-mono text-sm"
606 >
607 {traveler.booking_reference}
608 </a>
609 ),
610 },
611 ].filter(Boolean)}
612 />
613 </CardContent>
614 </Card>
615
616 {/* Pagination */}
617 {totalTravelers > 0 && (
618 <Pagination
619 currentPage={page}
620 totalPages={totalPages}
621 totalItems={totalTravelers}
622 itemsPerPage={perPage}
623 onPageChange={setPage}
624 itemName={__("travelers", "yatra")}
625 />
626 )}
627
628 {/* Delete Confirmation Dialog */}
629 <ConfirmationDialog
630 isOpen={deleteDialogOpen}
631 onClose={() => {
632 setDeleteDialogOpen(false);
633 setTravelerToDelete(null);
634 }}
635 onConfirm={confirmDelete}
636 title={__("Delete Traveler", "yatra")}
637 message={
638 travelerToDelete
639 ? __(
640 `Are you sure you want to delete ${[travelerToDelete.first_name, travelerToDelete.last_name].filter(Boolean).join(" ")}? This action cannot be undone.`,
641 "yatra",
642 )
643 : __("Are you sure you want to delete this traveler?", "yatra")
644 }
645 confirmText={__("Delete", "yatra")}
646 cancelText={__("Cancel", "yatra")}
647 variant="danger"
648 isLoading={bulkMutation.isPending}
649 />
650 </div>
651 );
652 };
653
654 export default Travelers;
655