PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/Bookings.tsx +197 -19 3.0.3 → 3.0.17 View file →
@@ -13,8 +13,13 @@
13 13 Eye,
14 14 Edit,
15 15 Trash2,
16 16 AlertTriangle,
17 + CreditCard,
18 + CircleDollarSign,
19 + Undo2,
20 + Mail,
21 + Download,
17 22 } from "lucide-react";
18 23 import {
19 24 Pagination,
20 25 SearchFilterToolbar,
@@ -21,8 +26,13 @@
21 26 BulkActionToolbar,
22 27 Table as SharedTable,
23 28 } from "../components/shared";
24 29 import { __ } from "../lib/i18n";
30 +import { downloadAdminBookingInvoice } from "../lib/invoice-download";
31 +import {
32 + buildYatraSinglePublicUrls,
33 + isWordPressPlainPermalink,
34 +} from "../lib/frontend-permalink-urls";
25 35 import { useToast } from "../components/ui/toast";
26 36 import { Button } from "../components/ui/button";
27 37 import { Select } from "../components/ui/select";
28 38 import { PageHeader } from "../components/common/PageHeader";
@@ -29,9 +39,9 @@
29 39 import { Card, CardContent } from "../components/ui/card";
30 40 import { ConditionalRender } from "../components/ui/conditional-render";
31 41 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
32 42 import { formatYatraMoney } from "../lib/currency-display";
33 -import { apiService } from "../lib/api-client";
43 +import { apiService, apiClient } from "../lib/api-client";
34 44 import { formatDate as formatDateUtil } from "../lib/dateFormat";
35 45 import { getErrorContext } from "../lib/errors";
36 46 import { usePermissions } from "../hooks/usePermissions";
37 47
@@ -41,8 +51,9 @@
41 51 customer_name: string;
42 52 customer_email: string;
43 53 trip_title: string;
44 54 trip_id: number;
55 + trip_slug?: string;
45 56 booking_date: string;
46 57 travel_date: string;
47 58 travelers?: number;
48 59 travelers_count?: number;
@@ -55,8 +66,46 @@
55 66 payment_gateway?: string;
56 67 created_at: string;
57 68 }
58 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 +
59 108 const Bookings: React.FC = () => {
60 109 const [searchTerm, setSearchTerm] = useState("");
61 110 const [statusFilter, setStatusFilter] = useState("all");
62 111 const [paymentFilter, setPaymentFilter] = useState("all");
@@ -170,8 +219,11 @@
170 219 per_page: queryParams.per_page,
171 220 search: queryParams.search,
172 221 status: queryParams.status,
173 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,
174 226 });
175 227 },
176 228 enabled: canViewBookings,
177 229 });
@@ -230,13 +282,12 @@
230 282 const formatDate = (dateString: string) => {
231 283 return formatDateUtil(dateString);
232 284 };
233 285
234 - const formatPrice = (
235 - price: number,
236 - currencyCode: string = defaultCurrency,
237 - ) =>
238 - formatYatraMoney(Number(price) || 0, currencyCode, { zeroAsUnknown: false });
286 + const formatPrice = (price: number, currencyCode: string = defaultCurrency) =>
287 + formatYatraMoney(Number(price) || 0, currencyCode, {
288 + zeroAsUnknown: false,
289 + });
239 290
240 291 const getBookingStatusBadge = (status: string) => {
241 292 const statusMap: Record<string, { className: string; label: string }> = {
242 293 confirmed: {
@@ -248,8 +299,19 @@
248 299 className:
249 300 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
250 301 label: __("Pending"),
251 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 + },
252 314 cancelled: {
253 315 className:
254 316 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
255 317 label: __("Cancelled"),
@@ -418,8 +480,32 @@
418 480 ) => {
419 481 await apiService.bulkUpdateStatus("bookings", ids, newStatus);
420 482 };
421 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 +
422 508 const handleBulkApply = async () => {
423 509 if (!bulkAction || selectedIds.length === 0) {
424 510 return;
425 511 }
@@ -582,21 +668,45 @@
582 668 key: "trip",
583 669 label: __("Trip"),
584 670 sortable: true,
585 671 visible: visibleColumns.trip,
586 - render: (booking: Booking) => (
587 - <div className="flex flex-col">
588 - <a
589 - href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${booking.trip_id}`}
590 - className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
591 - >
592 - {booking.trip_title}
593 - </a>
594 - <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
595 - {booking.trip_id ? `(ID: ${booking.trip_id})` : ""}
596 - </span>
597 - </div>
598 - ),
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 + },
599 709 },
600 710 {
601 711 key: "travelers",
602 712 label: __("Travelers"),
@@ -662,8 +772,27 @@
662 772 getBookingStatusBadge(booking.booking_status),
663 773 },
664 774 ];
665 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 +
666 795 const actions = [
667 796 {
668 797 key: "view",
669 798 label: __("View"),
@@ -768,8 +897,57 @@
768 897 }
769 898 },
770 899 condition: (booking: Booking) =>
771 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,
772 950 },
773 951 {
774 952 key: "delete",
775 953 label: __("Delete"),