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 / Bookings.tsx

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

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