/** * YATRA TRAVEL BOOKING REPORTS * Essential reports for travel booking businesses * Based on deep understanding of Yatra business model */ import React, { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { __ } from "../lib/i18n"; import { formatDateForInput } from "../lib/dateFormat"; import { apiClient, apiService } from "../lib/api-client"; import { unwrapApiPayload } from "../lib/unwrap-api-payload"; import { useToast } from "../components/ui/toast"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Select } from "../components/ui/select"; import BookingsOverviewChart from "../components/charts/BookingsOverviewChart"; import BookingStatusChart from "../components/charts/BookingStatusChart"; import { formatYatraMoney } from "../lib/currency-display"; import { bucketSeries, bucketStatusSeries, buildCsv, csvFilename, downloadCsv, type SeriesPoint, type StatusPoint, type TrendView, } from "../lib/report-series"; import { isModuleActive, isProPluginActive } from "../lib/plugin-utils"; import { canCap } from "../hooks/useCapabilities"; // Skeleton Loading Components const SkeletonCard = () => (
); const SkeletonReportSection = () => (
{[...Array(4)].map((_, i) => (
))}
); // SVG Icons for Travel Reports const SVGIcons = { Calendar: () => ( ), DollarSign: () => ( ), MapPin: () => ( ), Truck: () => ( ), Users: () => ( ), Activity: () => ( ), Facebook: () => ( ), Google: () => ( ), Target: () => ( ), BarChart: () => ( ), XCircle: () => ( ), }; // Travel Business Report Categories // Each category declares the capability that gates it. The Reports // page filters this list by `usePermissions().can()` at render time // so an Accountant only sees Revenue + Operational tabs, a Marketing // role sees Customer + Operational + Pixel + GA, etc. Categories // without a `cap` field are visible to anyone who can see Reports // (the page itself is already gated at the sidebar layer). const TravelReportCategories: Array<{ id: string; title: string; icon: string; description: string; cap: string; }> = [ { id: "booking-overview", title: "Booking Overview", icon: "Calendar", description: "Booking volume, status distribution, trends", cap: "yatra_view_operational_reports", }, { id: "revenue-analysis", title: "Revenue Analysis", icon: "DollarSign", description: "Revenue trends, payment status, profitability", cap: "yatra_view_financial_reports", }, { id: "trip-performance", title: "Trip Performance", icon: "MapPin", description: "Trip popularity, occupancy rates, capacity utilization", cap: "yatra_view_operational_reports", }, { id: "departure-management", title: "Departure Management", icon: "Truck", description: "Upcoming departures, capacity planning, scheduling", cap: "yatra_view_departures", }, { id: "customer-insights", title: "Customer Insights", icon: "Users", description: "Customer behavior, retention, demographics", cap: "yatra_view_customers", }, { id: "operational-metrics", title: "Operational Metrics", icon: "Activity", description: "Lead times, cancellations, efficiency metrics", cap: "yatra_view_operational_reports", }, { id: "facebook-pixel", title: "Facebook Pixel", icon: "Facebook", description: "Conversion tracking, event analytics, pixel performance", cap: "yatra_view_operational_reports", }, { id: "google-analytics", title: "Google Analytics 4", icon: "Google", description: "Enhanced e-commerce tracking, Measurement Protocol, visitor analytics", cap: "yatra_view_operational_reports", }, ]; // Detailed Breakdown Chart Component // // REWRITE NOTE (3.0.5): // The previous implementation fabricated daily/weekly/monthly values by // multiplying period totals by a `seasonalFactor` so the chart "looked // like" historical data. Operators were making decisions on numbers // that didn't exist in the database. This version reads day-level // trends straight from `/reports` (booking_trend / revenue_trend / // occupancy_trend) and buckets them via `report-series.bucketSeries` // when the operator picks Weekly or Monthly. The numbers always come // from real bookings. const DetailedBreakdownChart: React.FC<{ viewType: string; dateRange: string; selectedCategory: string; reportData?: any; }> = ({ viewType, selectedCategory, reportData }) => { const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD"; const formatCurrencyAmount = (amount: number) => formatYatraMoney(Number(amount) || 0, globalCurrency, { zeroAsUnknown: false, }); // Pick the right source series for the selected report category. // Each is already day-aligned and gap-filled by the backend. const sourceSeries: SeriesPoint[] = useMemo(() => { if (!reportData) return []; if (selectedCategory === "revenue-analysis") { return (reportData.revenue_trend as SeriesPoint[]) || []; } if (selectedCategory === "departure-management") { // occupancy_trend stores rate-per-day (already aggregated). For a // bar chart we want a comparable absolute (counts of departures), // so we derive from departures_table when present. Fall back to // booking_trend so the chart never goes empty. const dep = reportData.departures_table || []; if (Array.isArray(dep) && dep.length > 0) { const byDay = new Map(); for (const d of dep) { const date = (d?.date || "").slice(0, 10); if (!date) continue; byDay.set(date, (byDay.get(date) || 0) + 1); } return Array.from(byDay.entries()).map(([date, value]) => ({ date, label: date, value, })); } return (reportData.booking_trend as SeriesPoint[]) || []; } return (reportData.booking_trend as SeriesPoint[]) || []; }, [reportData, selectedCategory]); // viewType can be "summary" (a synthetic UI option that maps to // "daily" for the chart) or any TrendView. Coerce defensively. const view: TrendView = viewType === "weekly" || viewType === "monthly" ? viewType : "daily"; const bucketed = useMemo( () => bucketSeries(sourceSeries, view), [sourceSeries, view], ); // Empty-state guard: the chart formerly fell back to fabricated // values; now we render an honest "no data" instead. Better the // operator sees the truth than a synthetic 12-bar chart. if (!reportData || bucketed.length === 0) { return (
{__("No data in this period yet.", "yatra")}
); } // Find max for bar-width normalisation. Guard against zero. const maxValue = Math.max(1, ...bucketed.map((p) => p.value)); const isRevenue = selectedCategory === "revenue-analysis"; const isDepartures = selectedCategory === "departure-management"; const barClass = isRevenue ? "bg-emerald-500" : isDepartures ? "bg-purple-500" : "bg-blue-500"; const valueLabel = (v: number) => isRevenue ? formatCurrencyAmount(v) : v.toLocaleString(); return (

{isRevenue ? __("Revenue Trend", "yatra") : isDepartures ? __("Departures Trend", "yatra") : __("Bookings Trend", "yatra")}

{/* Bucketed bar chart — values are real data straight from the backend trend arrays. Bar widths normalised against the max in the bucketed set so a single outlier doesn't squash the rest into invisibility. */}
{bucketed.map((item, index) => (
{item.label}
{valueLabel(item.value)}
))}
{isRevenue ? __("Revenue", "yatra") : isDepartures ? __("Departures", "yatra") : __("Bookings", "yatra")}
); }; // Detailed Breakdown Table Component // // REWRITE NOTE (3.0.5): // Replaces the previous synthetic data path that: // - Fabricated daily/weekly/monthly bookings + revenue from period // totals * a `seasonalFactor` // - Made up confirmed/pending/cancelled splits (80/15/5 fixed ratio) // - Made up customer satisfaction (90 * factor), efficiency (85 * // factor), lead time (7..14 * inverse-factor) // - Cycled "topTrip" through trip_performance[i % 3] regardless of // which period the row represented // // New version reads four trend arrays from the backend, all day-aligned: // - booking_trend : { date, label, value } total bookings/day // - revenue_trend : { date, label, value } revenue/day // - status_trend : { date, label, confirmed, pending, cancelled, // completed } per-day status split // - traveler_segments.trend : { date, label, value } travellers/day // // We bucket those into the operator's selected view (daily/weekly/ // monthly) using report-series.bucketSeries / bucketStatusSeries and // render only columns we can stand behind. Columns whose source data // doesn't exist (customer satisfaction, efficiency, top-trip-per- // period) are removed — better than rendering invented numbers. const DetailedBreakdownTable: React.FC<{ viewType: string; dateRange: string; selectedCategory: string; reportData: any; }> = ({ viewType, selectedCategory, reportData }) => { // Suppress lints — these props are intentionally received for API // stability with the parent but the view bucket pipeline doesn't // need dateRange directly; backend already handed us the slice. // (Recovery of formatCurrencyAmount happens later in the component.) // viewType can be "summary" (UI-only) or a real TrendView. Coerce // anything that's not weekly/monthly to "daily" — same rule the // chart uses, so chart + table always agree on bucketing. const effectiveView: TrendView = viewType === "weekly" || viewType === "monthly" ? viewType : "daily"; const bookingTrend: SeriesPoint[] = (reportData?.booking_trend as SeriesPoint[]) || []; const revenueTrend: SeriesPoint[] = (reportData?.revenue_trend as SeriesPoint[]) || []; const statusTrend: StatusPoint[] = (reportData?.status_trend as StatusPoint[]) || []; const occupancyTrend: SeriesPoint[] = (reportData?.occupancy_trend as SeriesPoint[]) || []; // Bucket each series identically so rows in the same bucket line up. const bookingsBucketed = bucketSeries(bookingTrend, effectiveView); const revenueBucketed = bucketSeries(revenueTrend, effectiveView); const statusBucketed = bucketStatusSeries(statusTrend, effectiveView); // For occupancy per period: we have a per-day rate (%), and rates // don't sum across days — they average. Recompute properly using // departures_table when bucketing. const departuresTable = (reportData?.departures_table as any[]) || []; type OccupancyAgg = { booked: number; capacity: number }; const occByDay = new Map(); for (const d of departuresTable) { const date = (d?.date || "").slice(0, 10); if (!date) continue; const acc = occByDay.get(date) || { booked: 0, capacity: 0 }; acc.booked += Number(d?.bookedSeats ?? d?.booked ?? 0); acc.capacity += Number(d?.maxSeats ?? d?.capacity ?? 0); occByDay.set(date, acc); } // Same bucket-key strategy used by bucketSeries — re-derive labels. const occBookingSeries: SeriesPoint[] = Array.from(occByDay.entries()).map( ([date, agg]) => ({ date, label: date, value: agg.booked }), ); const occCapacitySeries: SeriesPoint[] = Array.from(occByDay.entries()).map( ([date, agg]) => ({ date, label: date, value: agg.capacity }), ); const occBookedBucketed = bucketSeries(occBookingSeries, effectiveView); const occCapacityBucketed = bucketSeries(occCapacitySeries, effectiveView); // Build the unified row set the table iterates. Each row carries // every field a category column might want; unsupported fields stay // null so the renderer can show "—" instead of a fabricated number. type Row = { period: string; fullDate: string; // Booking columns bookings: number; confirmed: number; pending: number; cancelled: number; // Revenue revenue: number; avgBookingValue: number; // Trip performance occupancyRate: number; booked: number; capacity: number; // Departures departures: number; }; const breakdownData: Row[] = bookingsBucketed.map((bRow, i) => { const rev = revenueBucketed[i]?.value ?? 0; const status = statusBucketed[i]; const ob = occBookedBucketed.find((p) => p.label === bRow.label); const oc = occCapacityBucketed.find((p) => p.label === bRow.label); const bookedSeats = ob?.value ?? 0; const capacitySeats = oc?.value ?? 0; const occRate = capacitySeats > 0 ? Math.round((bookedSeats / capacitySeats) * 1000) / 10 : 0; return { period: bRow.label, fullDate: bRow.date, bookings: bRow.value, confirmed: status?.confirmed ?? 0, pending: status?.pending ?? 0, cancelled: status?.cancelled ?? 0, revenue: rev, avgBookingValue: bRow.value > 0 ? Math.round(rev / bRow.value) : 0, occupancyRate: occRate, booked: bookedSeats, capacity: capacitySeats, departures: capacitySeats > 0 ? 1 : 0, // count via departures_table when available }; }); // Restore departures count if departures_table has them by bucket. if (departuresTable.length > 0) { const depCountSeries: SeriesPoint[] = (() => { const m = new Map(); for (const d of departuresTable) { const date = (d?.date || "").slice(0, 10); if (!date) continue; m.set(date, (m.get(date) || 0) + 1); } return Array.from(m.entries()).map(([date, value]) => ({ date, label: date, value, })); })(); const depBucketed = bucketSeries(depCountSeries, effectiveView); breakdownData.forEach((row) => { const hit = depBucketed.find((p) => p.label === row.period); row.departures = hit?.value ?? 0; }); } // Suppress legacy unused references to avoid TS dead-code warnings // until the rest of the table renderer is also pruned. void occupancyTrend; const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD"; const formatCurrencyAmount = (amount: number) => formatYatraMoney(Number(amount) || 0, globalCurrency, { zeroAsUnknown: false, }); // Render different table headers and columns based on category const renderTableHeaders = () => { switch (selectedCategory) { case "booking-overview": return ( {__("Period", "yatra")} {__("Total Bookings", "yatra")} {__("Confirmed", "yatra")} {__("Pending", "yatra")} {__("Cancelled", "yatra")} ); case "revenue-analysis": // Dropped "Collected" / "Outstanding" columns: we don't track // per-period collected vs. outstanding at the trend level. The // payment_status block in the Revenue Analysis section shows // the period-aggregate paid/pending/refunded split — that's // the right place for it. return ( {__("Period", "yatra")} {__("Total Revenue", "yatra")} {__("Bookings", "yatra")} {__("Avg Booking Value", "yatra")} ); case "trip-performance": // Dropped "Top Trip" column: top trip is a period-level concept, // not a per-bucket one. The Top Trips card above the table // shows the real ranking. return ( {__("Period", "yatra")} {__("Trip Bookings", "yatra")} {__("Occupancy Rate", "yatra")} {__("Revenue", "yatra")} ); case "departure-management": return ( {__("Period", "yatra")} {__("Departures", "yatra")} {__("Total Capacity", "yatra")} {__("Booked", "yatra")} {__("Utilization", "yatra")} ); case "customer-insights": return ( {__("Period", "yatra")} {__("New Customers", "yatra")} {__("Returning Customers", "yatra")} {__("Satisfaction", "yatra")} {__("Total Revenue", "yatra")} ); case "operational-metrics": return ( {__("Period", "yatra")} {__("Lead Time (days)", "yatra")} {__("Cancellation Rate", "yatra")} {__("Efficiency", "yatra")} {__("Bookings", "yatra")} ); default: return ( {__("Period", "yatra")} {__("Bookings", "yatra")} {__("Revenue", "yatra")} ); } }; const renderTableRows = () => { return breakdownData.map((row, index) => ( {row.period} {selectedCategory === "booking-overview" && ( <> {row.bookings} {row.confirmed} {row.pending} {row.cancelled} )} {selectedCategory === "revenue-analysis" && ( <> {formatCurrencyAmount(row.revenue)} {row.bookings} {formatCurrencyAmount(row.avgBookingValue)} )} {selectedCategory === "trip-performance" && ( <> {row.bookings}
{row.occupancyRate}%
{formatCurrencyAmount(row.revenue)} )} {selectedCategory === "departure-management" && ( <> {row.departures} {row.capacity} {row.booked}
0 ? `${Math.round((row.booked / row.capacity) * 100)}%` : "0%", }} >
{row.capacity > 0 ? Math.round((row.booked / row.capacity) * 100) : 0} %
)} )); }; // Customer-Insights + Operational-Metrics no longer ship a per-period // breakdown table — we don't aggregate those metrics day-by-day yet, // and the previous version filled them with synthetic numbers. Show // a calm note pointing the operator at the summary cards above. const categoriesWithoutBreakdown = new Set([ "customer-insights", "operational-metrics", ]); if (categoriesWithoutBreakdown.has(selectedCategory)) { return (
{__( "Per-period breakdown isn't available for this section. The summary cards above show the aggregate for the selected date range.", "yatra", )}
); } // Empty-state for ranges with no data — better than a 0-bar table. if (breakdownData.length === 0) { return (
{__("No data in this date range yet.", "yatra")}
); } return (
{renderTableHeaders()} {renderTableRows()}
{/* Summary Row */}

{__("Total Bookings", "yatra")}

{breakdownData.reduce((sum, row) => sum + row.bookings, 0)}

{__("Total Revenue", "yatra")}

{formatCurrencyAmount( breakdownData.reduce((sum, row) => sum + row.revenue, 0), )}

{__("Total Departures", "yatra")}

{breakdownData.reduce((sum, row) => sum + row.departures, 0)}

{__("Avg Occupancy", "yatra")}

{( breakdownData.reduce((sum, row) => sum + row.occupancyRate, 0) / breakdownData.length ).toFixed(1)} %

); }; // Facebook Pixel Reports Component const FacebookPixelReports: React.FC = () => { const [clearingLogs, setClearingLogs] = useState(false); const { showToast } = useToast(); // Fetch fresh Facebook Pixel data const { data: freshPixelData, refetch: refetchPixelData, isLoading: isPixelLoading, } = useQuery({ queryKey: ["facebook-pixel-status"], queryFn: async () => { const response = await apiService.getFacebookPixelSettings(); return response?.data || {}; }, refetchInterval: 30000, // Refresh every 30 seconds }); // Use fresh data if available, fallback to cached data const facebookPixelData = freshPixelData || (window as any).yatraAdmin?.facebookPixel || {}; const getEventStats = () => { const logs = facebookPixelData.eventLogs || []; return { success: logs.filter((log: any) => log.status === "success").length, errors: logs.filter((log: any) => log.status === "error").length, total: logs.length, }; }; const getRecentEvents = () => { const logs = facebookPixelData.eventLogs || []; return logs.slice(-10).reverse(); // Show last 10 events, newest first }; const clearPixelLogs = async () => { setClearingLogs(true); try { const response = await apiService.clearFacebookPixelEventLogs(); if (response.success) { showToast(__("Event logs cleared successfully.", "yatra"), "success"); // Refetch fresh data to update the UI await refetchPixelData(); } } catch (error: any) { showToast(error.message || __("Failed to clear logs.", "yatra"), "error"); } finally { setClearingLogs(false); } }; const eventStats = getEventStats(); const recentEvents = getRecentEvents(); // Empty-state gate. // Three conditions all funnel into the same "Not Configured" screen // because, from the operator's point of view, they look the same: // 1. Pro plugin isn't installed/activated at all // 2. Pro is installed but the Facebook Pixel module is toggled off // under Modules — pre-existing pixel_id stays in the DB and // would otherwise let this tab render with stale data // 3. Module is on but no Pixel ID has been saved yet const fbModuleActive = isProPluginActive() && isModuleActive("facebook_pixel"); if (!fbModuleActive || !facebookPixelData.pixel_id) { return (

{__("Facebook Pixel Not Configured", "yatra")}

{!fbModuleActive ? __( "Enable the Facebook Pixel module under Modules and add your Pixel ID in Settings to start tracking conversion events.", "yatra", ) : __( "Configure your Facebook Pixel in Settings to start tracking conversion events.", "yatra", )}

{!fbModuleActive ? __("Open Modules", "yatra") : __("Configure Facebook Pixel", "yatra")}
); } return (
{/* Connection Status */}

{__("Connection Status", "yatra")}

{__("Pixel Connection", "yatra")}

{facebookPixelData.connectionStatus?.pixelConnected ? __("Connected", "yatra") : __("Not Connected", "yatra")}

{facebookPixelData.connectionStatus?.pixelConnected ? ( ) : ( )}

{__("API Token", "yatra")}

{facebookPixelData.connectionStatus?.tokenConnected ? __("Valid", "yatra") : __("Invalid", "yatra")}

{facebookPixelData.connectionStatus?.tokenConnected ? ( ) : ( )}

{__("Pixel ID", "yatra")}

{facebookPixelData.pixel_id || __("Not Set", "yatra")}

{/* Event Statistics */}

{__("Event Statistics", "yatra")}

{eventStats.success}
{__("Successful Events", "yatra")}
{eventStats.errors}
{__("Failed Events", "yatra")}
{eventStats.total}
{__("Total Events", "yatra")}
{/* Recent Events */}

{__("Recent Activity", "yatra")}

{recentEvents.length > 0 ? (
{recentEvents.map((log: any, index: number) => (
{log.status === "success" && ( )} {log.status === "error" && ( )} {log.status === "logged" && ( )}
{log.event_name}
{log.event_data?.trip_name ? (
{__("Trip:", "yatra")} {log.event_data?.trip_url ? ( {log.event_data.trip_name} ) : ( {log.event_data.trip_name} )}
) : ( {log.event_type || "Frontend"} )}
{log.event_data?.value && (
{__("Value:", "yatra")}{" "} {log.event_data.currency || "USD"}{" "} {log.event_data.value}
)}
{new Date(log.timestamp).toLocaleTimeString()}
{new Date(log.timestamp).toLocaleDateString()}
))}
) : (

{__("No Events Yet", "yatra")}

{__( "Events will appear here once users start interacting with your site.", "yatra", )}

)}
{/* Quick Links */}

{__("Quick Links", "yatra")}

); }; // Google Analytics 4 Reports Component const GoogleAnalyticsReports: React.FC = () => { const [clearingLogs, setClearingLogs] = useState(false); const { showToast } = useToast(); // Fetch fresh Google Analytics data const { data: freshGAData, refetch: refetchGAData, isLoading: isGALoading, } = useQuery({ queryKey: ["google-analytics-status"], queryFn: async () => { const raw = await apiService.getGoogleAnalyticsSettings(); const payload = unwrapApiPayload>(raw); return (payload && typeof payload === "object" ? payload : {}) as Record< string, unknown >; }, refetchInterval: 30000, // Refresh every 30 seconds }); // Always use fresh data from API const googleAnalyticsData = (freshGAData || {}) as { measurement_id?: string; eventLogs?: Array<{ status?: string; [key: string]: unknown }>; connectionStatus?: { measurementConnected?: boolean; apiSecretConnected?: boolean; }; [key: string]: unknown; }; const getEventStats = () => { const logs = googleAnalyticsData.eventLogs || []; return { success: logs.filter((log) => log.status === "success").length, errors: logs.filter((log) => log.status === "error").length, total: logs.length, }; }; const getRecentEvents = () => { const logs = googleAnalyticsData.eventLogs || []; return logs.slice(-10).reverse(); // Show last 10 events, newest first }; const clearGALogs = async () => { setClearingLogs(true); try { const raw = (await apiService.clearGoogleAnalyticsEventLogs()) as { success?: boolean; }; if (raw?.success) { showToast(__("Event logs cleared successfully.", "yatra"), "success"); // Refetch fresh data to update the UI await refetchGAData(); } } catch (error: any) { showToast(error.message || __("Failed to clear logs.", "yatra"), "error"); } finally { setClearingLogs(false); } }; const eventStats = getEventStats(); const recentEvents = getRecentEvents(); // Empty-state gate. Mirrors the Facebook Pixel tab: same three // funnel paths (no Pro, module disabled, no Measurement ID) → same // visual treatment. Splits the CTA + body between // "Enable the module" vs. "Configure in Settings" based on which // condition tripped. const gaModuleActive = isProPluginActive() && isModuleActive("google_analytics"); if (!gaModuleActive || !googleAnalyticsData.measurement_id) { return (

{__("Google Analytics 4 Not Configured", "yatra")}

{!gaModuleActive ? __( "Enable the Google Analytics 4 module under Modules and add your Measurement ID in Settings to start tracking conversion events.", "yatra", ) : __( "Configure your Google Analytics 4 in Settings to start tracking conversion events.", "yatra", )}

{!gaModuleActive ? __("Open Modules", "yatra") : __("Configure Google Analytics 4", "yatra")}
); } return (
{/* Connection Status */}

{__("Connection Status", "yatra")}

{__("Measurement ID", "yatra")}

{googleAnalyticsData.connectionStatus?.measurementConnected ? __("Connected", "yatra") : __("Not Connected", "yatra")}

{googleAnalyticsData.connectionStatus?.measurementConnected ? ( ) : ( )}

{__("API Secret", "yatra")}

{googleAnalyticsData.connectionStatus?.apiSecretConnected ? __("Valid", "yatra") : __("Invalid", "yatra")}

{googleAnalyticsData.connectionStatus?.apiSecretConnected ? ( ) : ( )}

{__("Measurement Protocol", "yatra")}

{googleAnalyticsData.use_measurement_protocol ? __("Enabled", "yatra") : __("Disabled", "yatra")}

{/* Event Statistics */}

{__("Event Statistics", "yatra")}

{eventStats.success}
{__("Successful Events", "yatra")}
{eventStats.errors}
{__("Failed Events", "yatra")}
{eventStats.total}
{__("Total Events", "yatra")}
{/* Recent Events */}

{__("Recent Activity", "yatra")}

{recentEvents.length > 0 ? (
{recentEvents.map((log: any, index: number) => (
{log.status === "success" && ( )} {log.status === "error" && ( )} {log.status === "logged" && ( )}
{log.event_name}
{log.event_data?.trip_name ? (
{__("Trip:", "yatra")} {log.event_data?.trip_url ? ( {log.event_data.trip_name} ) : ( {log.event_data.trip_name} )}
) : ( {log.event_type || "Frontend"} )}
{log.event_data?.value && (
{__("Value:", "yatra")}{" "} {log.event_data.currency || "USD"}{" "} {log.event_data.value}
)}
{new Date(log.timestamp).toLocaleTimeString()}
{new Date(log.timestamp).toLocaleDateString()}
))}
) : (

{__("No Events Yet", "yatra")}

{__( "Events will appear here once users start interacting with your site.", "yatra", )}

)}
{/* Quick Links */}
); }; const TravelBookingReports: React.FC = () => { // Cap-filter the category list. canCap honors the WP admin fallback // (admins see everything), the Team module's userCaps array (granular // role-based), and falls back to default-allow when no team module is // installed (so non-Team installs keep their pre-existing behavior). const visibleCategories = useMemo( () => TravelReportCategories.filter((c) => canCap(c.cap)), [], ); const [selectedCategory, setSelectedCategory] = useState( () => visibleCategories[0]?.id || "booking-overview", ); const [dateRange, setDateRange] = useState("last_30_days"); const [viewType, setViewType] = useState("summary"); // 'summary', 'daily', 'weekly', 'monthly' // If the operator's currently-selected tab becomes invalid (e.g. // role changed mid-session), fall back to the first visible tab. React.useEffect(() => { if (!visibleCategories.some((c) => c.id === selectedCategory)) { const fallback = visibleCategories[0]?.id; if (fallback) setSelectedCategory(fallback); } }, [visibleCategories, selectedCategory]); // Fetch real data from Yatra ReportsController using apiClient const { data: reportData, isLoading } = useQuery({ queryKey: ["yatra-travel-reports", dateRange], queryFn: async () => { const params = getDateRangeParams(dateRange); const response = await apiClient.get( `/reports?date_from=${params.start}&date_to=${params.end}`, ); return response?.data || {}; }, }); // Calculate date range parameters function getDateRangeParams(range: string) { const today = new Date(); const start = new Date(); switch (range) { case "today": start.setHours(0, 0, 0, 0); break; case "last_7_days": start.setDate(today.getDate() - 7); break; case "last_30_days": start.setDate(today.getDate() - 30); break; case "last_90_days": start.setDate(today.getDate() - 90); break; case "this_year": start.setMonth(0, 1); break; default: start.setDate(today.getDate() - 30); } return { start: formatDateForInput(start), end: formatDateForInput(today), }; } // Travel Business KPIs const travelKPIs = useMemo(() => { if (!reportData) return { totalBookings: 0, totalRevenue: 0, occupancyRate: 0, avgBookingValue: 0, cancellationRate: 0, upcomingDepartures: 0, }; return { totalBookings: reportData.booking_stats?.total || 0, totalRevenue: reportData.revenue_stats?.total || 0, occupancyRate: reportData.operational_stats?.occupancyRate || 0, avgBookingValue: reportData.revenue_stats?.average || 0, cancellationRate: reportData.booking_stats?.cancellationRate || 0, upcomingDepartures: reportData.operational_stats?.upcomingDepartures || 0, }; }, [reportData]); const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD"; const formatCurrencyAmount = (amount: number) => formatYatraMoney(Number(amount) || 0, globalCurrency, { zeroAsUnknown: false, }); // CSV export — bundles the visible breakdown (or summary KPIs when // no breakdown is appropriate) into a downloadable file scoped to // the picked date range. Doesn't hit the network; everything we // already have in memory is enough. const handleExportCsv = () => { const params = getDateRangeParams(dateRange); const rows: (string | number | null | undefined)[][] = []; // Top KPI summary first — operators paste this into the email // before the detail table. rows.push(["Yatra Reports — Summary"]); rows.push([`Date range: ${params.start} to ${params.end}`]); rows.push([]); rows.push(["Metric", "Value"]); rows.push(["Total bookings", travelKPIs.totalBookings]); rows.push(["Total revenue", travelKPIs.totalRevenue]); rows.push(["Avg booking value", travelKPIs.avgBookingValue]); rows.push(["Occupancy rate (%)", travelKPIs.occupancyRate]); rows.push(["Cancellation rate (%)", travelKPIs.cancellationRate]); rows.push(["Upcoming departures", travelKPIs.upcomingDepartures]); rows.push([]); // Booking trend (day-level) if the operator has revenue/booking data. const trend: any[] = reportData?.booking_trend || []; const revenueTrend: any[] = reportData?.revenue_trend || []; if (trend.length) { rows.push(["Day", "Bookings", "Revenue"]); trend.forEach((row: any, i: number) => { rows.push([ row.date || row.label, row.value, revenueTrend[i]?.value ?? "", ]); }); rows.push([]); } // Top trips const tp = reportData?.trip_performance || []; if (tp.length) { rows.push(["Trip", "Bookings", "Revenue", "Occupancy %"]); tp.forEach((t: any) => { rows.push([t.label, t.value, t.revenue, t.occupancy]); }); rows.push([]); } // Payment methods const pm = reportData?.payment_methods || []; if (pm.length) { rows.push(["Payment method", "Count", "Revenue"]); pm.forEach((m: any) => { rows.push([m.method, m.count, m.revenue]); }); rows.push([]); } // Top destinations const dest = reportData?.top_destinations || []; if (dest.length) { rows.push(["Destination", "Bookings", "Revenue"]); dest.forEach((d: any) => { rows.push([d.label, d.value, d.revenue]); }); rows.push([]); } const blob = buildCsv(rows); downloadCsv(blob, csvFilename("reports", params.start, params.end)); }; return (
{/* ── HEADER ──────────────────────────────────────────────────── */}

{__("Travel Booking Reports", "yatra")}

{__( "Essential analytics for your travel booking business.", "yatra", )}

{/* Key Performance Indicators */}
{isLoading ? ( <> {[...Array(6)].map((_, i) => ( ))} ) : ( <>

{__("Total Bookings", "yatra")}

{travelKPIs.totalBookings}

{__("Total Revenue", "yatra")}

{formatCurrencyAmount(travelKPIs.totalRevenue)}

{__("Occupancy Rate", "yatra")}

{travelKPIs.occupancyRate.toFixed(1)}%

{__("Avg Booking Value", "yatra")}

{formatCurrencyAmount(travelKPIs.avgBookingValue)}

{__("Cancellation Rate", "yatra")}

{travelKPIs.cancellationRate.toFixed(1)}%

{__("Upcoming Departures", "yatra")}

{travelKPIs.upcomingDepartures}

)}
{/* Report Categories - Tab Navigation */} {__("Travel Business Reports", "yatra")} {__( "Comprehensive analytics for your travel booking operations", "yatra", )} {/* Tab Navigation: dropdown on small screens; scrollable pill row on md+ */}
{/* Report Content */} {isLoading ? ( ) : (
{selectedCategory === "booking-overview" && (

{__("Booking Overview", "yatra")}

{/* KPI Cards */}

{__("Confirmed Bookings", "yatra")}

{reportData?.booking_stats?.confirmed || 0}

{__("Pending Bookings", "yatra")}

{reportData?.booking_stats?.pending || 0}

{__("Cancelled Bookings", "yatra")}

{reportData?.booking_stats?.cancelled || 0}

{__("Completed Bookings", "yatra")}

{reportData?.booking_stats?.completed || 0}

{/* Charts Section */}
{/* Booking Status Chart */} {__("Booking Status Distribution", "yatra")} {/* Revenue Trend Chart */} {__("Revenue Trend", "yatra")}
)} {selectedCategory === "revenue-analysis" && (

Revenue Analysis

Total Revenue

{formatCurrencyAmount( reportData?.revenue_stats?.total || 0, )}

Average Booking Value

{formatCurrencyAmount( reportData?.revenue_stats?.average || 0, )}

Revenue Lost (Cancellations)

{formatCurrencyAmount( reportData?.cancellations?.revenueLost || 0, )}

)} {selectedCategory === "trip-performance" && (

Trip Performance

{(reportData?.trip_performance || []) .slice(0, 5) .map((trip: any, index: number) => (

{trip.label}

{trip.value} bookings

{formatCurrencyAmount(trip.revenue || 0)}

Revenue

))}
)} {selectedCategory === "departure-management" && (

Departure Management

Upcoming Departures

{reportData?.operational_stats?.upcomingDepartures || 0}

Total Capacity

{reportData?.operational_stats?.totalCapacity || 0}

Booked Capacity

{reportData?.operational_stats?.bookedCapacity || 0}

)} {selectedCategory === "customer-insights" && (

Customer Insights

Total Customers

{reportData?.customer_analytics?.totalCustomers || 0}

New Customers

{reportData?.customer_analytics?.newCustomers || 0}

Returning Customers

{reportData?.customer_analytics?.returningCustomers || 0}

Customer Lifetime Value

{formatCurrencyAmount( reportData?.customer_analytics ?.customerLifetimeValue || 0, )}

)} {selectedCategory === "operational-metrics" && (

Operational Metrics

Occupancy Rate

{( reportData?.operational_stats?.occupancyRate || 0 ).toFixed(1)} %

Average Group Size

{( reportData?.operational_stats?.averageGroupSize || 0 ).toFixed(1)}

Cancellation Rate

{( reportData?.booking_stats?.cancellationRate || 0 ).toFixed(1)} %

)} {/* ── REVENUE ANALYSIS — Payment method breakdown ───────── Operators routinely want to know which gateways are pulling weight (and which they could turn off). The data is computed in the backend per request. */} {selectedCategory === "revenue-analysis" && Array.isArray(reportData?.payment_methods) && reportData.payment_methods.length > 0 && (

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

{__( "Bookings and gross revenue split by payment gateway. Ranked by revenue.", "yatra", )}

{reportData.payment_methods.slice(0, 6).map((m: any) => (

{m.method}

{formatCurrencyAmount(m.revenue)}

{m.count} {__("bookings", "yatra")}

))}
)} {/* ── TRIP PERFORMANCE — Top destinations ──────────────── Geographic concentration. Useful when paired with Top Trips: a single trip can dominate a destination, or a destination can have a long tail of small wins. */} {selectedCategory === "trip-performance" && Array.isArray(reportData?.top_destinations) && reportData.top_destinations.length > 0 && (

{__("Top Destinations", "yatra")}

{__( "Booking count and revenue by primary destination. Useful for spotting geographic concentration.", "yatra", )}

{reportData.top_destinations.map((d: any) => { const max = Math.max( 1, ...reportData.top_destinations.map( (x: any) => x.value, ), ); return (
{d.label}
{d.value}
{formatCurrencyAmount(d.revenue || 0)}
); })}
)} {/* ── OPERATIONAL METRICS — Lead time histogram ────────── How far in advance customers book. Same-day = last- minute demand. >quarter = need solid deposit policy. */} {selectedCategory === "operational-metrics" && reportData?.lead_time && (

{__("Booking Lead Time", "yatra")}

{__( "Time between booking creation and travel date. Average:", "yatra", )}{" "} {Number(reportData.lead_time.averageDays || 0).toFixed( 1, )}{" "} {__("days", "yatra")} {" "} ({reportData.lead_time.sampleSize}{" "} {__("bookings sampled", "yatra")})

{(reportData.lead_time.buckets || []).map((b: any) => { const total = ( reportData.lead_time.buckets || [] ).reduce((s: number, x: any) => s + x.value, 0); const pct = total > 0 ? Math.round((b.value / total) * 100) : 0; return (
{b.label}
{b.value}
{pct}%
); })}
)} {/* ── OPERATIONAL METRICS — Refunds summary ────────────── Refunds are distinct from cancellations: cancelling doesn't necessarily refund (deposit policy). Track separately so finance has a clean view. Renders unconditionally on this tab — falls back to zero KPIs when the backend hasn't shipped the `refunds` block yet (e.g. PHP-FPM cached an older copy of the controller). Visible empty-state beats silently hiding the section. */} {selectedCategory === "operational-metrics" && (

{__("Refunds", "yatra")}

{__("Refunds issued", "yatra")}

{reportData?.refunds?.count ?? 0}

{__("Refund total", "yatra")}

{formatCurrencyAmount( Number(reportData?.refunds?.total) || 0, )}

{__("Refund rate", "yatra")}

{Number(reportData?.refunds?.refundRate || 0).toFixed( 1, )} %

{__("Avg refund", "yatra")}

{formatCurrencyAmount( Number(reportData?.refunds?.avgRefund) || 0, )}

)} {selectedCategory === "facebook-pixel" && ( )} {selectedCategory === "google-analytics" && ( )}
)} {/* Detailed Breakdown Section */} {viewType !== "summary" && ( {__("Detailed", "yatra")}{" "} {viewType.charAt(0).toUpperCase() + viewType.slice(1)}{" "} {__("Report", "yatra")} {viewType === "daily" && __( "Daily breakdown of bookings, revenue, and departures", "yatra", )} {viewType === "weekly" && __( "Weekly breakdown of bookings, revenue, and departures", "yatra", )} {viewType === "monthly" && __( "Monthly breakdown of bookings, revenue, and departures", "yatra", )}
{/* Table Section - Takes 2/3 width on large screens */}
{/* Chart Section - Takes 1/3 width on large screens */}
)}
); }; export default TravelBookingReports;