PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at resources/js/pages/Travelers.tsx

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