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 +182 -14 3.0.8 → 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 });
@@ -428,8 +480,32 @@
428 480 ) => {
429 481 await apiService.bulkUpdateStatus("bookings", ids, newStatus);
430 482 };
431 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 +
432 508 const handleBulkApply = async () => {
433 509 if (!bulkAction || selectedIds.length === 0) {
434 510 return;
435 511 }
@@ -592,21 +668,45 @@
592 668 key: "trip",
593 669 label: __("Trip"),
594 670 sortable: true,
595 671 visible: visibleColumns.trip,
596 - render: (booking: Booking) => (
597 - <div className="flex flex-col">
598 - <a
599 - href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${booking.trip_id}`}
600 - className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
601 - >
602 - {booking.trip_title}
603 - </a>
604 - <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
605 - {booking.trip_id ? `(ID: ${booking.trip_id})` : ""}
606 - </span>
607 - </div>
608 - ),
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 + },
609 709 },
610 710 {
611 711 key: "travelers",
612 712 label: __("Travelers"),
@@ -672,8 +772,27 @@
672 772 getBookingStatusBadge(booking.booking_status),
673 773 },
674 774 ];
675 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 +
676 795 const actions = [
677 796 {
678 797 key: "view",
679 798 label: __("View"),
@@ -778,8 +897,57 @@
778 897 }
779 898 },
780 899 condition: (booking: Booking) =>
781 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,
782 950 },
783 951 {
784 952 key: "delete",
785 953 label: __("Delete"),