| @@ -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,9 +23,11 @@ | ||
| 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"; |
| 29 | +import { getCountryName } from "../lib/countries"; | |
| 27 | 30 | import { Button } from "../components/ui/button"; |
| 28 | 31 | import { PageHeader } from "../components/common/PageHeader"; |
| 29 | 32 | import { |
| 30 | 33 | Card, |
| @@ -32,10 +35,12 @@ | ||
| 32 | 35 | CardHeader, |
| 33 | 36 | CardTitle, |
| 34 | 37 | } from "../components/ui/card"; |
| 35 | 38 | import { ConditionalRender } from "../components/ui/conditional-render"; |
| 39 | +import { ResendEmailMenu } from "../components/bookings/ResendEmailMenu"; | |
| 40 | +import { downloadAdminBookingInvoice } from "../lib/invoice-download"; | |
| 36 | 41 | import { Skeleton } from "../components/ui/skeleton"; |
| 37 | -import { getCurrencySymbol, getCurrency } from "../data/currencies"; | |
| 42 | +import { formatYatraMoney } from "../lib/currency-display"; | |
| 38 | 43 | |
| 39 | 44 | interface GoogleCalendarSyncInfo { |
| 40 | 45 | synced: boolean; |
| 41 | 46 | calendar_id: string | null; |
| @@ -52,10 +57,28 @@ | ||
| 52 | 57 | label: string; |
| 53 | 58 | enabled: boolean; |
| 54 | 59 | order: number; |
| 55 | 60 | section?: string; |
| 61 | + options?: { value: string; label: string }[]; | |
| 56 | 62 | } |
| 57 | 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 | + | |
| 58 | 81 | interface FormSectionConfig { |
| 59 | 82 | title: string; |
| 60 | 83 | enabled: boolean; |
| 61 | 84 | fields: FormFieldConfig[]; |
| @@ -75,37 +98,8 @@ | ||
| 75 | 98 | const params = new URLSearchParams(window.location.search); |
| 76 | 99 | return params.get("id") ? parseInt(params.get("id") || "0") : null; |
| 77 | 100 | }, []); |
| 78 | 101 | |
| 79 | - // Fetch booking form configuration for dynamic field labels | |
| 80 | - const { data: formConfig } = useQuery<BookingFormConfig>({ | |
| 81 | - queryKey: ["booking-form-config"], | |
| 82 | - queryFn: async () => { | |
| 83 | - const response = await apiService.getSettings(); | |
| 84 | - return ( | |
| 85 | - response?.data?.booking_form_config || | |
| 86 | - response?.booking_form_config || | |
| 87 | - null | |
| 88 | - ); | |
| 89 | - }, | |
| 90 | - }); | |
| 91 | - | |
| 92 | - // Get enabled traveler fields from config | |
| 93 | - const travelerFields = useMemo(() => { | |
| 94 | - if (!formConfig?.traveler_form?.fields) return []; | |
| 95 | - return formConfig.traveler_form.fields | |
| 96 | - .filter((field) => field.enabled) | |
| 97 | - .sort((a, b) => a.order - b.order); | |
| 98 | - }, [formConfig]); | |
| 99 | - | |
| 100 | - // Get enabled emergency contact fields | |
| 101 | - const emergencyFields = useMemo(() => { | |
| 102 | - if (!formConfig?.emergency_contact_form?.fields) return []; | |
| 103 | - return formConfig.emergency_contact_form.fields | |
| 104 | - .filter((field) => field.enabled) | |
| 105 | - .sort((a, b) => a.order - b.order); | |
| 106 | - }, [formConfig]); | |
| 107 | - | |
| 108 | 102 | // Helper to get field label by ID |
| 109 | 103 | const getFieldLabel = ( |
| 110 | 104 | fieldId: string, |
| 111 | 105 | fields: FormFieldConfig[], |
| @@ -154,9 +148,11 @@ | ||
| 154 | 148 | customer_country: data.contact_country || contact.country || "", |
| 155 | 149 | trip_id: data.trip_id, |
| 156 | 150 | trip_title: data.trip_title || `Trip #${data.trip_id}`, |
| 157 | 151 | trip_image: data.trip_image, |
| 158 | - trip_price: data.total_amount / (data.travelers_count || 1), | |
| 152 | + trip_price: | |
| 153 | + (parseFloat(data.subtotal) || data.total_amount || 0) / | |
| 154 | + (data.travelers_count || 1), | |
| 159 | 155 | booking_date: data.created_at, |
| 160 | 156 | travel_date: data.travel_date, |
| 161 | 157 | travelers: data.travelers_count, |
| 162 | 158 | travelers_data: data.travelers || [], |
| @@ -176,8 +172,11 @@ | ||
| 176 | 172 | taxable_amount: data.taxable_amount, |
| 177 | 173 | // Itinerary costs |
| 178 | 174 | itinerary_costs: data.itinerary_costs, |
| 179 | 175 | itinerary_costs_total: data.itinerary_costs_total, |
| 176 | + // Additional services | |
| 177 | + additional_services: data.additional_services, | |
| 178 | + services_total: data.services_total, | |
| 180 | 179 | // End tax fields |
| 181 | 180 | payment_status: data.payment_status, |
| 182 | 181 | booking_status: data.status, |
| 183 | 182 | payment_method: data.payment_gateway, |
| @@ -204,8 +203,50 @@ | ||
| 204 | 203 | }, |
| 205 | 204 | enabled: !!bookingId && can("yatra_view_bookings"), |
| 206 | 205 | }); |
| 207 | 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 | + | |
| 208 | 249 | // Fetch consent status for this booking (only if Pro is active) |
| 209 | 250 | const isPro = !!(window as any).yatraAdmin?.isPro; |
| 210 | 251 | const { data: consentStatus } = useQuery({ |
| 211 | 252 | queryKey: ["booking-consent-status", bookingId], |
| @@ -222,229 +263,13 @@ | ||
| 222 | 263 | const formatDate = (dateString: string) => { |
| 223 | 264 | return formatDateUtil(dateString); |
| 224 | 265 | }; |
| 225 | 266 | |
| 226 | - const formatPrice = (price: number, currencyCode: string = "USD") => { | |
| 227 | - // Always format the price, even if 0 - don't show "Contact for pricing" for bookings | |
| 228 | - const numPrice = Number(price) || 0; | |
| 229 | - const symbol = getCurrencySymbol(currencyCode); | |
| 230 | - const currencyData = getCurrency(currencyCode); | |
| 231 | - const decimals = currencyData?.decimalDigits ?? 2; | |
| 267 | + const formatPrice = (price: number, currencyCode: string = "USD") => | |
| 268 | + formatYatraMoney(Number(price) || 0, currencyCode, { | |
| 269 | + zeroAsUnknown: false, | |
| 270 | + }); | |
| 232 | 271 | |
| 233 | - const formatted = new Intl.NumberFormat(undefined, { | |
| 234 | - minimumFractionDigits: decimals, | |
| 235 | - maximumFractionDigits: decimals, | |
| 236 | - }).format(numPrice); | |
| 237 | - | |
| 238 | - return `${symbol}${formatted}`; | |
| 239 | - }; | |
| 240 | - | |
| 241 | - // Country code to name mapping | |
| 242 | - const COUNTRY_NAMES: Record<string, string> = { | |
| 243 | - AF: "Afghanistan", | |
| 244 | - AL: "Albania", | |
| 245 | - DZ: "Algeria", | |
| 246 | - AD: "Andorra", | |
| 247 | - AO: "Angola", | |
| 248 | - AG: "Antigua and Barbuda", | |
| 249 | - AR: "Argentina", | |
| 250 | - AM: "Armenia", | |
| 251 | - AU: "Australia", | |
| 252 | - AT: "Austria", | |
| 253 | - AZ: "Azerbaijan", | |
| 254 | - BS: "Bahamas", | |
| 255 | - BH: "Bahrain", | |
| 256 | - BD: "Bangladesh", | |
| 257 | - BB: "Barbados", | |
| 258 | - BY: "Belarus", | |
| 259 | - BE: "Belgium", | |
| 260 | - BZ: "Belize", | |
| 261 | - BJ: "Benin", | |
| 262 | - BT: "Bhutan", | |
| 263 | - BO: "Bolivia", | |
| 264 | - BA: "Bosnia and Herzegovina", | |
| 265 | - BW: "Botswana", | |
| 266 | - BR: "Brazil", | |
| 267 | - BN: "Brunei", | |
| 268 | - BG: "Bulgaria", | |
| 269 | - BF: "Burkina Faso", | |
| 270 | - BI: "Burundi", | |
| 271 | - KH: "Cambodia", | |
| 272 | - CM: "Cameroon", | |
| 273 | - CA: "Canada", | |
| 274 | - CV: "Cape Verde", | |
| 275 | - CF: "Central African Republic", | |
| 276 | - TD: "Chad", | |
| 277 | - CL: "Chile", | |
| 278 | - CN: "China", | |
| 279 | - CO: "Colombia", | |
| 280 | - KM: "Comoros", | |
| 281 | - CG: "Congo", | |
| 282 | - CD: "DR Congo", | |
| 283 | - CR: "Costa Rica", | |
| 284 | - CI: "Ivory Coast", | |
| 285 | - HR: "Croatia", | |
| 286 | - CU: "Cuba", | |
| 287 | - CY: "Cyprus", | |
| 288 | - CZ: "Czech Republic", | |
| 289 | - DK: "Denmark", | |
| 290 | - DJ: "Djibouti", | |
| 291 | - DM: "Dominica", | |
| 292 | - DO: "Dominican Republic", | |
| 293 | - EC: "Ecuador", | |
| 294 | - EG: "Egypt", | |
| 295 | - SV: "El Salvador", | |
| 296 | - GQ: "Equatorial Guinea", | |
| 297 | - ER: "Eritrea", | |
| 298 | - EE: "Estonia", | |
| 299 | - SZ: "Eswatini", | |
| 300 | - ET: "Ethiopia", | |
| 301 | - FJ: "Fiji", | |
| 302 | - FI: "Finland", | |
| 303 | - FR: "France", | |
| 304 | - GA: "Gabon", | |
| 305 | - GM: "Gambia", | |
| 306 | - GE: "Georgia", | |
| 307 | - DE: "Germany", | |
| 308 | - GH: "Ghana", | |
| 309 | - GR: "Greece", | |
| 310 | - GD: "Grenada", | |
| 311 | - GT: "Guatemala", | |
| 312 | - GN: "Guinea", | |
| 313 | - GW: "Guinea-Bissau", | |
| 314 | - GY: "Guyana", | |
| 315 | - HT: "Haiti", | |
| 316 | - HN: "Honduras", | |
| 317 | - HU: "Hungary", | |
| 318 | - IS: "Iceland", | |
| 319 | - IN: "India", | |
| 320 | - ID: "Indonesia", | |
| 321 | - IR: "Iran", | |
| 322 | - IQ: "Iraq", | |
| 323 | - IE: "Ireland", | |
| 324 | - IL: "Israel", | |
| 325 | - IT: "Italy", | |
| 326 | - JM: "Jamaica", | |
| 327 | - JP: "Japan", | |
| 328 | - JO: "Jordan", | |
| 329 | - KZ: "Kazakhstan", | |
| 330 | - KE: "Kenya", | |
| 331 | - KI: "Kiribati", | |
| 332 | - KP: "North Korea", | |
| 333 | - KR: "South Korea", | |
| 334 | - KW: "Kuwait", | |
| 335 | - KG: "Kyrgyzstan", | |
| 336 | - LA: "Laos", | |
| 337 | - LV: "Latvia", | |
| 338 | - LB: "Lebanon", | |
| 339 | - LS: "Lesotho", | |
| 340 | - LR: "Liberia", | |
| 341 | - LY: "Libya", | |
| 342 | - LI: "Liechtenstein", | |
| 343 | - LT: "Lithuania", | |
| 344 | - LU: "Luxembourg", | |
| 345 | - MG: "Madagascar", | |
| 346 | - MW: "Malawi", | |
| 347 | - MY: "Malaysia", | |
| 348 | - MV: "Maldives", | |
| 349 | - ML: "Mali", | |
| 350 | - MT: "Malta", | |
| 351 | - MH: "Marshall Islands", | |
| 352 | - MR: "Mauritania", | |
| 353 | - MU: "Mauritius", | |
| 354 | - MX: "Mexico", | |
| 355 | - FM: "Micronesia", | |
| 356 | - MD: "Moldova", | |
| 357 | - MC: "Monaco", | |
| 358 | - MN: "Mongolia", | |
| 359 | - ME: "Montenegro", | |
| 360 | - MA: "Morocco", | |
| 361 | - MZ: "Mozambique", | |
| 362 | - MM: "Myanmar", | |
| 363 | - NA: "Namibia", | |
| 364 | - NR: "Nauru", | |
| 365 | - NP: "Nepal", | |
| 366 | - NL: "Netherlands", | |
| 367 | - NZ: "New Zealand", | |
| 368 | - NI: "Nicaragua", | |
| 369 | - NE: "Niger", | |
| 370 | - NG: "Nigeria", | |
| 371 | - MK: "North Macedonia", | |
| 372 | - NO: "Norway", | |
| 373 | - OM: "Oman", | |
| 374 | - PK: "Pakistan", | |
| 375 | - PW: "Palau", | |
| 376 | - PS: "Palestine", | |
| 377 | - PA: "Panama", | |
| 378 | - PG: "Papua New Guinea", | |
| 379 | - PY: "Paraguay", | |
| 380 | - PE: "Peru", | |
| 381 | - PH: "Philippines", | |
| 382 | - PL: "Poland", | |
| 383 | - PT: "Portugal", | |
| 384 | - QA: "Qatar", | |
| 385 | - RO: "Romania", | |
| 386 | - RU: "Russia", | |
| 387 | - RW: "Rwanda", | |
| 388 | - KN: "Saint Kitts and Nevis", | |
| 389 | - LC: "Saint Lucia", | |
| 390 | - VC: "Saint Vincent and the Grenadines", | |
| 391 | - WS: "Samoa", | |
| 392 | - SM: "San Marino", | |
| 393 | - ST: "Sao Tome and Principe", | |
| 394 | - SA: "Saudi Arabia", | |
| 395 | - SN: "Senegal", | |
| 396 | - RS: "Serbia", | |
| 397 | - SC: "Seychelles", | |
| 398 | - SL: "Sierra Leone", | |
| 399 | - SG: "Singapore", | |
| 400 | - SK: "Slovakia", | |
| 401 | - SI: "Slovenia", | |
| 402 | - SB: "Solomon Islands", | |
| 403 | - SO: "Somalia", | |
| 404 | - ZA: "South Africa", | |
| 405 | - SS: "South Sudan", | |
| 406 | - ES: "Spain", | |
| 407 | - LK: "Sri Lanka", | |
| 408 | - SD: "Sudan", | |
| 409 | - SR: "Suriname", | |
| 410 | - SE: "Sweden", | |
| 411 | - CH: "Switzerland", | |
| 412 | - SY: "Syria", | |
| 413 | - TW: "Taiwan", | |
| 414 | - TJ: "Tajikistan", | |
| 415 | - TZ: "Tanzania", | |
| 416 | - TH: "Thailand", | |
| 417 | - TL: "Timor-Leste", | |
| 418 | - TG: "Togo", | |
| 419 | - TO: "Tonga", | |
| 420 | - TT: "Trinidad and Tobago", | |
| 421 | - TN: "Tunisia", | |
| 422 | - TR: "Turkey", | |
| 423 | - TM: "Turkmenistan", | |
| 424 | - TV: "Tuvalu", | |
| 425 | - UG: "Uganda", | |
| 426 | - UA: "Ukraine", | |
| 427 | - AE: "United Arab Emirates", | |
| 428 | - GB: "United Kingdom", | |
| 429 | - US: "United States", | |
| 430 | - UY: "Uruguay", | |
| 431 | - UZ: "Uzbekistan", | |
| 432 | - VU: "Vanuatu", | |
| 433 | - VA: "Vatican City", | |
| 434 | - VE: "Venezuela", | |
| 435 | - VN: "Vietnam", | |
| 436 | - YE: "Yemen", | |
| 437 | - ZM: "Zambia", | |
| 438 | - ZW: "Zimbabwe", | |
| 439 | - }; | |
| 440 | - | |
| 441 | - const getCountryName = (code: string): string => { | |
| 442 | - if (!code) return ""; | |
| 443 | - const upperCode = code.toUpperCase(); | |
| 444 | - return COUNTRY_NAMES[upperCode] || code; | |
| 445 | - }; | |
| 446 | - | |
| 447 | 272 | const getBookingStatusBadge = (status: string) => { |
| 448 | 273 | const statusMap: Record<string, { className: string; label: string }> = { |
| 449 | 274 | confirmed: { |
| 450 | 275 | className: |
| @@ -529,8 +354,29 @@ | ||
| 529 | 354 | const handleEdit = () => { |
| 530 | 355 | window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`; |
| 531 | 356 | }; |
| 532 | 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 | + | |
| 533 | 379 | if (isLoading) { |
| 534 | 380 | return ( |
| 535 | 381 | <div className="space-y-3"> |
| 536 | 382 | {/* Header Skeleton */} |
| @@ -680,9 +526,26 @@ | ||
| 680 | 526 | title={__("Booking Details", "yatra")} |
| 681 | 527 | description={__("View complete booking information", "yatra")} |
| 682 | 528 | actions={ |
| 683 | 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> | |
| 684 | 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"> | |
| 685 | 548 | <Button onClick={handleEdit} className="flex items-center gap-2"> |
| 686 | 549 | {__("Edit Booking", "yatra")} |
| 687 | 550 | </Button> |
| 688 | 551 | </ConditionalRender> |
| @@ -697,8 +560,18 @@ | ||
| 697 | 560 | </div> |
| 698 | 561 | } |
| 699 | 562 | /> |
| 700 | 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 | + | |
| 701 | 574 | <ConditionalRender capability="yatra_view_bookings"> |
| 702 | 575 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> |
| 703 | 576 | {/* Main Content */} |
| 704 | 577 | <div className="lg:col-span-2 space-y-3"> |
| @@ -767,249 +640,400 @@ | ||
| 767 | 640 | </div> |
| 768 | 641 | </div> |
| 769 | 642 | </div> |
| 770 | 643 | |
| 771 | - {/* Payment Summary with Gross/Net and Tax Breakdown */} | |
| 644 | + {/* Payment Summary */} | |
| 772 | 645 | <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> |
| 773 | 646 | <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3"> |
| 774 | 647 | {__("Payment Summary", "yatra")} |
| 775 | 648 | </h4> |
| 776 | - <div className="space-y-2"> | |
| 777 | - {/* Gross Total */} | |
| 778 | - <div className="flex justify-between text-sm"> | |
| 779 | - <span className="font-bold text-gray-900 dark:text-white"> | |
| 780 | - {__("Gross Total", "yatra")} | |
| 781 | - </span> | |
| 782 | - <span className="text-gray-900 dark:text-white"> | |
| 783 | - {formatPrice( | |
| 784 | - (booking as any).subtotal || booking.total_amount, | |
| 785 | - booking.currency, | |
| 786 | - )} | |
| 787 | - </span> | |
| 788 | - </div> | |
| 649 | + {(() => { | |
| 650 | + // ── Raw figures ────────────────────────────────────────── | |
| 651 | + const dbSubtotal = | |
| 652 | + parseFloat((booking as any).subtotal) || | |
| 653 | + booking.total_amount || | |
| 654 | + 0; | |
| 655 | + const discountAmount = booking.discount_amount || 0; | |
| 656 | + const itineraryCostsTotal = | |
| 657 | + parseFloat((booking as any).itinerary_costs_total) || 0; | |
| 658 | + const taxAmount = | |
| 659 | + parseFloat((booking as any).tax_amount) || 0; | |
| 660 | + // Handle both PHP bool (true/false) and int/string (1/0/"1"/"0") | |
| 661 | + const taxInclusiveRaw = (booking as any).tax_inclusive; | |
| 662 | + const taxInclusive = | |
| 663 | + taxInclusiveRaw === true || | |
| 664 | + parseInt(String(taxInclusiveRaw), 10) === 1; | |
| 665 | + const totalAmount = booking.total_amount || 0; | |
| 666 | + const amountPaid = booking.amount_paid || 0; | |
| 667 | + const amountDue = booking.amount_due || 0; | |
| 789 | 668 | |
| 790 | - {/* Itinerary Costs */} | |
| 791 | - {(() => { | |
| 792 | - const itineraryCosts = (booking as any).itinerary_costs; | |
| 793 | - const itineraryCostsTotal = parseFloat( | |
| 794 | - (booking as any).itinerary_costs_total || 0, | |
| 669 | + // ── Tax breakdown ──────────────────────────────────────── | |
| 670 | + let taxLines: { | |
| 671 | + name: string; | |
| 672 | + rate: number; | |
| 673 | + amount: number; | |
| 674 | + }[] = []; | |
| 675 | + const taxBreakdownRaw = (booking as any).tax_breakdown; | |
| 676 | + if ( | |
| 677 | + Array.isArray(taxBreakdownRaw) && | |
| 678 | + taxBreakdownRaw.length > 0 | |
| 679 | + ) { | |
| 680 | + taxLines = taxBreakdownRaw; | |
| 681 | + } else { | |
| 682 | + const taxDetailsRaw = (booking as any).tax_details; | |
| 683 | + if (taxDetailsRaw) { | |
| 684 | + try { | |
| 685 | + const parsed = | |
| 686 | + typeof taxDetailsRaw === "string" | |
| 687 | + ? JSON.parse(taxDetailsRaw) | |
| 688 | + : taxDetailsRaw; | |
| 689 | + if (Array.isArray(parsed) && parsed.length > 0) | |
| 690 | + taxLines = parsed; | |
| 691 | + } catch (_) {} | |
| 692 | + } | |
| 693 | + if (taxLines.length === 0 && taxAmount > 0) { | |
| 694 | + taxLines = [ | |
| 695 | + { | |
| 696 | + name: __("Tax", "yatra"), | |
| 697 | + rate: parseFloat((booking as any).tax_rate) || 0, | |
| 698 | + amount: taxAmount, | |
| 699 | + }, | |
| 700 | + ]; | |
| 701 | + } | |
| 702 | + } | |
| 703 | + | |
| 704 | + // ── Itinerary costs ────────────────────────────────────── | |
| 705 | + let itineraryCosts: any[] = []; | |
| 706 | + const itineraryCostsRaw = (booking as any).itinerary_costs; | |
| 707 | + if (typeof itineraryCostsRaw === "string") { | |
| 708 | + try { | |
| 709 | + itineraryCosts = JSON.parse(itineraryCostsRaw); | |
| 710 | + } catch (_) {} | |
| 711 | + } else if (Array.isArray(itineraryCostsRaw)) { | |
| 712 | + itineraryCosts = itineraryCostsRaw; | |
| 713 | + } | |
| 714 | + | |
| 715 | + // ── Services ───────────────────────────────────────────── | |
| 716 | + const rawServices = (booking as any).additional_services; | |
| 717 | + const selectedServices: any[] = Array.isArray(rawServices) | |
| 718 | + ? rawServices.filter((s: any) => s?.selected !== false) | |
| 719 | + : []; | |
| 720 | + const getServiceAmt = (s: any) => | |
| 721 | + parseFloat( | |
| 722 | + s?.total_price ?? | |
| 723 | + s?.calculated_price ?? | |
| 724 | + s?.total_cost ?? | |
| 725 | + s?.amount ?? | |
| 726 | + s?.unit_price ?? | |
| 727 | + s?.price ?? | |
| 728 | + 0, | |
| 795 | 729 | ); |
| 730 | + const servicesTotal = selectedServices.reduce( | |
| 731 | + (sum, s) => sum + getServiceAmt(s), | |
| 732 | + 0, | |
| 733 | + ); | |
| 734 | + const baseTripCost = dbSubtotal - servicesTotal; | |
| 796 | 735 | |
| 797 | - if (itineraryCostsTotal > 0) { | |
| 798 | - let costs = []; | |
| 736 | + // ── Derived amounts ────────────────────────────────────── | |
| 737 | + const taxableAmount = | |
| 738 | + Math.max(0, dbSubtotal - discountAmount) + | |
| 739 | + itineraryCostsTotal; | |
| 799 | 740 | |
| 800 | - // Try to parse itinerary_costs if it's a string | |
| 801 | - if (typeof itineraryCosts === "string") { | |
| 802 | - try { | |
| 803 | - costs = JSON.parse(itineraryCosts); | |
| 804 | - } catch (e) { | |
| 805 | - console.error( | |
| 806 | - "Failed to parse itinerary_costs:", | |
| 807 | - e, | |
| 808 | - ); | |
| 741 | + const hasServices = selectedServices.length > 0; | |
| 742 | + const hasDiscount = discountAmount > 0; | |
| 743 | + const hasItinerary = | |
| 744 | + itineraryCosts.length > 0 && itineraryCostsTotal > 0; | |
| 745 | + const hasTax = taxLines.length > 0; | |
| 746 | + const showTaxableRow = | |
| 747 | + hasTax && (hasDiscount || hasItinerary); | |
| 748 | + | |
| 749 | + const Row = ({ | |
| 750 | + label, | |
| 751 | + value, | |
| 752 | + sub = false, | |
| 753 | + green = false, | |
| 754 | + bold = false, | |
| 755 | + }: any) => ( | |
| 756 | + <div | |
| 757 | + className={`flex justify-between text-sm${sub ? " ml-4" : ""}`} | |
| 758 | + > | |
| 759 | + <span | |
| 760 | + className={ | |
| 761 | + bold | |
| 762 | + ? "font-semibold text-gray-900 dark:text-white" | |
| 763 | + : "text-gray-600 dark:text-gray-400" | |
| 809 | 764 | } |
| 810 | - } else if (Array.isArray(itineraryCosts)) { | |
| 811 | - costs = itineraryCosts; | |
| 812 | - } | |
| 765 | + > | |
| 766 | + {label} | |
| 767 | + </span> | |
| 768 | + <span | |
| 769 | + className={ | |
| 770 | + bold | |
| 771 | + ? "font-semibold text-gray-900 dark:text-white" | |
| 772 | + : green | |
| 773 | + ? "text-green-600 dark:text-green-400" | |
| 774 | + : "text-gray-900 dark:text-white" | |
| 775 | + } | |
| 776 | + > | |
| 777 | + {value} | |
| 778 | + </span> | |
| 779 | + </div> | |
| 780 | + ); | |
| 813 | 781 | |
| 814 | - if (costs.length > 0) { | |
| 815 | - return ( | |
| 816 | - <> | |
| 817 | - <div className="flex justify-between text-sm font-medium"> | |
| 818 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 819 | - {__("Itinerary Costs", "yatra")} | |
| 820 | - </span> | |
| 821 | - </div> | |
| 822 | - {costs.map((cost: any, index: number) => ( | |
| 823 | - <div | |
| 824 | - key={index} | |
| 825 | - className="flex justify-between text-sm ml-4" | |
| 826 | - > | |
| 827 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 828 | - {cost.name} ( | |
| 829 | - {cost.price_per === "person" | |
| 830 | - ? "per person" | |
| 831 | - : "per group"} | |
| 832 | - ) | |
| 833 | - </span> | |
| 834 | - <span className="text-gray-900 dark:text-white"> | |
| 835 | - {formatPrice( | |
| 836 | - cost.total_cost, | |
| 837 | - booking.currency, | |
| 838 | - )} | |
| 839 | - </span> | |
| 840 | - </div> | |
| 841 | - ))} | |
| 842 | - <div className="flex justify-between text-sm font-medium"> | |
| 843 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 844 | - {__("Itinerary Costs Total", "yatra")} | |
| 845 | - </span> | |
| 846 | - <span className="text-gray-900 dark:text-white"> | |
| 847 | - {formatPrice( | |
| 848 | - itineraryCostsTotal, | |
| 849 | - booking.currency, | |
| 850 | - )} | |
| 851 | - </span> | |
| 852 | - </div> | |
| 853 | - </> | |
| 854 | - ); | |
| 855 | - } else if (itineraryCostsTotal > 0) { | |
| 856 | - // Show total even if individual items aren't available | |
| 857 | - return ( | |
| 858 | - <div className="flex justify-between text-sm"> | |
| 859 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 860 | - {__("Itinerary Costs", "yatra")} | |
| 782 | + return ( | |
| 783 | + <div className="space-y-1.5"> | |
| 784 | + {/* Trip Base Price — always the first line */} | |
| 785 | + <div className="flex justify-between text-sm"> | |
| 786 | + <span className="text-gray-600 dark:text-gray-400 flex items-center gap-1.5"> | |
| 787 | + {__("Trip Base Price", "yatra")} | |
| 788 | + {taxInclusive && ( | |
| 789 | + <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"> | |
| 790 | + {__("Tax Incl.", "yatra")} | |
| 861 | 791 | </span> |
| 862 | - <span className="text-gray-900 dark:text-white"> | |
| 863 | - {formatPrice( | |
| 864 | - itineraryCostsTotal, | |
| 792 | + )} | |
| 793 | + </span> | |
| 794 | + <span className="text-gray-900 dark:text-white"> | |
| 795 | + {formatPrice(baseTripCost, booking.currency)} | |
| 796 | + </span> | |
| 797 | + </div> | |
| 798 | + | |
| 799 | + {/* Additional Services section — only when services exist */} | |
| 800 | + {hasServices && ( | |
| 801 | + <> | |
| 802 | + <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1"> | |
| 803 | + <span> | |
| 804 | + + {__("Additional Services", "yatra")} | |
| 805 | + </span> | |
| 806 | + <span></span> | |
| 807 | + </div> | |
| 808 | + {selectedServices.map((s: any, i: number) => ( | |
| 809 | + <Row | |
| 810 | + key={i} | |
| 811 | + sub | |
| 812 | + label={ | |
| 813 | + s?.service_name || | |
| 814 | + s?.name || | |
| 815 | + s?.title || | |
| 816 | + s?.label || | |
| 817 | + __("Service", "yatra") | |
| 818 | + } | |
| 819 | + value={formatPrice( | |
| 820 | + getServiceAmt(s), | |
| 865 | 821 | booking.currency, |
| 866 | 822 | )} |
| 823 | + /> | |
| 824 | + ))} | |
| 825 | + {/* Gross Total = Trip Base Price + Services */} | |
| 826 | + <div className="flex justify-between text-sm font-semibold border-t border-gray-200 dark:border-gray-600 pt-1.5 mt-0.5"> | |
| 827 | + <span className="text-gray-900 dark:text-white"> | |
| 828 | + {__("Gross Total", "yatra")} | |
| 867 | 829 | </span> |
| 830 | + <span className="text-gray-900 dark:text-white"> | |
| 831 | + {formatPrice(dbSubtotal, booking.currency)} | |
| 832 | + </span> | |
| 868 | 833 | </div> |
| 869 | - ); | |
| 870 | - } | |
| 871 | - } | |
| 834 | + </> | |
| 835 | + )} | |
| 872 | 836 | |
| 873 | - return null; | |
| 874 | - })()} | |
| 837 | + {/* Discount */} | |
| 838 | + {hasDiscount && ( | |
| 839 | + <Row | |
| 840 | + label={__("Discount", "yatra")} | |
| 841 | + value={`-${formatPrice(discountAmount, booking.currency)}`} | |
| 842 | + green | |
| 843 | + /> | |
| 844 | + )} | |
| 875 | 845 | |
| 876 | - {/* Taxable Amount (SubTotal) - Displayed after all costs are added */} | |
| 877 | - {((booking as any).tax_amount > 0 || | |
| 878 | - (booking as any).tax_breakdown?.length > 0) && ( | |
| 879 | - <div className="flex justify-between text-sm font-medium border-t border-gray-200 dark:border-gray-700 pt-2"> | |
| 880 | - <span className="font-bold text-gray-900 dark:text-white"> | |
| 881 | - {__("Subtotal (Taxable Amount)", "yatra")} | |
| 882 | - </span> | |
| 883 | - <span className="text-gray-900 dark:text-white"> | |
| 884 | - {formatPrice( | |
| 885 | - (booking as any).taxable_amount > 0 | |
| 886 | - ? (booking as any).taxable_amount | |
| 887 | - : (booking as any).subtotal || | |
| 888 | - booking.total_amount, | |
| 889 | - booking.currency, | |
| 890 | - )} | |
| 891 | - </span> | |
| 892 | - </div> | |
| 893 | - )} | |
| 846 | + {/* Itinerary Costs section */} | |
| 847 | + {hasItinerary && ( | |
| 848 | + <> | |
| 849 | + <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1"> | |
| 850 | + <span>+ {__("Itinerary Costs", "yatra")}</span> | |
| 851 | + <span></span> | |
| 852 | + </div> | |
| 853 | + {itineraryCosts.map((cost: any, i: number) => ( | |
| 854 | + <Row | |
| 855 | + key={i} | |
| 856 | + sub | |
| 857 | + label={`${cost.name} ${cost.price_per === "person" ? __("(per person)", "yatra") : cost.price_per === "group" ? __("(per booking)", "yatra") : __("(flat rate)", "yatra")}`} | |
| 858 | + value={formatPrice( | |
| 859 | + cost.total_cost ?? cost.price, | |
| 860 | + booking.currency, | |
| 861 | + )} | |
| 862 | + /> | |
| 863 | + ))} | |
| 864 | + </> | |
| 865 | + )} | |
| 894 | 866 | |
| 895 | - {/* Discount if any */} | |
| 896 | - {booking.discount_amount > 0 && ( | |
| 897 | - <div className="flex justify-between text-sm"> | |
| 898 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 899 | - {__("Discount", "yatra")} | |
| 900 | - </span> | |
| 901 | - <span className="text-green-600 dark:text-green-400"> | |
| 902 | - - | |
| 903 | - {formatPrice( | |
| 904 | - booking.discount_amount, | |
| 905 | - booking.currency, | |
| 906 | - )} | |
| 907 | - </span> | |
| 908 | - </div> | |
| 909 | - )} | |
| 867 | + {/* Taxable Amount — only for exclusive tax when discount/itinerary exist above it */} | |
| 868 | + {!taxInclusive && showTaxableRow && ( | |
| 869 | + <div className="flex justify-between text-sm font-semibold border-t border-dashed border-gray-300 dark:border-gray-600 pt-1.5 mt-0.5"> | |
| 870 | + <span className="text-gray-700 dark:text-gray-300"> | |
| 871 | + {__("Taxable Amount", "yatra")} | |
| 872 | + </span> | |
| 873 | + <span className="text-gray-900 dark:text-white"> | |
| 874 | + {formatPrice(taxableAmount, booking.currency)} | |
| 875 | + </span> | |
| 876 | + </div> | |
| 877 | + )} | |
| 910 | 878 | |
| 911 | - {/* Tax Breakdown */} | |
| 912 | - {(parseFloat((booking as any).tax_amount) > 0 || | |
| 913 | - (booking as any).tax_breakdown?.length > 0) && ( | |
| 914 | - <> | |
| 915 | - {(() => { | |
| 916 | - // Use tax_breakdown if available (already parsed by backend) | |
| 917 | - const taxBreakdown = (booking as any).tax_breakdown; | |
| 918 | - | |
| 919 | - if ( | |
| 920 | - Array.isArray(taxBreakdown) && | |
| 921 | - taxBreakdown.length > 0 | |
| 922 | - ) { | |
| 923 | - return taxBreakdown.map( | |
| 924 | - (tax: any, index: number) => ( | |
| 925 | - <div | |
| 926 | - key={index} | |
| 927 | - className="flex justify-between text-sm" | |
| 928 | - > | |
| 929 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 930 | - {tax.name} ({tax.rate}%) | |
| 931 | - </span> | |
| 932 | - <span className="text-gray-900 dark:text-white"> | |
| 933 | - {formatPrice(tax.amount, booking.currency)} | |
| 934 | - </span> | |
| 935 | - </div> | |
| 936 | - ), | |
| 937 | - ); | |
| 938 | - } | |
| 939 | - | |
| 940 | - // Fallback: try parsing tax_details JSON string | |
| 941 | - const taxDetails = (booking as any).tax_details; | |
| 942 | - if (taxDetails) { | |
| 943 | - try { | |
| 944 | - const taxes = | |
| 945 | - typeof taxDetails === "string" | |
| 946 | - ? JSON.parse(taxDetails) | |
| 947 | - : taxDetails; | |
| 948 | - | |
| 949 | - if (Array.isArray(taxes) && taxes.length > 0) { | |
| 950 | - return taxes.map((tax: any, index: number) => ( | |
| 951 | - <div | |
| 952 | - key={index} | |
| 953 | - className="flex justify-between text-sm" | |
| 954 | - > | |
| 955 | - <span className="text-gray-600 dark:text-gray-400"> | |
| 956 | - {tax.name} ({tax.rate}%) | |
| 957 | - </span> | |
| 958 | - <span className="text-gray-900 dark:text-white"> | |
| 959 | - {formatPrice( | |
| 960 | - tax.amount, | |
| 961 | - booking.currency, | |
| 962 | - )} | |
| 963 | - </span> | |
| 964 | - </div> | |
| 965 | - )); | |
| 966 | - } | |
| 967 | - } catch (e) { | |
| 968 | - console.error("Failed to parse tax_details:", e); | |
| 969 | - } | |
| 970 | - } | |
| 971 | - | |
| 972 | - // Final fallback: show single tax line | |
| 973 | - if ((booking as any).tax_amount > 0) { | |
| 974 | - return ( | |
| 975 | - <div className="flex justify-between text-sm"> | |
| 879 | + {/* Exclusive tax — shown as an addition before Net Amount */} | |
| 880 | + {hasTax && !taxInclusive && ( | |
| 881 | + <> | |
| 882 | + <div className="flex justify-between text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mt-1"> | |
| 883 | + <span>+ {__("Tax", "yatra")}</span> | |
| 884 | + <span></span> | |
| 885 | + </div> | |
| 886 | + {taxLines.map((tax: any, i: number) => ( | |
| 887 | + <div | |
| 888 | + key={i} | |
| 889 | + className="flex justify-between text-sm ml-4" | |
| 890 | + > | |
| 976 | 891 | <span className="text-gray-600 dark:text-gray-400"> |
| 977 | - {__("Tax", "yatra")} ( | |
| 978 | - {(booking as any).tax_rate}%) | |
| 892 | + {tax.name} | |
| 893 | + {tax.rate > 0 ? ` (${tax.rate}%)` : ""} | |
| 979 | 894 | </span> |
| 980 | 895 | <span className="text-gray-900 dark:text-white"> |
| 896 | + + | |
| 981 | 897 | {formatPrice( |
| 982 | - (booking as any).tax_amount, | |
| 898 | + parseFloat(tax.amount) || 0, | |
| 983 | 899 | booking.currency, |
| 984 | 900 | )} |
| 985 | 901 | </span> |
| 986 | 902 | </div> |
| 987 | - ); | |
| 988 | - } | |
| 903 | + ))} | |
| 904 | + </> | |
| 905 | + )} | |
| 989 | 906 | |
| 990 | - return null; | |
| 991 | - })()} | |
| 992 | - </> | |
| 993 | - )} | |
| 907 | + {/* Net Amount */} | |
| 908 | + <div className="flex justify-between text-base font-bold border-t-2 border-gray-800 dark:border-gray-300 pt-2 mt-1"> | |
| 909 | + <span className="text-gray-900 dark:text-white"> | |
| 910 | + {__("Net Amount", "yatra")} | |
| 911 | + </span> | |
| 912 | + <span className="text-gray-900 dark:text-white"> | |
| 913 | + {formatPrice(totalAmount, booking.currency)} | |
| 914 | + </span> | |
| 915 | + </div> | |
| 994 | 916 | |
| 995 | - {/* Net Amount (Total with tax) */} | |
| 996 | - <div className="flex justify-between text-base font-semibold pt-2 border-t border-gray-200 dark:border-gray-700"> | |
| 997 | - <span className="text-gray-900 dark:text-white"> | |
| 998 | - {__("Net Amount", "yatra")} | |
| 999 | - </span> | |
| 1000 | - <span className="text-gray-900 dark:text-white"> | |
| 1001 | - {formatPrice( | |
| 1002 | - booking.total_amount || 0, | |
| 1003 | - booking.currency, | |
| 917 | + {/* Inclusive tax — informational footnote after Net Amount */} | |
| 918 | + {hasTax && taxInclusive && ( | |
| 919 | + <div className="mt-1 space-y-0.5"> | |
| 920 | + {taxLines.map((tax: any, i: number) => ( | |
| 921 | + <div | |
| 922 | + key={i} | |
| 923 | + className="text-xs text-gray-400 dark:text-gray-500 italic" | |
| 924 | + > | |
| 925 | + {__("Incl.", "yatra")} {tax.name} | |
| 926 | + {tax.rate > 0 ? ` (${tax.rate}%)` : ""}:{" "} | |
| 927 | + {formatPrice( | |
| 928 | + parseFloat(tax.amount) || 0, | |
| 929 | + booking.currency, | |
| 930 | + )} | |
| 931 | + </div> | |
| 932 | + ))} | |
| 933 | + </div> | |
| 1004 | 934 | )} |
| 1005 | - </span> | |
| 1006 | - </div> | |
| 1007 | - </div> | |
| 935 | + | |
| 936 | + {/* Amount Paid */} | |
| 937 | + {amountPaid > 0 && ( | |
| 938 | + <Row | |
| 939 | + label={__("Amount Paid", "yatra")} | |
| 940 | + value={formatPrice(amountPaid, booking.currency)} | |
| 941 | + green | |
| 942 | + /> | |
| 943 | + )} | |
| 944 | + | |
| 945 | + {/* Due Now */} | |
| 946 | + {amountDue > 0 && ( | |
| 947 | + <div className="flex justify-between text-sm font-semibold"> | |
| 948 | + <span className="text-orange-600 dark:text-orange-400"> | |
| 949 | + {__("Due Now", "yatra")} | |
| 950 | + </span> | |
| 951 | + <span className="text-orange-600 dark:text-orange-400"> | |
| 952 | + {formatPrice(amountDue, booking.currency)} | |
| 953 | + </span> | |
| 954 | + </div> | |
| 955 | + )} | |
| 956 | + </div> | |
| 957 | + ); | |
| 958 | + })()} | |
| 1008 | 959 | </div> |
| 1009 | 960 | </CardContent> |
| 1010 | 961 | </Card> |
| 1011 | 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 | + | |
| 1012 | 1036 | {/* Customer Information */} |
| 1013 | 1037 | <Card> |
| 1014 | 1038 | <CardHeader className="pb-2"> |
| 1015 | 1039 | <CardTitle className="text-base"> |
| @@ -1028,13 +1052,69 @@ | ||
| 1028 | 1052 | </div> |
| 1029 | 1053 | {booking.customer_phone && ( |
| 1030 | 1054 | <div className="flex items-center gap-1.5"> |
| 1031 | 1055 | <Phone className="w-4 h-4" /> |
| 1032 | - {booking.customer_phone} | |
| 1056 | + <PhoneDisplay value={booking.customer_phone} /> | |
| 1033 | 1057 | </div> |
| 1034 | 1058 | )} |
| 1035 | 1059 | </div> |
| 1036 | 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 | + })()} | |
| 1037 | 1117 | </CardContent> |
| 1038 | 1118 | </Card> |
| 1039 | 1119 | |
| 1040 | 1120 | {/* Travelers Information - Dynamic Fields */} |
| @@ -1143,22 +1223,20 @@ | ||
| 1143 | 1223 | const isLongField = |
| 1144 | 1224 | fieldConfig?.type === "textarea" || |
| 1145 | 1225 | String(fieldValue).length > 50; |
| 1146 | 1226 | |
| 1147 | - // 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. | |
| 1148 | 1230 | let displayValue = String(fieldValue); |
| 1149 | 1231 | if ( |
| 1150 | 1232 | fieldConfig?.type === "date" || |
| 1151 | 1233 | fieldId.includes("date") || |
| 1234 | + fieldId.includes("birth") || | |
| 1235 | + fieldId.includes("dob") || | |
| 1152 | 1236 | fieldId.includes("expiry") |
| 1153 | 1237 | ) { |
| 1154 | - try { | |
| 1155 | - displayValue = new Date( | |
| 1156 | - fieldValue as string, | |
| 1157 | - ).toLocaleDateString(); | |
| 1158 | - } catch { | |
| 1159 | - displayValue = String(fieldValue); | |
| 1160 | - } | |
| 1238 | + displayValue = formatDate(fieldValue as string); | |
| 1161 | 1239 | } |
| 1162 | 1240 | |
| 1163 | 1241 | // Format country/nationality fields - convert code to full name |
| 1164 | 1242 | if ( |
| @@ -1170,8 +1248,20 @@ | ||
| 1170 | 1248 | ) { |
| 1171 | 1249 | displayValue = getCountryName(fieldValue); |
| 1172 | 1250 | } |
| 1173 | 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 | + | |
| 1174 | 1264 | return ( |
| 1175 | 1265 | <div |
| 1176 | 1266 | key={fieldId} |
| 1177 | 1267 | className={ |
| @@ -1183,9 +1273,9 @@ | ||
| 1183 | 1273 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1184 | 1274 | {label} |
| 1185 | 1275 | </div> |
| 1186 | 1276 | <div |
| 1187 | - 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"}`} | |
| 1188 | 1278 | > |
| 1189 | 1279 | {displayValue} |
| 1190 | 1280 | </div> |
| 1191 | 1281 | </div> |
| @@ -1229,15 +1319,24 @@ | ||
| 1229 | 1319 | ([_, value]) => value && String(value).trim() !== "", |
| 1230 | 1320 | ) |
| 1231 | 1321 | .map(([fieldId, fieldValue]) => { |
| 1232 | 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 | + ); | |
| 1233 | 1330 | return ( |
| 1234 | 1331 | <div key={fieldId}> |
| 1235 | 1332 | <div className="text-xs text-gray-500 dark:text-gray-400 mb-0.5"> |
| 1236 | 1333 | {label} |
| 1237 | 1334 | </div> |
| 1238 | - <div className="text-sm font-medium text-gray-900 dark:text-white capitalize"> | |
| 1239 | - {String(fieldValue)} | |
| 1335 | + <div | |
| 1336 | + className={`text-sm font-medium text-gray-900 dark:text-white ${emergencyDisplay !== String(fieldValue) ? "" : "capitalize"}`} | |
| 1337 | + > | |
| 1338 | + {emergencyDisplay} | |
| 1240 | 1339 | </div> |
| 1241 | 1340 | </div> |
| 1242 | 1341 | ); |
| 1243 | 1342 | })} |
| @@ -1375,24 +1474,66 @@ | ||
| 1375 | 1474 | </div> |
| 1376 | 1475 | </div> |
| 1377 | 1476 | )} |
| 1378 | 1477 | <div> |
| 1379 | - <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1"> | |
| 1478 | + <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1"> | |
| 1380 | 1479 | {__("Trip Price per Person", "yatra")} |
| 1480 | + {((v) => v === true || parseInt(String(v), 10) === 1)( | |
| 1481 | + (booking as any).tax_inclusive, | |
| 1482 | + ) && ( | |
| 1483 | + <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 normal-case tracking-normal"> | |
| 1484 | + {__("Tax Incl.", "yatra")} | |
| 1485 | + </span> | |
| 1486 | + )} | |
| 1381 | 1487 | </div> |
| 1382 | 1488 | <div className="text-sm text-gray-900 dark:text-white"> |
| 1383 | 1489 | {(() => { |
| 1490 | + // subtotal (DB) = base trip cost + services when Pro is active. | |
| 1491 | + // Subtract services total to get the pure trip base cost. | |
| 1384 | 1492 | const subtotal = |
| 1385 | - (booking as any).subtotal || booking.total_amount; | |
| 1493 | + parseFloat((booking as any).subtotal) || | |
| 1494 | + booking.total_amount || | |
| 1495 | + 0; | |
| 1386 | 1496 | const travelers = booking.travelers || 1; |
| 1387 | - const pricePerPerson = subtotal / travelers; | |
| 1388 | - return formatPrice(pricePerPerson, booking.currency); | |
| 1497 | + | |
| 1498 | + const rawServices = (booking as any).additional_services; | |
| 1499 | + const servicesTotal = Array.isArray(rawServices) | |
| 1500 | + ? rawServices | |
| 1501 | + .filter((s: any) => s?.selected !== false) | |
| 1502 | + .reduce( | |
| 1503 | + (sum: number, s: any) => | |
| 1504 | + sum + | |
| 1505 | + parseFloat( | |
| 1506 | + s?.total_price ?? | |
| 1507 | + s?.calculated_price ?? | |
| 1508 | + s?.total_cost ?? | |
| 1509 | + s?.amount ?? | |
| 1510 | + s?.unit_price ?? | |
| 1511 | + s?.price ?? | |
| 1512 | + 0, | |
| 1513 | + ), | |
| 1514 | + 0, | |
| 1515 | + ) | |
| 1516 | + : 0; | |
| 1517 | + | |
| 1518 | + const baseTripCost = subtotal - servicesTotal; | |
| 1519 | + return formatPrice( | |
| 1520 | + baseTripCost / travelers, | |
| 1521 | + booking.currency, | |
| 1522 | + ); | |
| 1389 | 1523 | })()} |
| 1390 | 1524 | </div> |
| 1391 | 1525 | </div> |
| 1392 | 1526 | <div> |
| 1393 | - <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1"> | |
| 1527 | + <div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 flex items-center gap-1"> | |
| 1394 | 1528 | {__("Total Amount", "yatra")} |
| 1529 | + {((v) => v === true || parseInt(String(v), 10) === 1)( | |
| 1530 | + (booking as any).tax_inclusive, | |
| 1531 | + ) && ( | |
| 1532 | + <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 normal-case tracking-normal"> | |
| 1533 | + {__("Tax Incl.", "yatra")} | |
| 1534 | + </span> | |
| 1535 | + )} | |
| 1395 | 1536 | </div> |
| 1396 | 1537 | <div className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 1397 | 1538 | {formatPrice(booking.total_amount || 0, booking.currency)} |
| 1398 | 1539 | </div> |