PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
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 / Bookings.tsx

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

1,097 lines 34.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Bookings Page
3 * Clean, minimal SaaS-style bookings management page
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 ArrowUpDown,
11 ArrowUp,
12 ArrowDown,
13 Eye,
14 Edit,
15 Trash2,
16 AlertTriangle,
17 CreditCard,
18 CircleDollarSign,
19 Undo2,
20 } from "lucide-react";
21 import {
22 Pagination,
23 SearchFilterToolbar,
24 BulkActionToolbar,
25 Table as SharedTable,
26 } from "../components/shared";
27 import { __ } from "../lib/i18n";
28 import { useToast } from "../components/ui/toast";
29 import { Button } from "../components/ui/button";
30 import { Select } from "../components/ui/select";
31 import { PageHeader } from "../components/common/PageHeader";
32 import { Card, CardContent } from "../components/ui/card";
33 import { ConditionalRender } from "../components/ui/conditional-render";
34 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
35 import { formatYatraMoney } from "../lib/currency-display";
36 import { apiService } from "../lib/api-client";
37 import { formatDate as formatDateUtil } from "../lib/dateFormat";
38 import { getErrorContext } from "../lib/errors";
39 import { usePermissions } from "../hooks/usePermissions";
40
41 interface Booking {
42 id: number;
43 booking_number: string;
44 customer_name: string;
45 customer_email: string;
46 trip_title: string;
47 trip_id: number;
48 booking_date: string;
49 travel_date: string;
50 travelers?: number;
51 travelers_count?: number;
52 total_amount: number;
53 amount_paid?: number;
54 currency?: string;
55 payment_status: string;
56 booking_status: string;
57 payment_method?: string;
58 payment_gateway?: string;
59 created_at: string;
60 }
61
62
63 /**
64 * Payment statuses an administrator can set straight from the booking list.
65 *
66 * Labels are prefixed with "Payment:" because the same menu already carries
67 * booking-status actions ("Mark as Pending", "Mark as Cancelled") — without the
68 * prefix two entries would read identically and set different fields.
69 *
70 * `failed` is deliberately omitted: it describes a gateway outcome rather than
71 * something an operator records by hand, and it is still settable from the
72 * booking edit screen.
73 */
74 const PAYMENT_STATUS_ACTIONS: Array<{
75 value: string;
76 label: () => string;
77 icon: React.ReactNode;
78 }> = [
79 {
80 value: "paid",
81 label: () => __("Payment: Mark as Paid"),
82 icon: <CreditCard className="w-4 h-4" />,
83 },
84 {
85 value: "partial",
86 label: () => __("Payment: Mark as Partially Paid"),
87 icon: <CircleDollarSign className="w-4 h-4" />,
88 },
89 {
90 value: "pending",
91 label: () => __("Payment: Mark as Pending"),
92 icon: <CircleDollarSign className="w-4 h-4" />,
93 },
94 {
95 value: "refunded",
96 label: () => __("Payment: Mark as Refunded"),
97 icon: <Undo2 className="w-4 h-4" />,
98 },
99 ];
100
101 const Bookings: React.FC = () => {
102 const [searchTerm, setSearchTerm] = useState("");
103 const [statusFilter, setStatusFilter] = useState("all");
104 const [paymentFilter, setPaymentFilter] = useState("all");
105 const [sortBy, setSortBy] = useState("booking_date");
106 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
107 const [page, setPage] = useState(1);
108 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
109 const [bulkAction, setBulkAction] = useState("");
110 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
111 const [isBulkPending, setIsBulkPending] = useState(false);
112 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
113 const [bookingToDelete, setBookingToDelete] = useState<Booking | null>(null);
114 const [visibleColumns, setVisibleColumns] = useState(() => {
115 if (typeof window === "undefined") {
116 return {
117 booking_number: true,
118 customer: true,
119 trip: true,
120 travelers: true,
121 booking_date: true,
122 travel_date: true,
123 amount: true,
124 payment_status: true,
125 booking_status: true,
126 };
127 }
128 const saved = window.localStorage.getItem("yatra-bookings-visible-columns");
129 return saved
130 ? JSON.parse(saved)
131 : {
132 booking_number: true,
133 customer: true,
134 trip: true,
135 travelers: true,
136 booking_date: true,
137 travel_date: true,
138 amount: true,
139 payment_status: true,
140 booking_status: true,
141 };
142 });
143 const queryClient = useQueryClient();
144 const { can } = usePermissions();
145 const { showToast } = useToast();
146 const defaultCurrency =
147 (window as any)?.yatraAdmin?.currency ||
148 (window as any)?.yatraBookingData?.currency ||
149 "USD";
150 const translations = (window as any)?.yatraAdmin?.translations || {};
151 const statsEnabled = can("yatra_view_bookings") !== false;
152
153 // Helper function for translations
154 const __ = (text: string) => translations[text] || text;
155
156 // Build query params
157 const BOOKINGS_PER_PAGE = 10;
158
159 const queryParams = useMemo(() => {
160 const params: Record<string, any> = {
161 page,
162 per_page: BOOKINGS_PER_PAGE,
163 orderby: sortBy,
164 order: sortOrder,
165 };
166
167 if (searchTerm) {
168 params.search = searchTerm;
169 }
170
171 if (statusFilter !== "all") {
172 params.status = statusFilter;
173 }
174
175 if (paymentFilter !== "all") {
176 params.payment_status = paymentFilter;
177 }
178
179 return params;
180 }, [searchTerm, statusFilter, paymentFilter, sortBy, sortOrder, page]);
181
182 // Fetch bookings from API
183 const canViewBookings = can("yatra_view_bookings") !== false; // default true when capability data missing
184
185 const { data, isLoading, error, refetch } = useQuery({
186 queryKey: ["bookings", queryParams],
187 queryFn: async () => {
188 // Check URL parameter for error simulation (for testing)
189 const urlParams = new URLSearchParams(window.location.search);
190 if (urlParams.get("simulate_error") === "true") {
191 throw new Error(
192 "Simulated API error for testing error UI functionality",
193 );
194 }
195
196 const params = new URLSearchParams();
197 params.append("page", String(queryParams.page));
198 params.append("per_page", String(queryParams.per_page));
199
200 if (queryParams.search) {
201 params.append("search", queryParams.search);
202 }
203 if (queryParams.status) {
204 params.append("status", queryParams.status);
205 }
206 if (queryParams.payment_status) {
207 params.append("payment_status", queryParams.payment_status);
208 }
209
210 return await apiService.getBookings({
211 page: queryParams.page,
212 per_page: queryParams.per_page,
213 search: queryParams.search,
214 status: queryParams.status,
215 payment_status: queryParams.payment_status,
216 });
217 },
218 enabled: canViewBookings,
219 });
220
221 // Fetch booking stats (counts)
222 const { data: statsRaw } = useQuery({
223 queryKey: ["bookings-stats"],
224 queryFn: async () => {
225 return await apiService.getBookingsStats();
226 },
227 enabled: statsEnabled,
228 });
229 const statsData: any = statsRaw ?? {};
230 const totalBookings = statsData?.all ?? 0;
231 const confirmedCount = statsData?.confirmed ?? 0;
232 const pendingCount = statsData?.pending ?? 0;
233 const cancelledCount = statsData?.cancelled ?? 0;
234 const completedCount = statsData?.completed ?? 0;
235
236 // Delete mutation
237 const deleteMutation = useMutation({
238 mutationFn: async (id: number) => {
239 await apiService.deleteBooking(id);
240 },
241 onSuccess: () => {
242 queryClient.invalidateQueries({ queryKey: ["bookings"] });
243 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
244 showToast(__("Booking deleted successfully"), "success");
245 },
246 onError: (error: any) => {
247 showToast(error?.message || __("Failed to delete booking"), "error");
248 },
249 });
250
251 const bookings: Booking[] = data?.data || [];
252 const listMeta = data?.meta ?? {};
253 const total = Number(listMeta.total ?? data?.total ?? 0);
254 const perPage =
255 Number(listMeta.per_page ?? data?.per_page ?? BOOKINGS_PER_PAGE) ||
256 BOOKINGS_PER_PAGE;
257 const totalPages = Math.max(
258 1,
259 Number(listMeta.total_pages) ||
260 (total > 0 ? Math.ceil(total / perPage) : 1),
261 );
262
263 // Enhanced error handling
264 const errorContext = getErrorContext(error);
265 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
266 const derivedErrorDetails =
267 errorContext.details ||
268 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
269 (error ? String(error?.message || error) : undefined);
270 const isBookingsError = !!error || !!apiErrorMessage;
271
272 const formatDate = (dateString: string) => {
273 return formatDateUtil(dateString);
274 };
275
276 const formatPrice = (price: number, currencyCode: string = defaultCurrency) =>
277 formatYatraMoney(Number(price) || 0, currencyCode, {
278 zeroAsUnknown: false,
279 });
280
281 const getBookingStatusBadge = (status: string) => {
282 const statusMap: Record<string, { className: string; label: string }> = {
283 confirmed: {
284 className:
285 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
286 label: __("Confirmed"),
287 },
288 pending: {
289 className:
290 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
291 label: __("Pending"),
292 },
293 // Guest-checkout holding state: row exists but the customer hasn't
294 // clicked the email-verification link yet, so the booking-created
295 // fan-out (admin notify + customer confirmation) is deferred.
296 // Visually amber-on-amber so it reads as "waiting on something
297 // outside our control" — distinct from the regular yellow "pending"
298 // (which means waiting on the operator).
299 pending_verification: {
300 className:
301 "bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300 border border-amber-200 dark:border-amber-800",
302 label: __("Awaiting email verification"),
303 },
304 cancelled: {
305 className:
306 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
307 label: __("Cancelled"),
308 },
309 completed: {
310 className:
311 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
312 label: __("Completed"),
313 },
314 };
315
316 const statusInfo = statusMap[status] || {
317 className:
318 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
319 label: status,
320 };
321
322 return (
323 <span
324 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
325 >
326 {statusInfo.label}
327 </span>
328 );
329 };
330
331 const getPaymentStatusBadge = (status: string) => {
332 const statusMap: Record<string, { className: string; label: string }> = {
333 paid: {
334 className:
335 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
336 label: __("Paid"),
337 },
338 pending: {
339 className:
340 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
341 label: __("Pending"),
342 },
343 partial: {
344 className:
345 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
346 label: __("Partial"),
347 },
348 refunded: {
349 className:
350 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
351 label: __("Refunded"),
352 },
353 };
354
355 const statusInfo = statusMap[status] || {
356 className:
357 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
358 label: status,
359 };
360
361 return (
362 <span
363 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
364 >
365 {statusInfo.label}
366 </span>
367 );
368 };
369
370 const handleEdit = (booking: Booking) => {
371 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${booking.id}`;
372 };
373
374 const handleDelete = (booking: Booking) => {
375 setBookingToDelete(booking);
376 setDeleteDialogOpen(true);
377 };
378
379 const confirmDelete = () => {
380 if (bookingToDelete) {
381 deleteMutation.mutate(bookingToDelete.id);
382 setDeleteDialogOpen(false);
383 setBookingToDelete(null);
384 }
385 };
386
387 const cancelDelete = () => {
388 setDeleteDialogOpen(false);
389 setBookingToDelete(null);
390 };
391
392 const handleView = (booking: Booking) => {
393 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${booking.id}`;
394 };
395
396 const handleCreateBooking = () => {
397 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=create`;
398 };
399
400 const handleResetFilters = () => {
401 setSearchTerm("");
402 setStatusFilter("all");
403 setPaymentFilter("all");
404 setSortBy("booking_date");
405 setSortOrder("desc");
406 setPage(1);
407 };
408
409 const handleSort = (field: string) => {
410 if (sortBy === field) {
411 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
412 } else {
413 setSortBy(field);
414 setSortOrder("desc");
415 }
416 };
417
418 const getSortIcon = (field: string) => {
419 if (sortBy !== field) {
420 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
421 }
422 return sortOrder === "asc" ? (
423 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
424 ) : (
425 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
426 );
427 };
428
429 const hasFilters =
430 searchTerm ||
431 statusFilter !== "all" ||
432 paymentFilter !== "all" ||
433 sortBy !== "booking_date" ||
434 sortOrder !== "desc";
435
436 const toggleColumn = (columnKey: string) => {
437 const newVisibleColumns = {
438 ...visibleColumns,
439 [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns],
440 };
441 setVisibleColumns(newVisibleColumns);
442 if (typeof window !== "undefined") {
443 window.localStorage.setItem(
444 "yatra-bookings-visible-columns",
445 JSON.stringify(newVisibleColumns),
446 );
447 }
448 };
449
450 const handleSelectItem = (id: string | number, checked: boolean) => {
451 setSelectedIds((prev) =>
452 checked ? [...prev, id] : prev.filter((selectedId) => selectedId !== id),
453 );
454 };
455
456 const handleSelectAll = (checked: boolean) => {
457 if (checked) {
458 setSelectedIds(bookings.map((booking: Booking) => booking.id));
459 } else {
460 setSelectedIds([]);
461 }
462 };
463
464 const isAllSelected =
465 bookings.length > 0 && selectedIds.length === bookings.length;
466
467 const updateStatusForIds = async (
468 ids: (string | number)[],
469 newStatus: string,
470 ) => {
471 await apiService.bulkUpdateStatus("bookings", ids, newStatus);
472 };
473
474 /**
475 * Set a booking's payment status from the actions menu.
476 *
477 * Marking a booking paid also settles its money fields server-side
478 * (BookingService::handlePaymentStatusChange), so the invoice reports the
479 * correct Amount Paid / Amount Due rather than staying at "Payment Pending".
480 */
481 const updatePaymentStatus = async (booking: Booking, status: string) => {
482 setIsBulkPending(true);
483 try {
484 await apiService.updateBooking(booking.id, { payment_status: status });
485 queryClient.invalidateQueries({ queryKey: ["bookings"] });
486 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
487 showToast(__("Payment status updated"), "success");
488 } catch (error: any) {
489 showToast(
490 error?.message || __("Failed to update payment status"),
491 "error",
492 );
493 } finally {
494 setIsBulkPending(false);
495 }
496 };
497
498 const handleBulkApply = async () => {
499 if (!bulkAction || selectedIds.length === 0) {
500 return;
501 }
502
503 try {
504 setIsBulkPending(true);
505
506 if (bulkAction === "delete") {
507 await apiService.bulkDelete("bookings", selectedIds);
508 showToast(__("Selected bookings deleted successfully"), "success");
509 } else {
510 await updateStatusForIds(selectedIds, bulkAction);
511 showToast(__("Bulk booking status updated successfully"), "success");
512 }
513
514 queryClient.invalidateQueries({ queryKey: ["bookings"] });
515 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
516 setSelectedIds([]);
517 setBulkAction("");
518 } catch (error: any) {
519 showToast(
520 __("Error: ") +
521 (error?.message || __("Failed to perform bulk action on bookings")),
522 "error",
523 );
524 } finally {
525 setIsBulkPending(false);
526 }
527 };
528
529 const allBulkActionOptions = [
530 { value: "confirmed", label: __("Mark as Confirmed") },
531 { value: "pending", label: __("Mark as Pending") },
532 { value: "cancelled", label: __("Mark as Cancelled") },
533 { value: "completed", label: __("Mark as Completed") },
534 { value: "delete", label: __("Delete permanently") },
535 ];
536
537 const getBulkActionOptionsForStatus = (view: string) => {
538 switch (view) {
539 case "confirmed":
540 return allBulkActionOptions.filter((opt) =>
541 ["pending", "cancelled", "completed", "delete"].includes(opt.value),
542 );
543 case "pending":
544 return allBulkActionOptions.filter((opt) =>
545 ["confirmed", "cancelled", "completed", "delete"].includes(opt.value),
546 );
547 case "cancelled":
548 return allBulkActionOptions.filter((opt) =>
549 ["pending", "confirmed", "delete"].includes(opt.value),
550 );
551 case "completed":
552 return allBulkActionOptions.filter((opt) =>
553 ["confirmed", "cancelled", "delete"].includes(opt.value),
554 );
555 default:
556 return allBulkActionOptions;
557 }
558 };
559
560 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
561
562 const statusOptions = [
563 {
564 value: "all",
565 label: __("All Status"),
566 count: statsData?.all ?? totalBookings,
567 },
568 { value: "confirmed", label: __("Confirmed"), count: confirmedCount },
569 { value: "pending", label: __("Pending"), count: pendingCount },
570 { value: "cancelled", label: __("Cancelled"), count: cancelledCount },
571 { value: "completed", label: __("Completed"), count: completedCount },
572 ];
573
574 const sortOptions = [
575 { value: "booking_date", label: __("Booking Date") },
576 { value: "travel_date", label: __("Travel Date") },
577 { value: "booking_number", label: __("Booking Number") },
578 { value: "customer_name", label: __("Customer") },
579 { value: "trip_title", label: __("Trip") },
580 { value: "total_amount", label: __("Amount") },
581 { value: "booking_status", label: __("Status") },
582 ];
583
584 const columnOptions = [
585 {
586 key: "booking_number",
587 label: __("Booking #"),
588 visible: visibleColumns.booking_number,
589 },
590 {
591 key: "customer",
592 label: __("Customer"),
593 visible: visibleColumns.customer,
594 },
595 { key: "trip", label: __("Trip"), visible: visibleColumns.trip },
596 {
597 key: "travelers",
598 label: __("Travelers"),
599 visible: visibleColumns.travelers,
600 },
601 {
602 key: "booking_date",
603 label: __("Booking Date"),
604 visible: visibleColumns.booking_date,
605 },
606 {
607 key: "travel_date",
608 label: __("Travel Date"),
609 visible: visibleColumns.travel_date,
610 },
611 { key: "amount", label: __("Amount"), visible: visibleColumns.amount },
612 {
613 key: "payment_status",
614 label: __("Payment"),
615 visible: visibleColumns.payment_status,
616 },
617 {
618 key: "booking_status",
619 label: __("Status"),
620 visible: visibleColumns.booking_status,
621 },
622 ];
623
624 const columns = [
625 {
626 key: "booking_number",
627 label: __("Booking #"),
628 sortable: true,
629 visible: visibleColumns.booking_number,
630 width: "w-[140px]",
631 render: (booking: Booking) => (
632 <button
633 type="button"
634 onClick={() => handleView(booking)}
635 className="text-sm font-mono font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline"
636 >
637 {booking.booking_number}
638 </button>
639 ),
640 },
641 {
642 key: "customer",
643 label: __("Customer"),
644 sortable: true,
645 visible: visibleColumns.customer,
646 render: (booking: Booking) => (
647 <div className="flex flex-col">
648 <span className="font-medium">
649 {booking.customer_name || __("Guest")}
650 </span>
651 <span className="text-xs text-muted-foreground">
652 {booking.customer_email}
653 </span>
654 </div>
655 ),
656 },
657 {
658 key: "trip",
659 label: __("Trip"),
660 sortable: true,
661 visible: visibleColumns.trip,
662 render: (booking: Booking) => (
663 <div className="flex flex-col">
664 <a
665 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${booking.trip_id}`}
666 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
667 >
668 {booking.trip_title}
669 </a>
670 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
671 {booking.trip_id ? `(ID: ${booking.trip_id})` : ""}
672 </span>
673 </div>
674 ),
675 },
676 {
677 key: "travelers",
678 label: __("Travelers"),
679 sortable: true,
680 visible: visibleColumns.travelers,
681 render: (booking: Booking) => (
682 <span className="font-medium">
683 {booking.travelers ?? booking.travelers_count ?? 0}
684 </span>
685 ),
686 },
687 {
688 key: "booking_date",
689 label: __("Booking Date"),
690 sortable: true,
691 visible: visibleColumns.booking_date,
692 render: (booking: Booking) => (
693 <span>{formatDate(booking.booking_date)}</span>
694 ),
695 },
696 {
697 key: "travel_date",
698 label: __("Travel Date"),
699 sortable: true,
700 visible: visibleColumns.travel_date,
701 render: (booking: Booking) => (
702 <span>{formatDate(booking.travel_date)}</span>
703 ),
704 },
705 {
706 key: "amount",
707 label: __("Amount"),
708 sortable: true,
709 visible: visibleColumns.amount,
710 render: (booking: Booking) => (
711 <div className="flex flex-col">
712 <span className="font-semibold">
713 {formatPrice(booking.total_amount, booking.currency)}
714 </span>
715 {booking.amount_paid ? (
716 <span className="text-xs text-muted-foreground">
717 {__("Paid: ")}
718 {formatPrice(booking.amount_paid, booking.currency)}
719 </span>
720 ) : null}
721 </div>
722 ),
723 },
724 {
725 key: "payment_status",
726 label: __("Payment"),
727 sortable: true,
728 visible: visibleColumns.payment_status,
729 render: (booking: Booking) =>
730 getPaymentStatusBadge(booking.payment_status),
731 },
732 {
733 key: "booking_status",
734 label: __("Status"),
735 sortable: true,
736 visible: visibleColumns.booking_status,
737 render: (booking: Booking) =>
738 getBookingStatusBadge(booking.booking_status),
739 },
740 ];
741
742 const actions = [
743 {
744 key: "view",
745 label: __("View"),
746 icon: <Eye className="w-4 h-4" />,
747 onClick: (booking: Booking) => handleView(booking),
748 condition: () => can("yatra_view_bookings"),
749 },
750 {
751 key: "edit",
752 label: __("Edit"),
753 icon: <Edit className="w-4 h-4" />,
754 onClick: (booking: Booking) => handleEdit(booking),
755 condition: () => can("yatra_edit_bookings"),
756 },
757 {
758 key: "mark_confirmed",
759 label: __("Mark as Confirmed"),
760 icon: <ArrowDown className="w-4 h-4" />,
761 onClick: async (booking: Booking) => {
762 setIsBulkPending(true);
763 try {
764 await updateStatusForIds([booking.id], "confirmed");
765 queryClient.invalidateQueries({ queryKey: ["bookings"] });
766 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
767 showToast(__("Booking status updated"), "success");
768 } catch (error: any) {
769 showToast(
770 error?.message || __("Failed to update booking status"),
771 "error",
772 );
773 } finally {
774 setIsBulkPending(false);
775 }
776 },
777 condition: (booking: Booking) =>
778 can("yatra_edit_bookings") && booking.booking_status !== "confirmed",
779 },
780 {
781 key: "mark_pending",
782 label: __("Mark as Pending"),
783 icon: <ArrowUp className="w-4 h-4" />,
784 onClick: async (booking: Booking) => {
785 setIsBulkPending(true);
786 try {
787 await updateStatusForIds([booking.id], "pending");
788 queryClient.invalidateQueries({ queryKey: ["bookings"] });
789 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
790 showToast(__("Booking status updated"), "success");
791 } catch (error: any) {
792 showToast(
793 error?.message || __("Failed to update booking status"),
794 "error",
795 );
796 } finally {
797 setIsBulkPending(false);
798 }
799 },
800 condition: (booking: Booking) =>
801 can("yatra_edit_bookings") && booking.booking_status !== "pending",
802 },
803 {
804 key: "mark_cancelled",
805 label: __("Mark as Cancelled"),
806 icon: <ArrowDown className="w-4 h-4" />,
807 onClick: async (booking: Booking) => {
808 setIsBulkPending(true);
809 try {
810 await updateStatusForIds([booking.id], "cancelled");
811 queryClient.invalidateQueries({ queryKey: ["bookings"] });
812 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
813 showToast(__("Booking status updated"), "success");
814 } catch (error: any) {
815 showToast(
816 error?.message || __("Failed to update booking status"),
817 "error",
818 );
819 } finally {
820 setIsBulkPending(false);
821 }
822 },
823 condition: (booking: Booking) =>
824 can("yatra_edit_bookings") && booking.booking_status !== "cancelled",
825 },
826 {
827 key: "mark_completed",
828 label: __("Mark as Completed"),
829 icon: <ArrowDown className="w-4 h-4" />,
830 onClick: async (booking: Booking) => {
831 setIsBulkPending(true);
832 try {
833 await updateStatusForIds([booking.id], "completed");
834 queryClient.invalidateQueries({ queryKey: ["bookings"] });
835 queryClient.invalidateQueries({ queryKey: ["bookings-stats"] });
836 showToast(__("Booking status updated"), "success");
837 } catch (error: any) {
838 showToast(
839 error?.message || __("Failed to update booking status"),
840 "error",
841 );
842 } finally {
843 setIsBulkPending(false);
844 }
845 },
846 condition: (booking: Booking) =>
847 can("yatra_edit_bookings") && booking.booking_status !== "completed",
848 },
849 ...PAYMENT_STATUS_ACTIONS.map((action) => ({
850 key: `payment_${action.value}`,
851 label: action.label(),
852 icon: action.icon,
853 onClick: (booking: Booking) => updatePaymentStatus(booking, action.value),
854 // Hidden when the booking is already in that payment state, matching how
855 // the booking-status actions above behave.
856 condition: (booking: Booking) =>
857 can("yatra_edit_bookings") && booking.payment_status !== action.value,
858 })),
859 {
860 key: "delete",
861 label: __("Delete"),
862 icon: <Trash2 className="w-4 h-4" />,
863 onClick: (booking: Booking) => handleDelete(booking),
864 variant: "destructive" as const,
865 condition: () => can("yatra_delete_bookings"),
866 },
867 ];
868
869 return (
870 <div className="space-y-3">
871 <PageHeader
872 title={__("Bookings")}
873 description={__("Manage customer bookings and reservations")}
874 actions={
875 <div className="flex items-center gap-2">
876 <Button
877 onClick={handleCreateBooking}
878 className="flex items-center gap-2"
879 >
880 <Plus className="w-4 h-4" />
881 {__("Create Booking")}
882 </Button>
883 </div>
884 }
885 />
886
887 {/* Stats summary */}
888 {statsData && (
889 <div className="grid grid-cols-1 md:grid-cols-4 gap-3">
890 <Card>
891 <CardContent className="p-4">
892 <div className="text-sm text-muted-foreground">
893 {__("Total Bookings")}
894 </div>
895 <div className="text-2xl font-semibold">{totalBookings}</div>
896 </CardContent>
897 </Card>
898 <Card>
899 <CardContent className="p-4">
900 <div className="text-sm text-muted-foreground">
901 {__("Confirmed")}
902 </div>
903 <div className="text-2xl font-semibold">{confirmedCount}</div>
904 </CardContent>
905 </Card>
906 <Card>
907 <CardContent className="p-4">
908 <div className="text-sm text-muted-foreground">
909 {__("Pending")}
910 </div>
911 <div className="text-2xl font-semibold">{pendingCount}</div>
912 </CardContent>
913 </Card>
914 <Card>
915 <CardContent className="p-4">
916 <div className="text-sm text-muted-foreground">
917 {__("Cancelled")}
918 </div>
919 <div className="text-2xl font-semibold">{cancelledCount}</div>
920 </CardContent>
921 </Card>
922 </div>
923 )}
924
925 <Card>
926 <CardContent className="p-4">
927 <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center w-full">
928 <div className="flex-1 min-w-0">
929 <SearchFilterToolbar
930 searchTerm={searchTerm}
931 onSearchChange={setSearchTerm}
932 statusFilter={statusFilter}
933 onStatusChange={(value) => {
934 setStatusFilter(value);
935 setPage(1);
936 }}
937 statusOptions={statusOptions}
938 sortBy={sortBy}
939 onSortByChange={(field) => {
940 setSortBy(field);
941 setPage(1);
942 }}
943 sortOrder={sortOrder}
944 onSortOrderChange={(order) => {
945 setSortOrder(order);
946 setPage(1);
947 }}
948 sortOptions={sortOptions}
949 onResetFilters={handleResetFilters}
950 hasFilters={!!hasFilters}
951 placeholder={__("Search bookings...")}
952 />
953 </div>
954
955 {/* Payment Status Filter */}
956 <div className="w-full lg:w-auto lg:min-w-[160px]">
957 <Select
958 value={paymentFilter}
959 onChange={(e) => {
960 setPaymentFilter(e.target.value);
961 setPage(1);
962 }}
963 className="w-full"
964 >
965 <option value="all">{__("All Payments")}</option>
966 <option value="paid">{__("Paid")}</option>
967 <option value="pending">{__("Pending")}</option>
968 <option value="partial">{__("Partial")}</option>
969 <option value="refunded">{__("Refunded")}</option>
970 </Select>
971 </div>
972 </div>
973 </CardContent>
974 </Card>
975
976 <ConditionalRender capability="yatra_view_bookings">
977 <>
978 <BulkActionToolbar
979 selectedIds={selectedIds}
980 bulkAction={bulkAction}
981 setBulkAction={setBulkAction}
982 onApply={handleBulkApply}
983 onClearSelection={() => setSelectedIds([])}
984 statusFilter={statusFilter}
985 setStatusFilter={(value) => {
986 setStatusFilter(value);
987 setPage(1);
988 }}
989 statusOptions={[
990 {
991 key: "all",
992 label: __("All"),
993 count: statsData?.all ?? totalBookings,
994 },
995 {
996 key: "confirmed",
997 label: __("Confirmed"),
998 count: confirmedCount,
999 },
1000 { key: "pending", label: __("Pending"), count: pendingCount },
1001 {
1002 key: "cancelled",
1003 label: __("Cancelled"),
1004 count: cancelledCount,
1005 },
1006 {
1007 key: "completed",
1008 label: __("Completed"),
1009 count: completedCount,
1010 },
1011 ]}
1012 showColumnsDropdown={showColumnsDropdown}
1013 setShowColumnsDropdown={setShowColumnsDropdown}
1014 columnOptions={columnOptions}
1015 onToggleColumn={toggleColumn}
1016 bulkMutationPending={isBulkPending}
1017 totalItems={total}
1018 bulkActionOptions={bulkActionOptions}
1019 />
1020
1021 <Card>
1022 <CardContent className="p-0">
1023 <SharedTable
1024 data={bookings}
1025 columns={columns}
1026 actions={actions}
1027 isLoading={isLoading}
1028 isError={isBookingsError}
1029 errorText={__("Error loading bookings")}
1030 errorDescription={__(
1031 "We couldn't connect to the bookings service. Please refresh or try again shortly.",
1032 )}
1033 errorDetails={derivedErrorDetails}
1034 errorRequestInfo={errorContext.requestInfo}
1035 onRetry={() => refetch()}
1036 emptyText={__("No bookings found")}
1037 emptyDescription={
1038 hasFilters
1039 ? __("Try adjusting your filters to see more results.")
1040 : __("Get started by creating your first booking.")
1041 }
1042 onCreateClick={
1043 can("yatra_edit_bookings") ? handleCreateBooking : undefined
1044 }
1045 onSort={handleSort}
1046 getSortIcon={getSortIcon}
1047 selectedItemIds={selectedIds}
1048 onSelectItem={handleSelectItem}
1049 onSelectAll={handleSelectAll}
1050 isAllSelected={isAllSelected}
1051 getItemId={(booking: Booking) => booking.id}
1052 skeletonRows={5}
1053 capability="yatra_view_bookings"
1054 />
1055 </CardContent>
1056 </Card>
1057
1058 {total > 0 && (
1059 <div className="mt-4">
1060 <Pagination
1061 currentPage={page}
1062 totalPages={totalPages}
1063 totalItems={total}
1064 itemsPerPage={perPage}
1065 onPageChange={(newPage) => setPage(newPage)}
1066 itemName={__("bookings")}
1067 />
1068 </div>
1069 )}
1070 </>
1071 </ConditionalRender>
1072
1073 {/* Delete Confirmation Dialog */}
1074 <ConfirmationDialog
1075 isOpen={deleteDialogOpen}
1076 onClose={cancelDelete}
1077 onConfirm={confirmDelete}
1078 title={__("Delete Booking")}
1079 description={
1080 bookingToDelete
1081 ? __(
1082 `Are you sure you want to delete booking "${bookingToDelete.booking_number}"? This action cannot be undone.`,
1083 )
1084 : __("Are you sure you want to delete this booking?")
1085 }
1086 confirmText={__("Delete")}
1087 cancelText={__("Cancel")}
1088 variant="danger"
1089 isLoading={deleteMutation.isPending}
1090 icon={<AlertTriangle className="w-6 h-6 text-red-500" />}
1091 />
1092 </div>
1093 );
1094 };
1095
1096 export default Bookings;
1097