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/ViewBooking.tsx +202 -43 3.0.14.2 → 3.0.17 View file →
@@ -2,11 +2,12 @@
2 2 * View Booking Page
3 3 * Display booking details in a clean, minimal SaaS-style design
4 4 */
5 5
6 -import React, { useMemo } from "react";
6 +import React, { useMemo, useState } from "react";
7 7 import { useQuery } from "@tanstack/react-query";
8 8 import {
9 + Download,
9 10 ArrowLeft,
10 11 Mail,
11 12 Phone,
12 13 Calendar,
@@ -35,8 +36,9 @@
35 36 CardTitle,
36 37 } from "../components/ui/card";
37 38 import { ConditionalRender } from "../components/ui/conditional-render";
38 39 import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu";
40 +import { downloadAdminBookingInvoice } from "../lib/invoice-download";
39 41 import { Skeleton } from "../components/ui/skeleton";
40 42 import { formatYatraMoney } from "../lib/currency-display";
41 43
42 44 interface GoogleCalendarSyncInfo {
@@ -55,10 +57,28 @@
55 57 label: string;
56 58 enabled: boolean;
57 59 order: number;
58 60 section?: string;
61 + options?: { value: string; label: string }[];
59 62 }
60 63
64 +/**
65 + * Dropdown answers are stored as the option VALUE (e.g. "spouse"); show the
66 + * option's label from the form the trip used ("Spouse/Partner") when we have
67 + * it, otherwise the raw value as before.
68 + */
69 +const optionLabel = (
70 + field: FormFieldConfig | undefined,
71 + value: unknown,
72 +): string => {
73 + const raw = String(value ?? "");
74 + if (field?.type === "select" && Array.isArray(field.options)) {
75 + const match = field.options.find((o) => String(o.value) === raw);
76 + if (match?.label) return match.label;
77 + }
78 + return raw;
79 +};
80 +
61 81 interface FormSectionConfig {
62 82 title: string;
63 83 enabled: boolean;
64 84 fields: FormFieldConfig[];
@@ -78,46 +98,8 @@
78 98 const params = new URLSearchParams(window.location.search);
79 99 return params.get("id") ? parseInt(params.get("id") || "0") : null;
80 100 }, []);
81 101
82 - // Fetch booking form configuration for dynamic field labels
83 - const { data: formConfig } = useQuery<BookingFormConfig>({
84 - queryKey: ["booking-form-config"],
85 - queryFn: async () => {
86 - const response = await apiService.getSettings();
87 - return (
88 - response?.data?.booking_form_config ||
89 - response?.booking_form_config ||
90 - null
91 - );
92 - },
93 - });
94 -
95 - // Get enabled traveler fields from config
96 - const travelerFields = useMemo(() => {
97 - if (!formConfig?.traveler_form?.fields) return [];
98 - return formConfig.traveler_form.fields
99 - .filter((field) => field.enabled)
100 - .sort((a, b) => a.order - b.order);
101 - }, [formConfig]);
102 -
103 - // Get enabled emergency contact fields
104 - const emergencyFields = useMemo(() => {
105 - if (!formConfig?.emergency_contact_form?.fields) return [];
106 - return formConfig.emergency_contact_form.fields
107 - .filter((field) => field.enabled)
108 - .sort((a, b) => a.order - b.order);
109 - }, [formConfig]);
110 -
111 - // Get enabled contact (lead traveler) fields — used to label and format the
112 - // country / nationality / address / custom contact fields in the summary.
113 - const contactFields = useMemo(() => {
114 - if (!formConfig?.contact_form?.fields) return [];
115 - return formConfig.contact_form.fields
116 - .filter((field) => field.enabled)
117 - .sort((a, b) => a.order - b.order);
118 - }, [formConfig]);
119 -
120 102 // Helper to get field label by ID
121 103 const getFieldLabel = (
122 104 fieldId: string,
123 105 fields: FormFieldConfig[],
@@ -221,8 +203,50 @@
221 203 },
222 204 enabled: !!bookingId && can("yatra_view_bookings"),
223 205 });
224 206
207 + // Fetch booking form configuration for dynamic field labels, resolved for
208 + // this booking's trip so per-trip form versions (Pro conditions) label the
209 + // fields the customer actually saw. Waits for the booking to know its trip.
210 + const bookingTripId = booking?.trip_id ? Number(booking.trip_id) : null;
211 + const { data: formConfig } = useQuery<BookingFormConfig>({
212 + queryKey: ["booking-form-config", bookingTripId],
213 + queryFn: async () => {
214 + const response = await apiService.getBookingFormConfig(bookingTripId);
215 + return (
216 + response?.data?.booking_form_config ||
217 + response?.booking_form_config ||
218 + null
219 + );
220 + },
221 + enabled: !!booking,
222 + });
223 +
224 + // Get enabled traveler fields from config
225 + const travelerFields = useMemo(() => {
226 + if (!formConfig?.traveler_form?.fields) return [];
227 + return formConfig.traveler_form.fields
228 + .filter((field) => field.enabled)
229 + .sort((a, b) => a.order - b.order);
230 + }, [formConfig]);
231 +
232 + // Get enabled emergency contact fields
233 + const emergencyFields = useMemo(() => {
234 + if (!formConfig?.emergency_contact_form?.fields) return [];
235 + return formConfig.emergency_contact_form.fields
236 + .filter((field) => field.enabled)
237 + .sort((a, b) => a.order - b.order);
238 + }, [formConfig]);
239 +
240 + // Get enabled contact (lead traveler) fields — used to label and format the
241 + // country / nationality / address / custom contact fields in the summary.
242 + const contactFields = useMemo(() => {
243 + if (!formConfig?.contact_form?.fields) return [];
244 + return formConfig.contact_form.fields
245 + .filter((field) => field.enabled)
246 + .sort((a, b) => a.order - b.order);
247 + }, [formConfig]);
248 +
225 249 // Fetch consent status for this booking (only if Pro is active)
226 250 const isPro = !!(window as any).yatraAdmin?.isPro;
227 251 const { data: consentStatus } = useQuery({
228 252 queryKey: ["booking-consent-status", bookingId],
@@ -330,8 +354,29 @@
330 354 const handleEdit = () => {
331 355 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`;
332 356 };
333 357
358 + // The booking invoice works for unpaid bookings too — it renders as a
359 + // pro-forma invoice with the gateway's payment instructions. This page has
360 + // no toast provider, so a failure is rendered as an inline banner under the
361 + // header; a tooltip alone would leave a failed download looking like nothing
362 + // happened.
363 + const [invoiceError, setInvoiceError] = useState<string>("");
364 + const handleDownloadInvoice = async () => {
365 + const id = Number(booking?.id ?? 0);
366 + if (!id) {
367 + return;
368 + }
369 + setInvoiceError("");
370 + try {
371 + await downloadAdminBookingInvoice(id);
372 + } catch (error: any) {
373 + setInvoiceError(
374 + error?.message || __("Failed to download invoice", "yatra"),
375 + );
376 + }
377 + };
378 +
334 379 if (isLoading) {
335 380 return (
336 381 <div className="space-y-3">
337 382 {/* Header Skeleton */}
@@ -481,8 +526,18 @@
481 526 title={__("Booking Details", "yatra")}
482 527 description={__("View complete booking information", "yatra")}
483 528 actions={
484 529 <div className="flex gap-2">
530 + <ConditionalRender capability="yatra_view_bookings">
531 + <Button
532 + variant="outline"
533 + onClick={handleDownloadInvoice}
534 + className="flex items-center gap-2"
535 + >
536 + <Download className="w-4 h-4" />
537 + {__("Download invoice", "yatra")}
538 + </Button>
539 + </ConditionalRender>
485 540 <ConditionalRender capability="yatra_edit_bookings">
486 541 <ResendEmailMenu
487 542 bookingId={Number(booking.id)}
488 543 status={booking.booking_status}
@@ -505,8 +560,18 @@
505 560 </div>
506 561 }
507 562 />
508 563
564 + {invoiceError && (
565 + <div
566 + role="alert"
567 + className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
568 + >
569 + <AlertCircle className="w-4 h-4 shrink-0" />
570 + {invoiceError}
571 + </div>
572 + )}
573 +
509 574 <ConditionalRender capability="yatra_view_bookings">
510 575 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
511 576 {/* Main Content */}
512 577 <div className="lg:col-span-2 space-y-3">
@@ -894,8 +959,81 @@
894 959 </div>
895 960 </CardContent>
896 961 </Card>
897 962
963 + {/* Payments recorded against this booking (gateway captures and
964 + manual entries). Only completed rows count towards Amount
965 + Paid — a pending row is money expected, not received. */}
966 + {Array.isArray(booking.payments) && booking.payments.length > 0 && (
967 + <Card data-testid="booking-payments-card">
968 + <CardHeader className="pb-2">
969 + <CardTitle className="text-base">
970 + {__("Payments", "yatra")}
971 + </CardTitle>
972 + </CardHeader>
973 + <CardContent>
974 + <div className="divide-y divide-gray-100 dark:divide-gray-800">
975 + {booking.payments.map((payment: any) => {
976 + const status = String(
977 + payment.status || payment.payment_status || "pending",
978 + );
979 + const statusClass =
980 + status === "completed"
981 + ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"
982 + : status === "pending"
983 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400"
984 + : status === "refunded"
985 + ? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400"
986 + : "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400";
987 + const statusLabel: Record<string, string> = {
988 + completed: __("Completed", "yatra"),
989 + pending: __("Pending", "yatra"),
990 + failed: __("Failed", "yatra"),
991 + refunded: __("Refunded", "yatra"),
992 + cancelled: __("Cancelled", "yatra"),
993 + };
994 + return (
995 + <div
996 + key={payment.id}
997 + className="py-2 flex items-start justify-between gap-3 text-sm"
998 + >
999 + <div className="min-w-0">
1000 + <div className="font-medium text-gray-900 dark:text-white">
1001 + {formatPrice(
1002 + Number(payment.amount || 0),
1003 + payment.currency || booking.currency,
1004 + )}
1005 + <span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
1006 + {payment.payment_method_label ||
1007 + payment.gateway ||
1008 + ""}
1009 + </span>
1010 + </div>
1011 + <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
1012 + {payment.payment_date
1013 + ? formatDate(payment.payment_date)
1014 + : ""}
1015 + {payment.transaction_id
1016 + ? ` · ${payment.transaction_id}`
1017 + : ""}
1018 + {payment.payment_number
1019 + ? ` · ${payment.payment_number}`
1020 + : ""}
1021 + </div>
1022 + </div>
1023 + <span
1024 + className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium whitespace-nowrap ${statusClass}`}
1025 + >
1026 + {statusLabel[status] || status}
1027 + </span>
1028 + </div>
1029 + );
1030 + })}
1031 + </div>
1032 + </CardContent>
1033 + </Card>
1034 + )}
1035 +
898 1036 {/* Customer Information */}
899 1037 <Card>
900 1038 <CardHeader className="pb-2">
901 1039 <CardTitle className="text-base">
@@ -960,9 +1098,9 @@
960 1098 );
961 1099 const display =
962 1100 field?.type === "country"
963 1101 ? getCountryName(String(value))
964 - : String(value);
1102 + : optionLabel(field, value);
965 1103 return (
966 1104 <div key={fieldId}>
967 1105 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
968 1106 {getFieldLabel(fieldId, contactFields)}
@@ -1110,8 +1248,20 @@
1110 1248 ) {
1111 1249 displayValue = getCountryName(fieldValue);
1112 1250 }
1113 1251
1252 + // Dropdown answers: option label instead of value.
1253 + // The label is the operator's own text, so it is
1254 + // shown as written (no title-casing).
1255 + let asWritten = false;
1256 + if (fieldConfig?.type === "select") {
1257 + displayValue = optionLabel(
1258 + fieldConfig,
1259 + fieldValue,
1260 + );
1261 + asWritten = displayValue !== String(fieldValue);
1262 + }
1263 +
1114 1264 return (
1115 1265 <div
1116 1266 key={fieldId}
1117 1267 className={
@@ -1123,9 +1273,9 @@
1123 1273 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1124 1274 {label}
1125 1275 </div>
1126 1276 <div
1127 - className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""} capitalize`}
1277 + className={`text-sm text-gray-900 dark:text-white ${fieldId === "passport" ? "font-mono" : ""} ${asWritten ? "" : "capitalize"}`}
1128 1278 >
1129 1279 {displayValue}
1130 1280 </div>
1131 1281 </div>
@@ -1169,15 +1319,24 @@
1169 1319 ([_, value]) => value && String(value).trim() !== "",
1170 1320 )
1171 1321 .map(([fieldId, fieldValue]) => {
1172 1322 const label = getFieldLabel(fieldId, emergencyFields);
1323 + const emergencyField = emergencyFields.find(
1324 + (f) => f.id === fieldId,
1325 + );
1326 + const emergencyDisplay = optionLabel(
1327 + emergencyField,
1328 + fieldValue,
1329 + );
1173 1330 return (
1174 1331 <div key={fieldId}>
1175 1332 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1176 1333 {label}
1177 1334 </div>
1178 - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize">
1179 - {String(fieldValue)}
1335 + <div
1336 + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`}
1337 + >
1338 + {emergencyDisplay}
1180 1339 </div>
1181 1340 </div>
1182 1341 );
1183 1342 })}