| @@ -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, |
| @@ -22,8 +23,9 @@ | ||
| 22 | 23 | } from "lucide-react"; |
| 23 | 24 | import { apiClient, apiService } from "../lib/api-client"; |
| 24 | 25 | import { __ } from "../lib/i18n"; |
| 25 | 26 | import { formatDate as formatDateUtil } from "../lib/dateFormat"; |
| 27 | +import PhoneDisplay from "../components/common/PhoneDisplay"; | |
| 26 | 28 | import { usePermissions } from "../hooks/usePermissions"; |
| 27 | 29 | import { getCountryName } from "../lib/countries"; |
| 28 | 30 | import { Button } from "../components/ui/button"; |
| 29 | 31 | import { PageHeader } from "../components/common/PageHeader"; |
| @@ -33,8 +35,10 @@ | ||
| 33 | 35 | CardHeader, |
| 34 | 36 | CardTitle, |
| 35 | 37 | } from "../components/ui/card"; |
| 36 | 38 | import { ConditionalRender } from "../components/ui/conditional-render"; |
| 39 | +import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu"; | |
| 40 | +import { downloadAdminBookingInvoice } from "../lib/invoice-download"; | |
| 37 | 41 | import { Skeleton } from "../components/ui/skeleton"; |
| 38 | 42 | import { formatYatraMoney } from "../lib/currency-display"; |
| 39 | 43 | |
| 40 | 44 | interface GoogleCalendarSyncInfo { |
| @@ -53,10 +57,28 @@ | ||
| 53 | 57 | label: string; |
| 54 | 58 | enabled: boolean; |
| 55 | 59 | order: number; |
| 56 | 60 | section?: string; |
| 61 | + options?: { value: string; label: string }[]; | |
| 57 | 62 | } |
| 58 | 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 | + | |
| 59 | 81 | interface FormSectionConfig { |
| 60 | 82 | title: string; |
| 61 | 83 | enabled: boolean; |
| 62 | 84 | fields: FormFieldConfig[]; |
| @@ -76,37 +98,8 @@ | ||
| 76 | 98 | const params = new URLSearchParams(window.location.search); |
| 77 | 99 | return params.get("id") ? parseInt(params.get("id") || "0") : null; |
| 78 | 100 | }, []); |
| 79 | 101 | |
| 80 | - // Fetch booking form configuration for dynamic field labels | |
| 81 | - const { data: formConfig } = useQuery<BookingFormConfig>({ | |
| 82 | - queryKey: ["booking-form-config"], | |
| 83 | - queryFn: async () => { | |
| 84 | - const response = await apiService.getSettings(); | |
| 85 | - return ( | |
| 86 | - response?.data?.booking_form_config || | |
| 87 | - response?.booking_form_config || | |
| 88 | - null | |
| 89 | - ); | |
| 90 | - }, | |
| 91 | - }); | |
| 92 | - | |
| 93 | - // Get enabled traveler fields from config | |
| 94 | - const travelerFields = useMemo(() => { | |
| 95 | - if (!formConfig?.traveler_form?.fields) return []; | |
| 96 | - return formConfig.traveler_form.fields | |
| 97 | - .filter((field) => field.enabled) | |
| 98 | - .sort((a, b) => a.order - b.order); | |
| 99 | - }, [formConfig]); | |
| 100 | - | |
| 101 | - // Get enabled emergency contact fields | |
| 102 | - const emergencyFields = useMemo(() => { | |
| 103 | - if (!formConfig?.emergency_contact_form?.fields) return []; | |
| 104 | - return formConfig.emergency_contact_form.fields | |
| 105 | - .filter((field) => field.enabled) | |
| 106 | - .sort((a, b) => a.order - b.order); | |
| 107 | - }, [formConfig]); | |
| 108 | - | |
| 109 | 102 | // Helper to get field label by ID |
| 110 | 103 | const getFieldLabel = ( |
| 111 | 104 | fieldId: string, |
| 112 | 105 | fields: FormFieldConfig[], |
| @@ -210,8 +203,50 @@ | ||
| 210 | 203 | }, |
| 211 | 204 | enabled: !!bookingId && can("yatra_view_bookings"), |
| 212 | 205 | }); |
| 213 | 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 | + | |
| 214 | 249 | // Fetch consent status for this booking (only if Pro is active) |
| 215 | 250 | const isPro = !!(window as any).yatraAdmin?.isPro; |
| 216 | 251 | const { data: consentStatus } = useQuery({ |
| 217 | 252 | queryKey: ["booking-consent-status", bookingId], |
| @@ -319,8 +354,29 @@ | ||
| 319 | 354 | const handleEdit = () => { |
| 320 | 355 | window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`; |
| 321 | 356 | }; |
| 322 | 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 | + | |
| 323 | 379 | if (isLoading) { |
| 324 | 380 | return ( |
| 325 | 381 | <div className="space-y-3"> |
| 326 | 382 | {/* Header Skeleton */} |
| @@ -470,9 +526,26 @@ | ||
| 470 | 526 | title={__("Booking Details", "yatra")} |
| 471 | 527 | description={__("View complete booking information", "yatra")} |
| 472 | 528 | actions={ |
| 473 | 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> | |
| 474 | 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"> | |
| 475 | 548 | <Button onClick={handleEdit} className="flex items-center gap-2"> |
| 476 | 549 | {__("Edit Booking", "yatra")} |
| 477 | 550 | </Button> |
| 478 | 551 | </ConditionalRender> |
| @@ -487,8 +560,18 @@ | ||
| 487 | 560 | </div> |
| 488 | 561 | } |
| 489 | 562 | /> |
| 490 | 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 | + | |
| 491 | 574 | <ConditionalRender capability="yatra_view_bookings"> |
| 492 | 575 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> |
| 493 | 576 | {/* Main Content */} |
| 494 | 577 | <div className="lg:col-span-2 space-y-3"> |
| @@ -876,8 +959,81 @@ | ||
| 876 | 959 | </div> |
| 877 | 960 | </CardContent> |
| 878 | 961 | </Card> |
| 879 | 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 | + | |
| 880 | 1036 | {/* Customer Information */} |
| 881 | 1037 | <Card> |
| 882 | 1038 | <CardHeader className="pb-2"> |
| 883 | 1039 | <CardTitle className="text-base"> |
| @@ -896,13 +1052,69 @@ | ||
| 896 | 1052 | </div> |
| 897 | 1053 | {booking.customer_phone && ( |
| 898 | 1054 | <div className="flex items-center gap-1.5"> |
| 899 | 1055 | <Phone className="w-4 h-4" /> |
| 900 | - {booking.customer_phone} | |
| 1056 | + <PhoneDisplay value={booking.customer_phone} /> | |
| 901 | 1057 | </div> |
| 902 | 1058 | )} |
| 903 | 1059 | </div> |
| 904 | 1060 | </div> |
| 1061 | + | |
| 1062 | + {/* Country + dynamic/custom contact fields (nationality, | |
| 1063 | + address, and any custom contact field). Mirrors the | |
| 1064 | + Emergency/Traveler dynamic-field display. */} | |
| 1065 | + {(() => { | |
| 1066 | + const cd = | |
| 1067 | + booking.contact_data && | |
| 1068 | + typeof booking.contact_data === "object" | |
| 1069 | + ? (booking.contact_data as Record<string, unknown>) | |
| 1070 | + : {}; | |
| 1071 | + const CORE = ["first_name", "last_name", "email", "phone"]; | |
| 1072 | + const extras = Object.entries(cd).filter( | |
| 1073 | + ([k, v]) => | |
| 1074 | + !CORE.includes(k) && | |
| 1075 | + k !== "country" && | |
| 1076 | + v != null && | |
| 1077 | + String(v).trim() !== "", | |
| 1078 | + ); | |
| 1079 | + const countryCode = | |
| 1080 | + booking.customer_country || | |
| 1081 | + (cd.country ? String(cd.country) : ""); | |
| 1082 | + if (!countryCode && extras.length === 0) return null; | |
| 1083 | + return ( | |
| 1084 | + <div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-1 border-t border-gray-100 dark:border-gray-700"> | |
| 1085 | + {countryCode && ( | |
| 1086 | + <div> | |
| 1087 | + <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> | |
| 1088 | + {getFieldLabel("country", contactFields)} | |
| 1089 | + </div> | |
| 1090 | + <div className="text-sm font-medium text-gray-900 dark:text-white"> | |
| 1091 | + {getCountryName(countryCode)} | |
| 1092 | + </div> | |
| 1093 | + </div> | |
| 1094 | + )} | |
| 1095 | + {extras.map(([fieldId, value]) => { | |
| 1096 | + const field = contactFields.find( | |
| 1097 | + (f) => f.id === fieldId, | |
| 1098 | + ); | |
| 1099 | + const display = | |
| 1100 | + field?.type === "country" | |
| 1101 | + ? getCountryName(String(value)) | |
| 1102 | + : optionLabel(field, value); | |
| 1103 | + return ( | |
| 1104 | + <div key={fieldId}> | |
| 1105 | + <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> | |
| 1106 | + {getFieldLabel(fieldId, contactFields)} | |
| 1107 | + </div> | |
| 1108 | + <div className="text-sm font-medium text-gray-900 dark:text-white"> | |
| 1109 | + {display} | |
| 1110 | + </div> | |
| 1111 | + </div> | |
| 1112 | + ); | |
| 1113 | + })} | |
| 1114 | + </div> | |
| 1115 | + ); | |
| 1116 | + })()} | |
| 905 | 1117 | </CardContent> |
| 906 | 1118 | </Card> |
| 907 | 1119 | |
| 908 | 1120 | {/* Travelers Information - Dynamic Fields */} |
| @@ -1011,22 +1223,20 @@ | ||
| 1011 | 1223 | const isLongField = |
| 1012 | 1224 | fieldConfig?.type === "textarea" || |
| 1013 | 1225 | String(fieldValue).length > 50; |
| 1014 | 1226 | |
| 1015 | - // Format date fields | |
| 1227 | + // Format date fields (e.g. Date of Birth) with the | |
| 1228 | + // global WP date-format setting via the shared | |
| 1229 | + // formatter — not the browser locale. | |
| 1016 | 1230 | let displayValue = String(fieldValue); |
| 1017 | 1231 | if ( |
| 1018 | 1232 | fieldConfig?.type === "date" || |
| 1019 | 1233 | fieldId.includes("date") || |
| 1234 | + fieldId.includes("birth") || | |
| 1235 | + fieldId.includes("dob") || | |
| 1020 | 1236 | fieldId.includes("expiry") |
| 1021 | 1237 | ) { |
| 1022 | - try { | |
| 1023 | - displayValue = new Date( | |
| 1024 | - fieldValue as string, | |
| 1025 | - ).toLocaleDateString(); | |
| 1026 | - } catch { | |
| 1027 | - displayValue = String(fieldValue); | |
| 1028 | - } | |
| 1238 | + displayValue = formatDate(fieldValue as string); | |
| 1029 | 1239 | } |
| 1030 | 1240 | |
| 1031 | 1241 | // Format country/nationality fields - convert code to full name |
| 1032 | 1242 | if ( |
| @@ -1038,8 +1248,20 @@ | ||
| 1038 | 1248 | ) { |
| 1039 | 1249 | displayValue = getCountryName(fieldValue); |
| 1040 | 1250 | } |
| 1041 | 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 | + | |
| 1042 | 1264 | return ( |
| 1043 | 1265 | <div |
| 1044 | 1266 | key={fieldId} |
| 1045 | 1267 | className={ |
| @@ -1051,9 +1273,9 @@ | ||
| 1051 | 1273 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1052 | 1274 | {label} |
| 1053 | 1275 | </div> |
| 1054 | 1276 | <div |
| 1055 | - 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"}`} | |
| 1056 | 1278 | > |
| 1057 | 1279 | {displayValue} |
| 1058 | 1280 | </div> |
| 1059 | 1281 | </div> |
| @@ -1097,15 +1319,24 @@ | ||
| 1097 | 1319 | ([_, value]) => value && String(value).trim() !== "", |
| 1098 | 1320 | ) |
| 1099 | 1321 | .map(([fieldId, fieldValue]) => { |
| 1100 | 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 | + ); | |
| 1101 | 1330 | return ( |
| 1102 | 1331 | <div key={fieldId}> |
| 1103 | 1332 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1104 | 1333 | {label} |
| 1105 | 1334 | </div> |
| 1106 | - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize"> | |
| 1107 | - {String(fieldValue)} | |
| 1335 | + <div | |
| 1336 | + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`} | |
| 1337 | + > | |
| 1338 | + {emergencyDisplay} | |
| 1108 | 1339 | </div> |
| 1109 | 1340 | </div> |
| 1110 | 1341 | ); |
| 1111 | 1342 | })} |