/** * Booking Form Page * Add/Edit Booking form with clean, minimal SaaS-style design */ import React, { useState, useEffect, useMemo, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Save, Loader2, Plus, Trash2, Users, ChevronDown, ChevronUp, AlertCircle, Search, X, } from "lucide-react"; import { __ } from "../lib/i18n"; import { formatDate as formatDateUtil, formatDateForInput, todayYmd, } from "../lib/dateFormat"; import { getCountryOptions } from "../lib/countries"; import { apiService } from "../lib/api-client"; import { usePermissions } from "../hooks/usePermissions"; import { getCurrencySymbol } from "../data/currencies"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; 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 { DatePicker } from "../components/ui/date-picker"; import { taxService } from "../services/TaxService"; // Dynamic traveler data - keys are field IDs from form config interface TravelerData { [key: string]: string; } interface FormFieldConfig { id: string; type: string; label: string; placeholder?: string; required: boolean; enabled: boolean; order: number; width?: string; options?: { value: string; label: string }[]; section?: string; /** Traveler section: "lead" shows only for the lead traveler; "all"/absent = every traveler. */ applies_to?: "all" | "lead"; } interface FormSectionConfig { title: string; description?: string; enabled: boolean; fields: FormFieldConfig[]; } interface BookingFormConfig { contact_form: FormSectionConfig; emergency_contact_form: FormSectionConfig; traveler_form: FormSectionConfig; } interface BookingFormData { customer_name: string; customer_email: string; customer_phone: string; customer_country: string; trip_id: string; booking_date: string; travel_date: string; travelers: string; subtotal: string; tax_amount: string; total_amount: string; currency: string; payment_status: string; booking_status: string; payment_method: string; notes: string; } // Country list — pulled from the canonical source (PHP // FormatHelper::getCountries() → localized to window.yatraAdmin.countries). // One source of truth; operators that want a curated subset apply // the `yatra_countries_list` PHP filter once and every dropdown // (admin + public booking + Pro modules) picks it up. const countryList = getCountryOptions(); // Core contact fields rendered explicitly (name/email/phone/country); everything // else in the contact form is treated as an "extra/custom" field. const CORE_CONTACT_IDS = [ "first_name", "last_name", "email", "phone", "country", ]; // Normalize ISO/date-like string to formatted date using shared date library const normalizeDateInput = (value?: string | null) => { if (!value) return ""; // DatePicker strictly requires YYYY-MM-DD; use formatDateForInput to ensure correct format return formatDateForInput(value) || value; }; const BookingForm: React.FC = () => { const queryClient = useQueryClient(); const { can } = usePermissions(); // Get global currency settings const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD"; const currencySymbol = getCurrencySymbol(globalCurrency); const [formData, setFormData] = useState({ customer_name: "", customer_email: "", customer_phone: "", customer_country: "", trip_id: "", booking_date: todayYmd(), travel_date: "", travelers: "1", subtotal: "", tax_amount: "", total_amount: "", currency: globalCurrency, payment_status: "pending", booking_status: "pending", payment_method: "", notes: "", }); const [travelersData, setTravelersData] = useState([{}]); const [expandedTravelers, setExpandedTravelers] = useState([0]); const [emergencyContactData, setEmergencyContactData] = useState< Record >({}); // Extra/custom contact fields (everything beyond the core name/email/phone/ // country handled by formData), keyed by field id — kept in sync with the // booking's contact_data JSON so admins can edit custom contact fields. const [contactExtraData, setContactExtraData] = useState< Record >({}); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); // Trip search state const [tripSearchQuery, setTripSearchQuery] = useState(""); const [isTripDropdownOpen, setIsTripDropdownOpen] = useState(false); const tripDropdownRef = useRef(null); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( tripDropdownRef.current && !tripDropdownRef.current.contains(event.target as Node) ) { setIsTripDropdownOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Fetch booking form configuration 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 from config const emergencyContactFields = 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]); // Contact fields beyond the core ones already rendered above (name/email/ // phone/country) — i.e. nationality, address, and any CUSTOM contact fields. const contactExtraFields = useMemo(() => { if (!formConfig?.contact_form?.fields) return []; if (formConfig.contact_form.enabled === false) return []; return formConfig.contact_form.fields .filter((field) => field.enabled && !CORE_CONTACT_IDS.includes(field.id)) .sort((a, b) => a.order - b.order); }, [formConfig]); // Create empty traveler based on enabled fields const createEmptyTraveler = (): TravelerData => { const empty: TravelerData = {}; travelerFields.forEach((field) => { empty[field.id] = ""; }); return empty; }; // Get action and id from URL const action = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("action") || "create"; }, []); const bookingId = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("id") ? parseInt(params.get("id") || "0") : null; }, []); const isEditMode = action === "edit" && bookingId !== null; // Fetch payment gateways for dropdown const { data: gatewaysData } = useQuery({ queryKey: ["payment-gateways"], queryFn: async () => { const result = await apiService.getPaymentGateways(); if (result?.success) { return { data: result.data .filter((gw: any) => gw.enabled) .map((gw: any) => ({ id: gw.id, title: gw.title || gw.name, })), }; } return { data: [] }; }, }); // Fetch trips for dropdown - get all trips regardless of status const { data: tripsData, isLoading: isLoadingTrips } = useQuery({ queryKey: ["trips-list-all"], queryFn: async () => { // Fetch all trips - try without status filter first, then with status=all return await apiService.getTrips({ per_page: 500 }); }, // Always fetch trips when user can manage bookings enabled: can("yatra_view_bookings") || can("yatra_view_trips"), retry: 1, }); // Fetch booking data if editing const { data: bookingData, isLoading: isLoadingBooking } = 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 booking = (result as any)?.data ?? result; if (booking && booking.id) { // Use customer_name if available, otherwise construct from first/last name const contact = booking.contact || {}; const firstName = booking.contact_first_name || contact.first_name || ""; const lastName = booking.contact_last_name || contact.last_name || ""; const customerName = booking.customer_name || `${firstName} ${lastName}`.trim(); // Parse travelers data - dynamic fields based on stored data let travelers: TravelerData[] = []; if (booking.travelers && Array.isArray(booking.travelers)) { // Map all stored fields dynamically (keys are field IDs from form builder) // The API returns travelers with a nested 'fields' object containing the form data travelers = booking.travelers.map((t: any) => { const traveler: TravelerData = {}; // Check if fields are nested in a 'fields' property (from TravellerRepository) const fieldsData = t.fields || t; if (fieldsData && typeof fieldsData === "object") { Object.entries(fieldsData).forEach(([key, value]) => { // Skip internal fields like id, booking_id, traveller_index, etc. if ( ![ "id", "booking_id", "traveller_index", "is_lead", "created_at", "updated_at", ].includes(key) ) { traveler[key] = String(value || ""); } }); } // Also copy is_lead if present if (t.is_lead !== undefined) { traveler["is_lead"] = String(t.is_lead); } return traveler; }); } // Ensure at least one traveler exists if (travelers.length === 0) { travelers = [{}]; // Empty object - fields will be populated from form config } // Parse emergency contact data let emergencyContact: Record = {}; if ( booking.emergency_contact && typeof booking.emergency_contact === "object" ) { Object.entries(booking.emergency_contact).forEach(([key, value]) => { emergencyContact[key] = String(value || ""); }); } else if ( contact.emergency_name || contact.emergency_phone || contact.emergency_relationship ) { emergencyContact = { name: String(contact.emergency_name || ""), phone: String(contact.emergency_phone || ""), relationship: String(contact.emergency_relationship || ""), }; } const mappedData = { id: booking.id, customer_name: customerName, customer_email: booking.customer_email || contact.email || booking.contact_email || "", customer_phone: booking.customer_phone || contact.phone || booking.contact_phone || "", customer_country: (booking as any).contact_country || "", trip_id: String(booking.trip_id || ""), trip_title: booking.trip_title || "", booking_date: normalizeDateInput( booking.booking_date ?? booking.created_at ?? new Date().toISOString(), ), travel_date: normalizeDateInput(booking.travel_date ?? ""), travelers: String(booking.travelers || "1"), travelers_data: travelers, // Add the correctly parsed travelers data subtotal: String( (booking as any).subtotal || booking.total_amount || "", ), tax_amount: String((booking as any).tax_amount || "0"), total_amount: String(booking.total_amount || ""), currency: booking.currency || "USD", payment_status: booking.payment_status || "pending", booking_status: booking.booking_status || "pending", payment_method: booking.payment_method || "", notes: booking.notes || "", emergency_contact: emergencyContact, contact_data: (booking as any).contact_data || null, }; return mappedData; } return null; }, enabled: isEditMode && can("yatra_view_bookings"), }); // Track if we've loaded booking data to prevent auto-calculation from overwriting const [isDataLoaded, setIsDataLoaded] = useState(false); // Load booking data into form when editing useEffect(() => { if (bookingData && isEditMode) { setFormData({ customer_name: bookingData.customer_name || "", customer_email: bookingData.customer_email || "", customer_phone: bookingData.customer_phone || "", customer_country: (bookingData as any).contact_country || (bookingData as any).customer_country || "", trip_id: String(bookingData.trip_id || ""), booking_date: bookingData.booking_date || todayYmd(), travel_date: bookingData.travel_date || "", travelers: String( (bookingData as any).travelers_count || bookingData.travelers_data?.length || "1", ), subtotal: String( (bookingData as any).subtotal || bookingData.total_amount || "", ), tax_amount: String((bookingData as any).tax_amount || "0"), total_amount: String(bookingData.total_amount || ""), currency: bookingData.currency || "USD", payment_status: bookingData.payment_status || "pending", booking_status: bookingData.booking_status || "pending", payment_method: bookingData.payment_method || "", notes: (bookingData as any).special_requests || bookingData.notes || "", }); // Set travelers data - use the correctly parsed travelers_data if ( bookingData.travelers_data && Array.isArray(bookingData.travelers_data) && bookingData.travelers_data.length > 0 ) { setTravelersData(bookingData.travelers_data); // Expand first traveler by default setExpandedTravelers([0]); } else { // Ensure at least one empty traveler setTravelersData([{}]); setExpandedTravelers([0]); } // Set emergency contact data if (bookingData.emergency_contact) { setEmergencyContactData(bookingData.emergency_contact); } // Set extra/custom contact fields from the booking's contact_data JSON // (everything beyond the core name/email/phone/country handled above). const cd = (bookingData as any).contact_data; if (cd && typeof cd === "object") { const extras: Record = {}; Object.entries(cd).forEach(([k, v]) => { if (!CORE_CONTACT_IDS.includes(k)) { extras[k] = v == null ? "" : String(v); } }); setContactExtraData(extras); } setIsDataLoaded(true); } }, [bookingData, isEditMode]); // Auto-calculate pricing when trip or travelers change useEffect(() => { // Don't auto-calculate if we're in edit mode - user can manually update if needed if (isEditMode) return; if (formData.trip_id && formData.travelers) { const selectedTrip = tripsData?.data?.find( (trip: any) => String(trip.id) === formData.trip_id, ); if (selectedTrip) { const subtotal = selectedTrip.price * parseInt(formData.travelers); // Calculate tax const taxCalculation = taxService.calculateBookingTax( subtotal, formData.customer_country, ); setFormData((prev) => ({ ...prev, subtotal: taxCalculation.subtotal.toString(), tax_amount: taxCalculation.tax_amount.toString(), total_amount: taxCalculation.total_amount.toString(), })); } } }, [ formData.trip_id, formData.travelers, formData.customer_country, tripsData?.data, isEditMode, ]); // Load tax settings on mount useEffect(() => { taxService.loadTaxSettings(); }, []); const handleFieldChange = (field: keyof BookingFormData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: "" })); } }; // Emergency contact change handler const handleEmergencyContactChange = (field: string, value: string) => { setEmergencyContactData((prev) => ({ ...prev, [field]: value })); }; const handleContactExtraChange = (field: string, value: string) => { setContactExtraData((prev) => ({ ...prev, [field]: value })); }; // Shared renderer for a dynamic form field's input (matches the checkout // field types). Used for the extra/custom contact fields. const renderDynamicInput = ( field: FormFieldConfig, value: string, onChange: (v: string) => void, idPrefix: string, ) => { const id = `${idPrefix}-${field.id}`; if (field.type === "select") { return ( ); } if (field.type === "country") { return ( ); } if (field.type === "textarea") { return (