| @@ -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 | })} |