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
← All changes | resources/js/pages/ViewBooking.tsx +210 -43 3.0.13 → 3.0.16 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,
@@ -34,8 +35,10 @@
34 35 CardHeader,
35 36 CardTitle,
36 37 } from "../components/ui/card";
37 38 import { ConditionalRender } from "../components/ui/conditional-render";
39 +import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu";
40 +import { downloadAdminBookingInvoice } from "../lib/invoice-download";
38 41 import { Skeleton } from "../components/ui/skeleton";
39 42 import { formatYatraMoney } from "../lib/currency-display";
40 43
41 44 interface GoogleCalendarSyncInfo {
@@ -54,10 +57,28 @@
54 57 label: string;
55 58 enabled: boolean;
56 59 order: number;
57 60 section?: string;
61 + options?: { value: string; label: string }[];
58 62 }
59 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 +
60 81 interface FormSectionConfig {
61 82 title: string;
62 83 enabled: boolean;
63 84 fields: FormFieldConfig[];
@@ -77,46 +98,8 @@
77 98 const params = new URLSearchParams(window.location.search);
78 99 return params.get("id") ? parseInt(params.get("id") || "0") : null;
79 100 }, []);
80 101
81 - // Fetch booking form configuration for dynamic field labels
82 - const { data: formConfig } = useQuery<BookingFormConfig>({
83 - queryKey: ["booking-form-config"],
84 - queryFn: async () => {
85 - const response = await apiService.getSettings();
86 - return (
87 - response?.data?.booking_form_config ||
88 - response?.booking_form_config ||
89 - null
90 - );
91 - },
92 - });
93 -
94 - // Get enabled traveler fields from config
95 - const travelerFields = useMemo(() => {
96 - if (!formConfig?.traveler_form?.fields) return [];
97 - return formConfig.traveler_form.fields
98 - .filter((field) => field.enabled)
99 - .sort((a, b) => a.order - b.order);
100 - }, [formConfig]);
101 -
102 - // Get enabled emergency contact fields
103 - const emergencyFields = useMemo(() => {
104 - if (!formConfig?.emergency_contact_form?.fields) return [];
105 - return formConfig.emergency_contact_form.fields
106 - .filter((field) => field.enabled)
107 - .sort((a, b) => a.order - b.order);
108 - }, [formConfig]);
109 -
110 - // Get enabled contact (lead traveler) fields — used to label and format the
111 - // country / nationality / address / custom contact fields in the summary.
112 - const contactFields = useMemo(() => {
113 - if (!formConfig?.contact_form?.fields) return [];
114 - return formConfig.contact_form.fields
115 - .filter((field) => field.enabled)
116 - .sort((a, b) => a.order - b.order);
117 - }, [formConfig]);
118 -
119 102 // Helper to get field label by ID
120 103 const getFieldLabel = (
121 104 fieldId: string,
122 105 fields: FormFieldConfig[],
@@ -220,8 +203,50 @@
220 203 },
221 204 enabled: !!bookingId && can("yatra_view_bookings"),
222 205 });
223 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 +
224 249 // Fetch consent status for this booking (only if Pro is active)
225 250 const isPro = !!(window as any).yatraAdmin?.isPro;
226 251 const { data: consentStatus } = useQuery({
227 252 queryKey: ["booking-consent-status", bookingId],
@@ -329,8 +354,29 @@
329 354 const handleEdit = () => {
330 355 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`;
331 356 };
332 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 +
333 379 if (isLoading) {
334 380 return (
335 381 <div className="space-y-3">
336 382 {/* Header Skeleton */}
@@ -480,9 +526,26 @@
480 526 title={__("Booking Details", "yatra")}
481 527 description={__("View complete booking information", "yatra")}
482 528 actions={
483 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>
484 540 <ConditionalRender capability="yatra_edit_bookings">
541 + <ResendEmailMenu
542 + bookingId={Number(booking.id)}
543 + status={booking.booking_status}
544 + amountPaid={Number(booking.amount_paid) || 0}
545 + />
546 + </ConditionalRender>
547 + <ConditionalRender capability="yatra_edit_bookings">
485 548 <Button onClick={handleEdit} className="flex items-center gap-2">
486 549 {__("Edit Booking", "yatra")}
487 550 </Button>
488 551 </ConditionalRender>
@@ -497,8 +560,18 @@
497 560 </div>
498 561 }
499 562 />
500 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 +
501 574 <ConditionalRender capability="yatra_view_bookings">
502 575 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
503 576 {/* Main Content */}
504 577 <div className="lg:col-span-2 space-y-3">
@@ -886,8 +959,81 @@
886 959 </div>
887 960 </CardContent>
888 961 </Card>
889 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 +
890 1036 {/* Customer Information */}
891 1037 <Card>
892 1038 <CardHeader className="pb-2">
893 1039 <CardTitle className="text-base">
@@ -952,9 +1098,9 @@
952 1098 );
953 1099 const display =
954 1100 field?.type === "country"
955 1101 ? getCountryName(String(value))
956 - : String(value);
1102 + : optionLabel(field, value);
957 1103 return (
958 1104 <div key={fieldId}>
959 1105 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
960 1106 {getFieldLabel(fieldId, contactFields)}
@@ -1102,8 +1248,20 @@
1102 1248 ) {
1103 1249 displayValue = getCountryName(fieldValue);
1104 1250 }
1105 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 +
1106 1264 return (
1107 1265 <div
1108 1266 key={fieldId}
1109 1267 className={
@@ -1115,9 +1273,9 @@
1115 1273 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1116 1274 {label}
1117 1275 </div>
1118 1276 <div
1119 - 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"}`}
1120 1278 >
1121 1279 {displayValue}
1122 1280 </div>
1123 1281 </div>
@@ -1161,15 +1319,24 @@
1161 1319 ([_, value]) => value && String(value).trim() !== "",
1162 1320 )
1163 1321 .map(([fieldId, fieldValue]) => {
1164 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 + );
1165 1330 return (
1166 1331 <div key={fieldId}>
1167 1332 <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5">
1168 1333 {label}
1169 1334 </div>
1170 - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize">
1171 - {String(fieldValue)}
1335 + <div
1336 + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`}
1337 + >
1338 + {emergencyDisplay}
1172 1339 </div>
1173 1340 </div>
1174 1341 );
1175 1342 })}