/** * View Booking Page * Display booking details in a clean, minimal SaaS-style design */ import React, { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Mail, Phone, Calendar, Users, DollarSign, CreditCard, FileText, AlertCircle, FileSignature, CheckCircle, Clock, Send, } from "lucide-react"; import { apiClient, apiService } from "../lib/api-client"; import { __ } from "../lib/i18n"; import { formatDate as formatDateUtil } from "../lib/dateFormat"; import { usePermissions } from "../hooks/usePermissions"; import { getCountryName } from "../lib/countries"; import { Button } from "../components/ui/button"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { Skeleton } from "../components/ui/skeleton"; import { formatYatraMoney } from "../lib/currency-display"; interface GoogleCalendarSyncInfo { synced: boolean; calendar_id: string | null; event_id: string | null; event_type: string | null; sync_status: string | null; error_message: string | null; last_synced_at: string | null; } interface FormFieldConfig { id: string; type: string; label: string; enabled: boolean; order: number; section?: string; } interface FormSectionConfig { title: string; enabled: boolean; fields: FormFieldConfig[]; } interface BookingFormConfig { contact_form: FormSectionConfig; emergency_contact_form: FormSectionConfig; traveler_form: FormSectionConfig; } const ViewBooking: React.FC = () => { const { can } = usePermissions(); // Get booking id from URL const bookingId = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("id") ? parseInt(params.get("id") || "0") : null; }, []); // Fetch booking form configuration for dynamic field labels const { data: formConfig } = useQuery({ queryKey: ["booking-form-config"], queryFn: async () => { const response = await apiService.getSettings(); return ( response?.data?.booking_form_config || response?.booking_form_config || null ); }, }); // Get enabled traveler fields from config const travelerFields = useMemo(() => { if (!formConfig?.traveler_form?.fields) return []; return formConfig.traveler_form.fields .filter((field) => field.enabled) .sort((a, b) => a.order - b.order); }, [formConfig]); // Get enabled emergency contact fields const emergencyFields = useMemo(() => { if (!formConfig?.emergency_contact_form?.fields) return []; return formConfig.emergency_contact_form.fields .filter((field) => field.enabled) .sort((a, b) => a.order - b.order); }, [formConfig]); // Get enabled contact (lead traveler) fields — used to label and format the // country / nationality / address / custom contact fields in the summary. const contactFields = useMemo(() => { if (!formConfig?.contact_form?.fields) return []; return formConfig.contact_form.fields .filter((field) => field.enabled) .sort((a, b) => a.order - b.order); }, [formConfig]); // Helper to get field label by ID const getFieldLabel = ( fieldId: string, fields: FormFieldConfig[], ): string => { const field = fields.find((f) => f.id === fieldId); return ( field?.label || fieldId.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()) ); }; // Fetch booking data from API const { data: booking, isLoading, error, } = useQuery({ queryKey: ["booking", bookingId], queryFn: async () => { if (!bookingId) return null; const result = await apiService.getBooking(bookingId); if (!result) { throw new Error("Failed to fetch booking"); } // Handle both wrapped { success, data } and direct data response formats const data = (result as any)?.data ?? result; if (data && data.id) { const contact = (data as any).contact || {}; return { id: data.id, booking_number: data.reference, customer_name: data.customer_name || (data.contact?.first_name && data.contact?.last_name ? `${data.contact.first_name} ${data.contact.last_name}`.trim() : `${data.contact_first_name || ""} ${data.contact_last_name || ""}`.trim()) || "N/A", customer_email: data.customer_email || data.contact_email || contact.email || "", customer_phone: data.customer_phone || data.contact_phone || contact.phone || "", customer_country: data.contact_country || contact.country || "", trip_id: data.trip_id, trip_title: data.trip_title || `Trip #${data.trip_id}`, trip_image: data.trip_image, trip_price: (parseFloat(data.subtotal) || data.total_amount || 0) / (data.travelers_count || 1), booking_date: data.created_at, travel_date: data.travel_date, travelers: data.travelers_count, travelers_data: data.travelers || [], total_amount: data.total_amount, amount_paid: data.amount_paid || 0, amount_due: data.amount_due || 0, discount_amount: data.discount_amount || 0, discount_code: data.discount_code || null, currency: data.currency || "USD", // Tax fields subtotal: data.subtotal, tax_amount: data.tax_amount, tax_rate: data.tax_rate, tax_inclusive: data.tax_inclusive, tax_details: data.tax_details, tax_breakdown: data.tax_breakdown, taxable_amount: data.taxable_amount, // Itinerary costs itinerary_costs: data.itinerary_costs, itinerary_costs_total: data.itinerary_costs_total, // Additional services additional_services: data.additional_services, services_total: data.services_total, // End tax fields payment_status: data.payment_status, booking_status: data.status, payment_method: data.payment_gateway, payment_date: data.payment_date, notes: data.special_requests, internal_notes: data.internal_notes, emergency_contact: data.emergency_contact, contact_data: data.contact_data, payments: data.payments || [], created_at: data.created_at, updated_at: data.updated_at, confirmed_at: data.confirmed_at, completed_at: data.completed_at, cancelled_at: data.cancelled_at, cancellation_reason: data.cancellation_reason, trip_details: data.trip_details, google_calendar: data.google_calendar as | GoogleCalendarSyncInfo | undefined, }; } return null; }, enabled: !!bookingId && can("yatra_view_bookings"), }); // Fetch consent status for this booking (only if Pro is active) const isPro = !!(window as any).yatraAdmin?.isPro; const { data: consentStatus } = useQuery({ queryKey: ["booking-consent-status", bookingId], queryFn: async () => { if (!bookingId) return null; const response = await apiClient.get( `/bookings/${bookingId}/consent-status`, ); return response?.data || null; }, enabled: !!bookingId && isPro, }); const formatDate = (dateString: string) => { return formatDateUtil(dateString); }; const formatPrice = (price: number, currencyCode: string = "USD") => formatYatraMoney(Number(price) || 0, currencyCode, { zeroAsUnknown: false, }); const getBookingStatusBadge = (status: string) => { const statusMap: Record = { confirmed: { className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", label: __("Confirmed", "yatra"), }, pending: { className: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400", label: __("Pending", "yatra"), }, cancelled: { className: "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", label: __("Cancelled", "yatra"), }, completed: { className: "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400", label: __("Completed", "yatra"), }, }; const statusInfo = statusMap[status] || { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: status, }; return ( {statusInfo.label} ); }; const getPaymentStatusBadge = (status: string) => { const statusMap: Record = { paid: { className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", label: __("Paid", "yatra"), }, pending: { className: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400", label: __("Pending", "yatra"), }, partial: { className: "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400", label: __("Partial", "yatra"), }, refunded: { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: __("Refunded", "yatra"), }, }; const statusInfo = statusMap[status] || { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: status, }; return ( {statusInfo.label} ); }; const handleBack = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings`; }; const handleEdit = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=bookings&action=edit&id=${bookingId}`; }; if (isLoading) { return (
{/* Header Skeleton */}
{/* Main Content Skeleton */}
{/* Booking Overview Card */}
{[...Array(6)].map((_, i) => (
))}
{/* Customer Information Card */}
{/* Notes Card */}
{/* Sidebar Skeleton */}
{/* Payment Information Card */}
{/* Timeline Card */}
); } if (error || !booking) { return (
{__("Back to Bookings", "yatra")} } /> {__("Error loading booking or booking not found", "yatra")}
); } return (
} />
{/* Main Content */}
{/* Booking Overview */}
{__("Booking Overview", "yatra")}
{getBookingStatusBadge(booking.booking_status)} {getPaymentStatusBadge(booking.payment_status)}
{__("Booking Number", "yatra")}
{booking.booking_number}
{__("Trip", "yatra")}
{booking.trip_title}
{__("Trip ID", "yatra")}: #{booking.trip_id}
{__("Booking Date", "yatra")}
{formatDate(booking.booking_date)}
{__("Travel Date", "yatra")}
{formatDate(booking.travel_date)}
{__("Number of Travelers", "yatra")}
{booking.travelers}{" "} {booking.travelers === 1 ? __("Traveler", "yatra") : __("Travelers", "yatra")}
{/* Payment Summary */}

{__("Payment Summary", "yatra")}

{(() => { // ── Raw figures ────────────────────────────────────────── const dbSubtotal = parseFloat((booking as any).subtotal) || booking.total_amount || 0; const discountAmount = booking.discount_amount || 0; const itineraryCostsTotal = parseFloat((booking as any).itinerary_costs_total) || 0; const taxAmount = parseFloat((booking as any).tax_amount) || 0; // Handle both PHP bool (true/false) and int/string (1/0/"1"/"0") const taxInclusiveRaw = (booking as any).tax_inclusive; const taxInclusive = taxInclusiveRaw === true || parseInt(String(taxInclusiveRaw), 10) === 1; const totalAmount = booking.total_amount || 0; const amountPaid = booking.amount_paid || 0; const amountDue = booking.amount_due || 0; // ── Tax breakdown ──────────────────────────────────────── let taxLines: { name: string; rate: number; amount: number; }[] = []; const taxBreakdownRaw = (booking as any).tax_breakdown; if ( Array.isArray(taxBreakdownRaw) && taxBreakdownRaw.length > 0 ) { taxLines = taxBreakdownRaw; } else { const taxDetailsRaw = (booking as any).tax_details; if (taxDetailsRaw) { try { const parsed = typeof taxDetailsRaw === "string" ? JSON.parse(taxDetailsRaw) : taxDetailsRaw; if (Array.isArray(parsed) && parsed.length > 0) taxLines = parsed; } catch (_) {} } if (taxLines.length === 0 && taxAmount > 0) { taxLines = [ { name: __("Tax", "yatra"), rate: parseFloat((booking as any).tax_rate) || 0, amount: taxAmount, }, ]; } } // ── Itinerary costs ────────────────────────────────────── let itineraryCosts: any[] = []; const itineraryCostsRaw = (booking as any).itinerary_costs; if (typeof itineraryCostsRaw === "string") { try { itineraryCosts = JSON.parse(itineraryCostsRaw); } catch (_) {} } else if (Array.isArray(itineraryCostsRaw)) { itineraryCosts = itineraryCostsRaw; } // ── Services ───────────────────────────────────────────── const rawServices = (booking as any).additional_services; const selectedServices: any[] = Array.isArray(rawServices) ? rawServices.filter((s: any) => s?.selected !== false) : []; const getServiceAmt = (s: any) => parseFloat( s?.total_price ?? s?.calculated_price ?? s?.total_cost ?? s?.amount ?? s?.unit_price ?? s?.price ?? 0, ); const servicesTotal = selectedServices.reduce( (sum, s) => sum + getServiceAmt(s), 0, ); const baseTripCost = dbSubtotal - servicesTotal; // ── Derived amounts ────────────────────────────────────── const taxableAmount = Math.max(0, dbSubtotal - discountAmount) + itineraryCostsTotal; const hasServices = selectedServices.length > 0; const hasDiscount = discountAmount > 0; const hasItinerary = itineraryCosts.length > 0 && itineraryCostsTotal > 0; const hasTax = taxLines.length > 0; const showTaxableRow = hasTax && (hasDiscount || hasItinerary); const Row = ({ label, value, sub = false, green = false, bold = false, }: any) => (
{label} {value}
); return (
{/* Trip Base Price — always the first line */}
{__("Trip Base Price", "yatra")} {taxInclusive && ( {__("Tax Incl.", "yatra")} )} {formatPrice(baseTripCost, booking.currency)}
{/* Additional Services section — only when services exist */} {hasServices && ( <>
+ {__("Additional Services", "yatra")}
{selectedServices.map((s: any, i: number) => ( ))} {/* Gross Total = Trip Base Price + Services */}
{__("Gross Total", "yatra")} {formatPrice(dbSubtotal, booking.currency)}
)} {/* Discount */} {hasDiscount && ( )} {/* Itinerary Costs section */} {hasItinerary && ( <>
+ {__("Itinerary Costs", "yatra")}
{itineraryCosts.map((cost: any, i: number) => ( ))} )} {/* Taxable Amount — only for exclusive tax when discount/itinerary exist above it */} {!taxInclusive && showTaxableRow && (
{__("Taxable Amount", "yatra")} {formatPrice(taxableAmount, booking.currency)}
)} {/* Exclusive tax — shown as an addition before Net Amount */} {hasTax && !taxInclusive && ( <>
+ {__("Tax", "yatra")}
{taxLines.map((tax: any, i: number) => (
{tax.name} {tax.rate > 0 ? ` (${tax.rate}%)` : ""} + {formatPrice( parseFloat(tax.amount) || 0, booking.currency, )}
))} )} {/* Net Amount */}
{__("Net Amount", "yatra")} {formatPrice(totalAmount, booking.currency)}
{/* Inclusive tax — informational footnote after Net Amount */} {hasTax && taxInclusive && (
{taxLines.map((tax: any, i: number) => (
{__("Incl.", "yatra")} {tax.name} {tax.rate > 0 ? ` (${tax.rate}%)` : ""}:{" "} {formatPrice( parseFloat(tax.amount) || 0, booking.currency, )}
))}
)} {/* Amount Paid */} {amountPaid > 0 && ( )} {/* Due Now */} {amountDue > 0 && (
{__("Due Now", "yatra")} {formatPrice(amountDue, booking.currency)}
)}
); })()}
{/* Customer Information */} {__("Customer Information", "yatra")}
{booking.customer_name}
{booking.customer_email}
{booking.customer_phone && (
{booking.customer_phone}
)}
{/* Country + dynamic/custom contact fields (nationality, address, and any custom contact field). Mirrors the Emergency/Traveler dynamic-field display. */} {(() => { const cd = booking.contact_data && typeof booking.contact_data === "object" ? (booking.contact_data as Record) : {}; const CORE = ["first_name", "last_name", "email", "phone"]; const extras = Object.entries(cd).filter( ([k, v]) => !CORE.includes(k) && k !== "country" && v != null && String(v).trim() !== "", ); const countryCode = booking.customer_country || (cd.country ? String(cd.country) : ""); if (!countryCode && extras.length === 0) return null; return (
{countryCode && (
{getFieldLabel("country", contactFields)}
{getCountryName(countryCode)}
)} {extras.map(([fieldId, value]) => { const field = contactFields.find( (f) => f.id === fieldId, ); const display = field?.type === "country" ? getCountryName(String(value)) : String(value); return (
{getFieldLabel(fieldId, contactFields)}
{display}
); })}
); })()}
{/* Travelers Information - Dynamic Fields */} {booking.travelers_data && booking.travelers_data.length > 0 && ( {formConfig?.traveler_form?.title || __("Travelers Information", "yatra")} ({booking.travelers_data.length}{" "} {booking.travelers_data.length === 1 ? __("traveler", "yatra") : __("travelers", "yatra")} ) {booking.travelers_data.map( (traveler: any, index: number) => { // Extract fields from traveler - handle both flat structure and nested fields property const travelerFieldsData = traveler.fields || traveler; const systemFields = [ "id", "booking_id", "traveller_index", "is_lead", "created_at", "updated_at", "fields", ]; // Get all non-empty fields from the traveler data, excluding system fields const travelerEntries = Object.entries( travelerFieldsData, ).filter(([key, value]) => { // Exclude system fields if (systemFields.includes(key)) return false; // Exclude empty values if ( !value || (typeof value === "string" && value.trim() === "") ) return false; // Exclude objects (they should be in fields) if (typeof value === "object" && !Array.isArray(value)) return false; return true; }); // Get name from fields or direct properties const firstName = travelerFieldsData.first_name || traveler.first_name || ""; const lastName = travelerFieldsData.last_name || traveler.last_name || ""; return (

{index === 0 ? __("Lead Traveler", "yatra") : `${__("Traveler", "yatra")} ${index + 1}`} {/* Show name if available */} {(firstName || lastName) && ( -{" "} {[firstName, lastName] .filter(Boolean) .join(" ")} )}

{index === 0 && ( {__("Primary Contact", "yatra")} )}
{/* Dynamic Fields Display */}
{travelerEntries.map(([fieldId, fieldValue]) => { // Skip first_name and last_name as they're shown in header if ( fieldId === "first_name" || fieldId === "last_name" ) return null; const fieldConfig = travelerFields.find( (f) => f.id === fieldId, ); const label = fieldConfig?.label || getFieldLabel(fieldId, travelerFields); const isLongField = fieldConfig?.type === "textarea" || String(fieldValue).length > 50; // Format date fields let displayValue = String(fieldValue); if ( fieldConfig?.type === "date" || fieldId.includes("date") || fieldId.includes("expiry") ) { try { displayValue = new Date( fieldValue as string, ).toLocaleDateString(); } catch { displayValue = String(fieldValue); } } // Format country/nationality fields - convert code to full name if ( (fieldId === "nationality" || fieldId === "country") && fieldValue && typeof fieldValue === "string" && fieldValue.length === 2 ) { displayValue = getCountryName(fieldValue); } return (
{label}
{displayValue}
); })}
{travelerEntries.length <= 2 && (
{__( "Limited traveler information provided", "yatra", )}
)}
); }, )}
)} {/* Emergency Contact - Dynamic Fields */} {booking.emergency_contact && Object.values(booking.emergency_contact).some( (v) => v && String(v).trim() !== "", ) && ( {formConfig?.emergency_contact_form?.title || __("Emergency Contact", "yatra")}
{Object.entries(booking.emergency_contact) .filter( ([_, value]) => value && String(value).trim() !== "", ) .map(([fieldId, fieldValue]) => { const label = getFieldLabel(fieldId, emergencyFields); return (
{label}
{String(fieldValue)}
); })}
)} {/* Notes */} {booking.notes && ( {__("Special Requests", "yatra")}

{booking.notes}

)}
{/* Sidebar */}
{/* Google Calendar Sync */} {(window as any).yatraAdmin?.isPro && !!(window as any).yatraAdmin?.googleCalendar?.enabled && (booking as any).google_calendar && ( {__("Google Calendar Sync", "yatra")} {(() => { const gc = (booking as any) .google_calendar as GoogleCalendarSyncInfo; const status = gc.sync_status || (gc.synced ? "synced" : "not_synced"); const statusClass = status === "synced" ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400" : status === "failed" ? "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400" : "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400"; return ( <>
{__("Status", "yatra")}
{status}
{__("Last Synced", "yatra")}
{gc.last_synced_at ? formatDate(gc.last_synced_at) : __("—", "yatra")}
{__("Calendar", "yatra")}
{gc.calendar_id || __("—", "yatra")}
{__("Event ID", "yatra")}
{gc.event_id || __("—", "yatra")}
{gc.error_message && (
{__("Error", "yatra")}
{gc.error_message}
)} ); })()}
)} {/* Payment Information */} {__("Payment Information", "yatra")}
{__("Payment Status", "yatra")}
{getPaymentStatusBadge(booking.payment_status)}
{booking.payment_method && (
{__("Payment Method", "yatra")}
{booking.payment_method}
)}
{__("Trip Price per Person", "yatra")} {((v) => v === true || parseInt(String(v), 10) === 1)( (booking as any).tax_inclusive, ) && ( {__("Tax Incl.", "yatra")} )}
{(() => { // subtotal (DB) = base trip cost + services when Pro is active. // Subtract services total to get the pure trip base cost. const subtotal = parseFloat((booking as any).subtotal) || booking.total_amount || 0; const travelers = booking.travelers || 1; const rawServices = (booking as any).additional_services; const servicesTotal = Array.isArray(rawServices) ? rawServices .filter((s: any) => s?.selected !== false) .reduce( (sum: number, s: any) => sum + parseFloat( s?.total_price ?? s?.calculated_price ?? s?.total_cost ?? s?.amount ?? s?.unit_price ?? s?.price ?? 0, ), 0, ) : 0; const baseTripCost = subtotal - servicesTotal; return formatPrice( baseTripCost / travelers, booking.currency, ); })()}
{__("Total Amount", "yatra")} {((v) => v === true || parseInt(String(v), 10) === 1)( (booking as any).tax_inclusive, ) && ( {__("Tax Incl.", "yatra")} )}
{formatPrice(booking.total_amount || 0, booking.currency)}
{/* Discount Applied Card */} {booking.discount_amount > 0 && ( {__("Discount Applied", "yatra")}
{__("Discount Type", "yatra")}
{booking.discount_code?.toLowerCase().includes("group") || booking.discount_code?.includes("GROUP") ? __("Group Discount", "yatra") : __("Coupon Discount", "yatra")}
{booking.discount_code && (
{__("Code", "yatra")}
{booking.discount_code}
)}
{__("Savings", "yatra")}
-{formatPrice(booking.discount_amount, booking.currency)}
)} {/* Consent Status Card - Only show if Pro is active and there are consent forms */} {isPro && consentStatus && consentStatus.total_required > 0 && ( {__("Consent Status", "yatra")}
{consentStatus.all_signed ? ( {__("All Consents Signed", "yatra")} ) : ( {__("Pending Signatures", "yatra")} )}
{__("Signed", "yatra")} {consentStatus.total_signed} /{" "} {consentStatus.total_required}
{/* Progress bar */}
{/* Pending requests */} {consentStatus.pending_requests && consentStatus.pending_requests.length > 0 && (
{__("Pending", "yatra")}
{consentStatus.pending_requests .slice(0, 3) .map((req: any, idx: number) => (
{req.recipient_name || req.recipient_email}
))} {consentStatus.pending_requests.length > 3 && (
+{consentStatus.pending_requests.length - 3}{" "} {__("more", "yatra")}
)}
)} {/* Link to consent management */} )} {/* Booking Timeline */} {__("Timeline", "yatra")}
{__("Created", "yatra")}
{formatDate(booking.created_at)}
{booking.updated_at && booking.updated_at !== booking.created_at && (
{__("Last Updated", "yatra")}
{formatDate(booking.updated_at)}
)}
); }; export default ViewBooking;