| @@ -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"), |