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

634 lines 20.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 { apiService } from "../lib/api-client";
20 import { usePermissions } from "../hooks/usePermissions";
21 import { Input } from "../components/ui/input";
22 import { Select } from "../components/ui/select";
23 import { PageHeader } from "../components/common/PageHeader";
24 import { Card, CardContent } from "../components/ui/card";
25 import {
26 Pagination,
27 Table as SharedTable,
28 BulkActionToolbar,
29 } from "../components/shared";
30 import { Skeleton } from "../components/ui/skeleton";
31 import { Badge } from "../components/ui/badge";
32 import { useToast } from "../components/ui/toast";
33 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
34 import { getErrorContext } from "../lib/errors";
35 import { getCountryName } from "../lib/countries";
36
37 interface FormField {
38 id: string;
39 type: string;
40 label: string;
41 placeholder?: string;
42 required: boolean;
43 enabled: boolean;
44 order: number;
45 width?: string;
46 locked?: boolean;
47 }
48
49 interface FormConfig {
50 traveler_form?: {
51 title: string;
52 description: string;
53 enabled: boolean;
54 fields: FormField[];
55 };
56 }
57
58 interface Traveler {
59 id: string | number;
60 booking_id: number;
61 booking_reference: string;
62 trip_id: number;
63 trip_title: string;
64 travel_date: string;
65 traveler_index: number;
66 is_lead: boolean;
67 // Dynamic fields
68 [key: string]: any;
69 }
70
71 // Fields to exclude from dynamic columns (already shown in fixed columns)
72 const EXCLUDED_DYNAMIC_FIELDS = [
73 "first_name",
74 "last_name",
75 "gender",
76 "nationality",
77 "phone",
78 "_traveller_id",
79 "_is_lead",
80 "_traveller_index",
81 "booking_id",
82 "booking_reference",
83 "trip_id",
84 "trip_title",
85 "travel_date",
86 "traveler_index",
87 "is_lead",
88 "id",
89 ];
90
91 const Travelers: React.FC = () => {
92 const queryClient = useQueryClient();
93 const { can } = usePermissions();
94 const { showToast } = useToast();
95 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
96 const [travelerToDelete, setTravelerToDelete] = useState<Traveler | null>(
97 null,
98 );
99 const [searchTerm, setSearchTerm] = useState("");
100 const [tripFilter, setTripFilter] = useState("");
101 const [page, setPage] = useState(1);
102 const perPage = 20;
103 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
104 const [bulkAction, setBulkAction] = useState("");
105 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
106 const [visibleColumns, setVisibleColumns] = useState(() => {
107 if (typeof window === "undefined") {
108 return {
109 traveler_info: true,
110 trip: true,
111 travel_date: true,
112 booking_reference: true,
113 };
114 }
115 const saved = window.localStorage.getItem(
116 "yatra-travelers-visible-columns",
117 );
118 return saved
119 ? JSON.parse(saved)
120 : {
121 traveler_info: true,
122 trip: true,
123 travel_date: true,
124 booking_reference: true,
125 };
126 });
127
128 // Fetch form configuration for dynamic columns
129 const { data: formConfigData } = useQuery({
130 queryKey: ["booking-form-config"],
131 queryFn: async () => {
132 const result = await apiService.getSettings("booking_form");
133 return result.success ? result.data?.booking_form_config : null;
134 },
135 });
136
137 // Store available trips from first page load
138 const [availableTrips, setAvailableTrips] = useState<
139 Array<{ id: number; title: string }>
140 >([]);
141
142 // Fetch travelers from API (includes trip info via JOIN, and available_trips in meta on first page)
143 const { data, isLoading, error, refetch } = useQuery({
144 queryKey: ["travelers", searchTerm, tripFilter, page],
145 queryFn: async () => {
146 // Check URL parameter for error simulation (for testing)
147 const urlParams = new URLSearchParams(window.location.search);
148 if (urlParams.get("simulate_error") === "true") {
149 throw new Error(
150 "Simulated API error for testing error UI functionality",
151 );
152 }
153
154 const paramsObj: Record<string, any> = {
155 page: page,
156 per_page: perPage,
157 };
158 if (searchTerm) paramsObj.search = searchTerm;
159 if (tripFilter) paramsObj.trip_id = tripFilter;
160
161 const response = await apiService.getTravelers(paramsObj);
162
163 // Store available trips from meta (returned on first page with no search filter)
164 if (response.meta?.available_trips?.length > 0) {
165 setAvailableTrips(response.meta.available_trips);
166 }
167
168 return response;
169 },
170 enabled: can("yatra_view_bookings"),
171 });
172
173 // Use available trips from state for filter dropdown
174 const tripsData = useMemo(() => ({ data: availableTrips }), [availableTrips]);
175
176 // Get dynamic columns from form config
177 const dynamicColumns = useMemo(() => {
178 const formConfig = formConfigData as FormConfig | null;
179 if (!formConfig?.traveler_form?.fields) {
180 return [];
181 }
182
183 // Get enabled fields from form config, sorted by order
184 const enabledFields = formConfig.traveler_form.fields
185 .filter(
186 (field) => field.enabled && !EXCLUDED_DYNAMIC_FIELDS.includes(field.id),
187 )
188 .sort((a, b) => (a.order || 0) - (b.order || 0));
189
190 return enabledFields.map((field) => ({
191 id: field.id,
192 label: field.label,
193 type: field.type,
194 }));
195 }, [formConfigData]);
196
197 const travelers = data?.data || [];
198 const totalTravelers = data?.meta?.total || 0;
199 const totalPages = Math.ceil(totalTravelers / perPage);
200
201 // Enhanced error handling
202 const errorContext = getErrorContext(error);
203 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
204 const derivedErrorDetails =
205 errorContext.details ||
206 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
207 (error ? String(error?.message || error) : undefined);
208 const isTravelersError = !!error || !!apiErrorMessage;
209
210 const handleViewBooking = (bookingId: number) => {
211 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${bookingId}`;
212 };
213
214 const formatDate = (dateString: string) => {
215 if (!dateString) return "-";
216 try {
217 return new Date(dateString).toLocaleDateString();
218 } catch {
219 return dateString;
220 }
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