PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 All 85 releases
← All changes | resources/js/pages/Reports.tsx +741 -607 3.0.2.8 → 3.0.17 View file →
@@ -6,9 +6,11 @@
6 6
7 7 import React, { useState, useMemo } from "react";
8 8 import { useQuery } from "@tanstack/react-query";
9 9 import { __ } from "../lib/i18n";
10 +import { formatDateForInput } from "../lib/dateFormat";
10 11 import { apiClient, apiService } from "../lib/api-client";
12 +import { unwrapApiPayload } from "../lib/unwrap-api-payload";
11 13 import { useToast } from "../components/ui/toast";
12 14 import {
13 15 Card,
14 16 CardContent,
@@ -19,9 +21,21 @@
19 21 import { Button } from "../components/ui/button";
20 22 import { Select } from "../components/ui/select";
21 23 import BookingsOverviewChart from "../components/charts/BookingsOverviewChart";
22 24 import BookingStatusChart from "../components/charts/BookingStatusChart";
23 -import { getCurrencySymbol } from "../data/currencies";
25 +import { formatYatraMoney } from "../lib/currency-display";
26 +import {
27 + bucketSeries,
28 + bucketStatusSeries,
29 + buildCsv,
30 + csvFilename,
31 + downloadCsv,
32 + type SeriesPoint,
33 + type StatusPoint,
34 + type TrendView,
35 +} from "../lib/report-series";
36 +import { isModuleActive, isProPluginActive } from "../lib/plugin-utils";
37 +import { canCap } from "../hooks/useCapabilities";
24 38
25 39 // Skeleton Loading Components
26 40 const SkeletonCard = () => (
27 41 <Card className="p-4">
@@ -213,14 +227,27 @@
213 227 ),
214 228 };
215 229
216 230 // Travel Business Report Categories
217 -const TravelReportCategories = [
231 +// Each category declares the capability that gates it. The Reports
232 +// page filters this list by `usePermissions().can()` at render time
233 +// so an Accountant only sees Revenue + Operational tabs, a Marketing
234 +// role sees Customer + Operational + Pixel + GA, etc. Categories
235 +// without a `cap` field are visible to anyone who can see Reports
236 +// (the page itself is already gated at the sidebar layer).
237 +const TravelReportCategories: Array<{
238 + id: string;
239 + title: string;
240 + icon: string;
241 + description: string;
242 + cap: string;
243 +}> = [
218 244 {
219 245 id: "booking-overview",
220 246 title: "Booking Overview",
221 247 icon: "Calendar",
222 248 description: "Booking volume, status distribution, trends",
249 + cap: "yatra_view_operational_reports",
223 250 },
224 251 {
225 252 id: "revenue-analysis",
226 253 title: "Revenue Analysis",
@@ -225,8 +252,9 @@
225 252 id: "revenue-analysis",
226 253 title: "Revenue Analysis",
227 254 icon: "DollarSign",
228 255 description: "Revenue trends, payment status, profitability",
256 + cap: "yatra_view_financial_reports",
229 257 },
230 258 {
231 259 id: "trip-performance",
232 260 title: "Trip Performance",
@@ -231,8 +259,9 @@
231 259 id: "trip-performance",
232 260 title: "Trip Performance",
233 261 icon: "MapPin",
234 262 description: "Trip popularity, occupancy rates, capacity utilization",
263 + cap: "yatra_view_operational_reports",
235 264 },
236 265 {
237 266 id: "departure-management",
238 267 title: "Departure Management",
@@ -237,8 +266,9 @@
237 266 id: "departure-management",
238 267 title: "Departure Management",
239 268 icon: "Truck",
240 269 description: "Upcoming departures, capacity planning, scheduling",
270 + cap: "yatra_view_departures",
241 271 },
242 272 {
243 273 id: "customer-insights",
244 274 title: "Customer Insights",
@@ -243,8 +273,9 @@
243 273 id: "customer-insights",
244 274 title: "Customer Insights",
245 275 icon: "Users",
246 276 description: "Customer behavior, retention, demographics",
277 + cap: "yatra_view_customers",
247 278 },
248 279 {
249 280 id: "operational-metrics",
250 281 title: "Operational Metrics",
@@ -249,8 +280,9 @@
249 280 id: "operational-metrics",
250 281 title: "Operational Metrics",
251 282 icon: "Activity",
252 283 description: "Lead times, cancellations, efficiency metrics",
284 + cap: "yatra_view_operational_reports",
253 285 },
254 286 {
255 287 id: "facebook-pixel",
256 288 title: "Facebook Pixel",
@@ -255,8 +287,9 @@
255 287 id: "facebook-pixel",
256 288 title: "Facebook Pixel",
257 289 icon: "Facebook",
258 290 description: "Conversion tracking, event analytics, pixel performance",
291 + cap: "yatra_view_operational_reports",
259 292 },
260 293 {
261 294 id: "google-analytics",
262 295 title: "Google Analytics 4",
@@ -262,252 +295,150 @@
262 295 title: "Google Analytics 4",
263 296 icon: "Google",
264 297 description:
265 298 "Enhanced e-commerce tracking, Measurement Protocol, visitor analytics",
299 + cap: "yatra_view_operational_reports",
266 300 },
267 301 ];
268 302
269 303 // Detailed Breakdown Chart Component
304 +//
305 +// REWRITE NOTE (3.0.5):
306 +// The previous implementation fabricated daily/weekly/monthly values by
307 +// multiplying period totals by a `seasonalFactor` so the chart "looked
308 +// like" historical data. Operators were making decisions on numbers
309 +// that didn't exist in the database. This version reads day-level
310 +// trends straight from `/reports` (booking_trend / revenue_trend /
311 +// occupancy_trend) and buckets them via `report-series.bucketSeries`
312 +// when the operator picks Weekly or Monthly. The numbers always come
313 +// from real bookings.
270 314 const DetailedBreakdownChart: React.FC<{
271 315 viewType: string;
272 316 dateRange: string;
273 317 selectedCategory: string;
274 318 reportData?: any;
275 -}> = ({ viewType, dateRange, selectedCategory, reportData }) => {
276 - // Get global currency settings for chart
319 +}> = ({ viewType, selectedCategory, reportData }) => {
277 320 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
278 - const currencyPosition =
279 - (window as any)?.yatraAdmin?.currencyPosition ||
280 - (window as any)?.yatraAdmin?.currency_position ||
281 - "before";
282 - const decimalPlaces = Number(
283 - (window as any)?.yatraAdmin?.decimalPlaces ||
284 - (window as any)?.yatraAdmin?.currency_decimals ||
285 - 2,
286 - );
287 - const thousandSeparator =
288 - (window as any)?.yatraAdmin?.thousandSeparator || ",";
289 - const decimalSeparator = (window as any)?.yatraAdmin?.decimalSeparator || ".";
321 + const formatCurrencyAmount = (amount: number) =>
322 + formatYatraMoney(Number(amount) || 0, globalCurrency, {
323 + zeroAsUnknown: false,
324 + });
290 325
291 - const formatCurrencyAmount = (amount: number) => {
292 - if (!amount || amount === 0) return getCurrencySymbol(globalCurrency) + "0";
293 -
294 - const numPrice = Number(amount) || 0;
295 -
296 - // Format the number with proper separators
297 - const formattedAmount = new Intl.NumberFormat(undefined, {
298 - minimumFractionDigits: decimalPlaces,
299 - maximumFractionDigits: decimalPlaces,
300 - })
301 - .format(numPrice)
302 - .replace(/,/g, "TEMP_THOUSAND")
303 - .replace(/\./g, decimalSeparator)
304 - .replace(/TEMP_THOUSAND/g, thousandSeparator);
305 -
306 - // Get currency symbol
307 - const currencySymbol = getCurrencySymbol(globalCurrency);
308 -
309 - // Apply currency position
310 - if (currencyPosition === "after" || currencyPosition === "right") {
311 - return `${formattedAmount} ${currencySymbol}`;
312 - } else {
313 - return `${currencySymbol}${formattedAmount}`;
326 + // Pick the right source series for the selected report category.
327 + // Each is already day-aligned and gap-filled by the backend.
328 + const sourceSeries: SeriesPoint[] = useMemo(() => {
329 + if (!reportData) return [];
330 + if (selectedCategory === "revenue-analysis") {
331 + return (reportData.revenue_trend as SeriesPoint[]) || [];
314 332 }
315 - };
316 -
317 - // Generate chart data based on view type using real API data
318 - const generateChartData = () => {
319 - const data = [];
320 - const today = new Date();
321 -
322 - // Base values from API data - NO FALLBACKS, use real data only
323 - const baseBookings = reportData?.booking_stats?.totalBookings || 0;
324 - const baseRevenue = reportData?.revenue_stats?.totalRevenue || 0;
325 - const baseDepartures = reportData?.departure_stats?.totalDepartures || 0;
326 -
327 - if (viewType === "daily") {
328 - const days =
329 - dateRange === "last_7_days"
330 - ? 7
331 - : dateRange === "last_30_days"
332 - ? 30
333 - : 90;
334 - for (let i = days - 1; i >= 0; i--) {
335 - const date = new Date(today);
336 - date.setDate(date.getDate() - i);
337 -
338 - // Calculate daily values from totals with realistic distribution
339 - const dayIndex = days - i - 1; // 0 for most recent day
340 - const seasonalFactor = 0.8 + (dayIndex / days) * 0.4; // Recent days have higher activity
341 - const dailyBookings = Math.floor(
342 - (baseBookings / days) * seasonalFactor,
343 - );
344 - const dailyRevenue = Math.floor((baseRevenue / days) * seasonalFactor);
345 - const dailyDepartures = Math.floor(
346 - (baseDepartures / days) * seasonalFactor,
347 - );
348 -
349 - data.push({
350 - name: date.toLocaleDateString("en-US", {
351 - month: "short",
352 - day: "numeric",
353 - }),
354 - bookings: dailyBookings,
355 - revenue: dailyRevenue,
356 - departures: dailyDepartures,
357 - });
333 + if (selectedCategory === "departure-management") {
334 + // occupancy_trend stores rate-per-day (already aggregated). For a
335 + // bar chart we want a comparable absolute (counts of departures),
336 + // so we derive from departures_table when present. Fall back to
337 + // booking_trend so the chart never goes empty.
338 + const dep = reportData.departures_table || [];
339 + if (Array.isArray(dep) && dep.length > 0) {
340 + const byDay = new Map<string, number>();
341 + for (const d of dep) {
342 + const date = (d?.date || "").slice(0, 10);
343 + if (!date) continue;
344 + byDay.set(date, (byDay.get(date) || 0) + 1);
345 + }
346 + return Array.from(byDay.entries()).map(([date, value]) => ({
347 + date,
348 + label: date,
349 + value,
350 + }));
358 351 }
359 - } else if (viewType === "weekly") {
360 - const weeks = 8; // Show last 8 weeks for better chart readability
361 - for (let i = weeks - 1; i >= 0; i--) {
362 - const weekStart = new Date(today);
363 - weekStart.setDate(weekStart.getDate() - i * 7);
352 + return (reportData.booking_trend as SeriesPoint[]) || [];
353 + }
354 + return (reportData.booking_trend as SeriesPoint[]) || [];
355 + }, [reportData, selectedCategory]);
364 356
365 - // Calculate weekly values from totals with realistic distribution
366 - const weekIndex = weeks - i - 1; // 0 for most recent week
367 - const seasonalFactor = 0.7 + (weekIndex / weeks) * 0.6; // Recent weeks have higher activity
368 - const weeklyBookings = Math.floor((baseBookings / 4) * seasonalFactor);
369 - const weeklyRevenue = Math.floor((baseRevenue / 4) * seasonalFactor);
370 - const weeklyDepartures = Math.floor(
371 - (baseDepartures / 4) * seasonalFactor,
372 - );
357 + // viewType can be "summary" (a synthetic UI option that maps to
358 + // "daily" for the chart) or any TrendView. Coerce defensively.
359 + const view: TrendView =
360 + viewType === "weekly" || viewType === "monthly" ? viewType : "daily";
361 + const bucketed = useMemo(
362 + () => bucketSeries(sourceSeries, view),
363 + [sourceSeries, view],
364 + );
373 365
374 - data.push({
375 - name: `W${weeks - i}`,
376 - bookings: weeklyBookings,
377 - revenue: weeklyRevenue,
378 - departures: weeklyDepartures,
379 - });
380 - }
381 - } else if (viewType === "monthly") {
382 - const months = 6; // Show last 6 months for better chart readability
383 - for (let i = months - 1; i >= 0; i--) {
384 - const monthDate = new Date(today);
385 - monthDate.setMonth(monthDate.getMonth() - i);
366 + // Empty-state guard: the chart formerly fell back to fabricated
367 + // values; now we render an honest "no data" instead. Better the
368 + // operator sees the truth than a synthetic 12-bar chart.
369 + if (!reportData || bucketed.length === 0) {
370 + return (
371 + <div className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
372 + {__("No data in this period yet.", "yatra")}
373 + </div>
374 + );
375 + }
386 376
387 - // Calculate monthly values from totals with realistic distribution
388 - const monthIndex = months - i - 1; // 0 for most recent month
389 - const seasonalFactor = 0.6 + (monthIndex / months) * 0.8; // Recent months have higher activity
390 - const monthlyBookings = Math.floor(baseBookings * seasonalFactor);
391 - const monthlyRevenue = Math.floor(baseRevenue * seasonalFactor);
392 - const monthlyDepartures = Math.floor(baseDepartures * seasonalFactor);
377 + // Find max for bar-width normalisation. Guard against zero.
378 + const maxValue = Math.max(1, ...bucketed.map((p) => p.value));
379 + const isRevenue = selectedCategory === "revenue-analysis";
380 + const isDepartures = selectedCategory === "departure-management";
393 381
394 - data.push({
395 - name: monthDate.toLocaleDateString("en-US", { month: "short" }),
396 - bookings: monthlyBookings,
397 - revenue: monthlyRevenue,
398 - departures: monthlyDepartures,
399 - });
400 - }
401 - }
382 + const barClass = isRevenue
383 + ? "bg-emerald-500"
384 + : isDepartures
385 + ? "bg-purple-500"
386 + : "bg-blue-500";
402 387
403 - return data;
404 - };
388 + const valueLabel = (v: number) =>
389 + isRevenue ? formatCurrencyAmount(v) : v.toLocaleString();
405 390
406 - const chartData = generateChartData();
407 - const maxRevenue = Math.max(...chartData.map((d) => d.revenue));
408 - const maxBookings = Math.max(...chartData.map((d) => d.bookings));
409 -
410 391 return (
411 392 <div className="space-y-4">
412 393 <div className="text-center">
413 - <h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
414 - {selectedCategory === "booking-overview" &&
415 - __("Bookings Trend", "yatra")}
416 - {selectedCategory === "revenue-analysis" &&
417 - __("Revenue Trend", "yatra")}
418 - {selectedCategory === "departure-management" &&
419 - __("Departures Trend", "yatra")}
420 - {![
421 - "booking-overview",
422 - "revenue-analysis",
423 - "departure-management",
424 - ].includes(selectedCategory) && __("Performance Trend", "yatra")}
394 + <h4 className="text-sm font-medium text-gray-900 dark:text-white">
395 + {isRevenue
396 + ? __("Revenue Trend", "yatra")
397 + : isDepartures
398 + ? __("Departures Trend", "yatra")
399 + : __("Bookings Trend", "yatra")}
425 400 </h4>
426 401 </div>
427 402
428 - {/* Simple Bar Chart */}
403 + {/* Bucketed bar chart — values are real data straight from the
404 + backend trend arrays. Bar widths normalised against the max
405 + in the bucketed set so a single outlier doesn't squash the
406 + rest into invisibility. */}
429 407 <div className="space-y-2">
430 - {chartData.map((item, index) => (
431 - <div key={index} className="flex items-center gap-2">
432 - <div className="w-8 text-xs text-gray-600 dark:text-gray-400 text-right">
433 - {item.name}
408 + {bucketed.map((item, index) => (
409 + <div
410 + key={`${item.date}-${index}`}
411 + className="flex items-center gap-2"
412 + >
413 + <div className="w-20 shrink-0 text-right text-xs text-gray-600 dark:text-gray-400">
414 + {item.label}
434 415 </div>
435 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-4 relative">
436 - {/* Revenue Bar */}
437 - {selectedCategory === "revenue-analysis" && (
438 - <div
439 - className="bg-green-500 h-4 rounded-full flex items-center justify-end pr-1"
440 - style={{ width: `${(item.revenue / maxRevenue) * 100}%` }}
441 - >
442 - <span className="text-xs text-white font-medium">
443 - {formatCurrencyAmount(item.revenue / 1000)}k
444 - </span>
445 - </div>
446 - )}
447 -
448 - {/* Bookings Bar */}
449 - {(selectedCategory === "booking-overview" ||
450 - !["revenue-analysis", "departure-management"].includes(
451 - selectedCategory,
452 - )) && (
453 - <div
454 - className="bg-blue-500 h-4 rounded-full flex items-center justify-end pr-1"
455 - style={{ width: `${(item.bookings / maxBookings) * 100}%` }}
456 - >
457 - <span className="text-xs text-white font-medium">
458 - {item.bookings}
459 - </span>
460 - </div>
461 - )}
462 -
463 - {/* Departures Bar */}
464 - {selectedCategory === "departure-management" && (
465 - <div
466 - className="bg-purple-500 h-4 rounded-full flex items-center justify-end pr-1"
467 - style={{
468 - width: `${(item.departures / Math.max(...chartData.map((d) => d.departures))) * 100}%`,
469 - }}
470 - >
471 - <span className="text-xs text-white font-medium">
472 - {item.departures}
473 - </span>
474 - </div>
475 - )}
416 + <div className="relative h-4 flex-1 rounded-full bg-gray-100 dark:bg-gray-700">
417 + <div
418 + className={`flex h-4 items-center justify-end rounded-full pr-1.5 ${barClass}`}
419 + style={{
420 + width: `${Math.max(2, (item.value / maxValue) * 100)}%`,
421 + }}
422 + >
423 + <span className="text-[11px] font-medium text-white">
424 + {valueLabel(item.value)}
425 + </span>
426 + </div>
476 427 </div>
477 428 </div>
478 429 ))}
479 430 </div>
480 431
481 - {/* Legend */}
482 - <div className="flex flex-wrap gap-2 text-xs">
483 - {selectedCategory === "revenue-analysis" && (
484 - <div className="flex items-center gap-1">
485 - <div className="w-3 h-3 bg-green-500 rounded"></div>
486 - <span className="text-gray-600 dark:text-gray-400">
487 - {__("Revenue", "yatra")}
488 - </span>
489 - </div>
490 - )}
491 - {(selectedCategory === "booking-overview" ||
492 - !["revenue-analysis", "departure-management"].includes(
493 - selectedCategory,
494 - )) && (
495 - <div className="flex items-center gap-1">
496 - <div className="w-3 h-3 bg-blue-500 rounded"></div>
497 - <span className="text-gray-600 dark:text-gray-400">
498 - {__("Bookings", "yatra")}
499 - </span>
500 - </div>
501 - )}
502 - {selectedCategory === "departure-management" && (
503 - <div className="flex items-center gap-1">
504 - <div className="w-3 h-3 bg-purple-500 rounded"></div>
505 - <span className="text-gray-600 dark:text-gray-400">
506 - {__("Departures", "yatra")}
507 - </span>
508 - </div>
509 - )}
432 + <div className="flex items-center gap-1 text-xs">
433 + <div className={`h-3 w-3 rounded ${barClass}`}></div>
434 + <span className="text-gray-600 dark:text-gray-400">
435 + {isRevenue
436 + ? __("Revenue", "yatra")
437 + : isDepartures
438 + ? __("Departures", "yatra")
439 + : __("Bookings", "yatra")}
440 + </span>
510 441 </div>
511 442 </div>
512 443 );
513 444 };
@@ -512,262 +443,165 @@
512 443 );
513 444 };
514 445
515 446 // Detailed Breakdown Table Component
447 +//
448 +// REWRITE NOTE (3.0.5):
449 +// Replaces the previous synthetic data path that:
450 +// - Fabricated daily/weekly/monthly bookings + revenue from period
451 +// totals * a `seasonalFactor`
452 +// - Made up confirmed/pending/cancelled splits (80/15/5 fixed ratio)
453 +// - Made up customer satisfaction (90 * factor), efficiency (85 *
454 +// factor), lead time (7..14 * inverse-factor)
455 +// - Cycled "topTrip" through trip_performance[i % 3] regardless of
456 +// which period the row represented
457 +//
458 +// New version reads four trend arrays from the backend, all day-aligned:
459 +// - booking_trend : { date, label, value } total bookings/day
460 +// - revenue_trend : { date, label, value } revenue/day
461 +// - status_trend : { date, label, confirmed, pending, cancelled,
462 +// completed } per-day status split
463 +// - traveler_segments.trend : { date, label, value } travellers/day
464 +//
465 +// We bucket those into the operator's selected view (daily/weekly/
466 +// monthly) using report-series.bucketSeries / bucketStatusSeries and
467 +// render only columns we can stand behind. Columns whose source data
468 +// doesn't exist (customer satisfaction, efficiency, top-trip-per-
469 +// period) are removed — better than rendering invented numbers.
516 470 const DetailedBreakdownTable: React.FC<{
517 471 viewType: string;
518 472 dateRange: string;
519 473 selectedCategory: string;
520 474 reportData: any;
521 -}> = ({ viewType, dateRange, selectedCategory, reportData }) => {
522 - // Generate breakdown data based on view type and category using real API data
523 - const generateBreakdownData = () => {
524 - const data = [];
525 - const today = new Date();
475 +}> = ({ viewType, selectedCategory, reportData }) => {
476 + // Suppress lints — these props are intentionally received for API
477 + // stability with the parent but the view bucket pipeline doesn't
478 + // need dateRange directly; backend already handed us the slice.
479 + // (Recovery of formatCurrencyAmount happens later in the component.)
480 + // viewType can be "summary" (UI-only) or a real TrendView. Coerce
481 + // anything that's not weekly/monthly to "daily" — same rule the
482 + // chart uses, so chart + table always agree on bucketing.
483 + const effectiveView: TrendView =
484 + viewType === "weekly" || viewType === "monthly" ? viewType : "daily";
526 485
527 - // Base values from API data - NO FALLBACKS, use real data only
528 - const baseBookings = reportData?.booking_stats?.totalBookings || 0;
529 - const baseRevenue = reportData?.revenue_stats?.totalRevenue || 0;
530 - // These are available for more granular breakdown if needed
531 - const _baseConfirmed = reportData?.booking_stats?.confirmedBookings || 0;
532 - const _basePending = reportData?.booking_stats?.pendingBookings || 0;
533 - const _baseCancelled = reportData?.booking_stats?.cancelledBookings || 0;
534 - void _baseConfirmed;
535 - void _basePending;
536 - void _baseCancelled; // Suppress unused warnings
537 - const baseDepartures = reportData?.departure_stats?.totalDepartures || 0;
538 - const baseCapacity = reportData?.departure_stats?.totalCapacity || 0;
539 - const baseOccupancy = reportData?.operational_stats?.occupancyRate || 0;
486 + const bookingTrend: SeriesPoint[] =
487 + (reportData?.booking_trend as SeriesPoint[]) || [];
488 + const revenueTrend: SeriesPoint[] =
489 + (reportData?.revenue_trend as SeriesPoint[]) || [];
490 + const statusTrend: StatusPoint[] =
491 + (reportData?.status_trend as StatusPoint[]) || [];
492 + const occupancyTrend: SeriesPoint[] =
493 + (reportData?.occupancy_trend as SeriesPoint[]) || [];
540 494
541 - if (viewType === "daily") {
542 - const days =
543 - dateRange === "last_7_days"
544 - ? 7
545 - : dateRange === "last_30_days"
546 - ? 30
547 - : 90;
548 - for (let i = days - 1; i >= 0; i--) {
549 - const date = new Date(today);
550 - date.setDate(date.getDate() - i);
495 + // Bucket each series identically so rows in the same bucket line up.
496 + const bookingsBucketed = bucketSeries(bookingTrend, effectiveView);
497 + const revenueBucketed = bucketSeries(revenueTrend, effectiveView);
498 + const statusBucketed = bucketStatusSeries(statusTrend, effectiveView);
551 499
552 - // Calculate daily distribution from totals with realistic patterns
553 - const dayIndex = days - i - 1; // 0 for most recent day
554 - const seasonalFactor = 0.8 + (dayIndex / days) * 0.4; // Recent days have higher activity
555 - const dailyBookings = Math.floor(
556 - (baseBookings / days) * seasonalFactor,
557 - );
558 - const dailyRevenue = Math.floor((baseRevenue / days) * seasonalFactor);
559 - const dailyConfirmed = Math.floor(dailyBookings * 0.8);
560 - const dailyPending = Math.floor(dailyBookings * 0.15);
561 - const dailyCancelled = Math.max(
562 - 0,
563 - dailyBookings - dailyConfirmed - dailyPending,
564 - );
500 + // For occupancy per period: we have a per-day rate (%), and rates
501 + // don't sum across days — they average. Recompute properly using
502 + // departures_table when bucketing.
503 + const departuresTable = (reportData?.departures_table as any[]) || [];
504 + type OccupancyAgg = { booked: number; capacity: number };
505 + const occByDay = new Map<string, OccupancyAgg>();
506 + for (const d of departuresTable) {
507 + const date = (d?.date || "").slice(0, 10);
508 + if (!date) continue;
509 + const acc = occByDay.get(date) || { booked: 0, capacity: 0 };
510 + acc.booked += Number(d?.bookedSeats ?? d?.booked ?? 0);
511 + acc.capacity += Number(d?.maxSeats ?? d?.capacity ?? 0);
512 + occByDay.set(date, acc);
513 + }
514 + // Same bucket-key strategy used by bucketSeries — re-derive labels.
515 + const occBookingSeries: SeriesPoint[] = Array.from(occByDay.entries()).map(
516 + ([date, agg]) => ({ date, label: date, value: agg.booked }),
517 + );
518 + const occCapacitySeries: SeriesPoint[] = Array.from(occByDay.entries()).map(
519 + ([date, agg]) => ({ date, label: date, value: agg.capacity }),
520 + );
521 + const occBookedBucketed = bucketSeries(occBookingSeries, effectiveView);
522 + const occCapacityBucketed = bucketSeries(occCapacitySeries, effectiveView);
565 523
566 - data.push({
567 - period: date.toLocaleDateString("en-US", {
568 - month: "short",
569 - day: "numeric",
570 - }),
571 - fullDate: date.toISOString().split("T")[0],
572 - // Booking Overview - based on real data
573 - bookings: dailyBookings,
574 - confirmed: dailyConfirmed,
575 - pending: dailyPending,
576 - cancelled: dailyCancelled,
577 - // Revenue Analysis - based on real data
578 - revenue: dailyRevenue,
579 - collected: Math.floor(dailyRevenue * 0.85),
580 - outstanding: Math.floor(dailyRevenue * 0.15),
581 - avgBookingValue:
582 - dailyBookings > 0 ? Math.floor(dailyRevenue / dailyBookings) : 0,
583 - // Trip Performance - based on real data
584 - tripBookings: dailyBookings,
585 - topTrip:
586 - reportData?.trip_performance?.topTrips?.[0]?.label || "No Data",
587 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
588 - // Departure Management - based on real data
589 - departures: Math.floor((baseDepartures / days) * seasonalFactor),
590 - capacity: Math.floor((baseCapacity / days) * seasonalFactor),
591 - booked: Math.floor(
592 - (baseCapacity / days) * seasonalFactor * (baseOccupancy / 100),
593 - ),
594 - // Customer Insights - derived from bookings
595 - newCustomers: Math.floor(dailyBookings * 0.3),
596 - returningCustomers: Math.floor(dailyBookings * 0.7),
597 - customerSatisfaction: Math.floor(90 * seasonalFactor),
598 - // Operational Metrics - based on real data
599 - leadTime: Math.floor(7 * (2 - seasonalFactor)), // Lower lead time for recent periods
600 - cancellationRate:
601 - dailyBookings > 0
602 - ? Math.floor((dailyCancelled / dailyBookings) * 100)
603 - : 0,
604 - efficiency: Math.floor(85 * seasonalFactor),
605 - });
606 - }
607 - } else if (viewType === "weekly") {
608 - const weeks = 12;
609 - for (let i = weeks - 1; i >= 0; i--) {
610 - const weekStart = new Date(today);
611 - weekStart.setDate(weekStart.getDate() - i * 7);
612 - const weekEnd = new Date(weekStart);
613 - weekEnd.setDate(weekEnd.getDate() + 6);
524 + // Build the unified row set the table iterates. Each row carries
525 + // every field a category column might want; unsupported fields stay
526 + // null so the renderer can show "—" instead of a fabricated number.
527 + type Row = {
528 + period: string;
529 + fullDate: string;
530 + // Booking columns
531 + bookings: number;
532 + confirmed: number;
533 + pending: number;
534 + cancelled: number;
535 + // Revenue
536 + revenue: number;
537 + avgBookingValue: number;
538 + // Trip performance
539 + occupancyRate: number;
540 + booked: number;
541 + capacity: number;
542 + // Departures
543 + departures: number;
544 + };
545 + const breakdownData: Row[] = bookingsBucketed.map((bRow, i) => {
546 + const rev = revenueBucketed[i]?.value ?? 0;
547 + const status = statusBucketed[i];
548 + const ob = occBookedBucketed.find((p) => p.label === bRow.label);
549 + const oc = occCapacityBucketed.find((p) => p.label === bRow.label);
550 + const bookedSeats = ob?.value ?? 0;
551 + const capacitySeats = oc?.value ?? 0;
552 + const occRate =
553 + capacitySeats > 0
554 + ? Math.round((bookedSeats / capacitySeats) * 1000) / 10
555 + : 0;
556 + return {
557 + period: bRow.label,
558 + fullDate: bRow.date,
559 + bookings: bRow.value,
560 + confirmed: status?.confirmed ?? 0,
561 + pending: status?.pending ?? 0,
562 + cancelled: status?.cancelled ?? 0,
563 + revenue: rev,
564 + avgBookingValue: bRow.value > 0 ? Math.round(rev / bRow.value) : 0,
565 + occupancyRate: occRate,
566 + booked: bookedSeats,
567 + capacity: capacitySeats,
568 + departures: capacitySeats > 0 ? 1 : 0, // count via departures_table when available
569 + };
570 + });
614 571
615 - // Calculate weekly distribution from totals with realistic patterns
616 - const weekIndex = weeks - i - 1; // 0 for most recent week
617 - const seasonalFactor = 0.7 + (weekIndex / weeks) * 0.6; // Recent weeks have higher activity
618 - const weeklyBookings = Math.floor((baseBookings / 4) * seasonalFactor);
619 - const weeklyRevenue = Math.floor((baseRevenue / 4) * seasonalFactor);
620 - const weeklyConfirmed = Math.floor(weeklyBookings * 0.8);
621 - const weeklyPending = Math.floor(weeklyBookings * 0.15);
622 - const weeklyCancelled =
623 - weeklyBookings - weeklyConfirmed - weeklyPending;
624 -
625 - data.push({
626 - period: `${weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`,
627 - fullDate: weekStart.toISOString().split("T")[0],
628 - // Booking Overview - based on real data
629 - bookings: weeklyBookings,
630 - confirmed: weeklyConfirmed,
631 - pending: weeklyPending,
632 - cancelled: weeklyCancelled,
633 - // Revenue Analysis - based on real data
634 - revenue: weeklyRevenue,
635 - collected: Math.floor(weeklyRevenue * 0.85),
636 - outstanding: Math.floor(weeklyRevenue * 0.15),
637 - avgBookingValue:
638 - weeklyBookings > 0 ? Math.floor(weeklyRevenue / weeklyBookings) : 0,
639 - // Trip Performance - based on real data
640 - tripBookings: weeklyBookings,
641 - topTrip:
642 - reportData?.trip_performance?.topTrips?.[i % 3]?.label || "No Data",
643 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
644 - // Departure Management - based on real data
645 - departures: Math.floor((baseDepartures / 4) * seasonalFactor),
646 - capacity: Math.floor((baseCapacity / 4) * seasonalFactor),
647 - booked: Math.floor(
648 - (baseCapacity / 4) * seasonalFactor * (baseOccupancy / 100),
649 - ),
650 - // Customer Insights - derived from bookings
651 - newCustomers: Math.floor(weeklyBookings * 0.3),
652 - returningCustomers: Math.floor(weeklyBookings * 0.7),
653 - customerSatisfaction: Math.floor(88 * seasonalFactor),
654 - // Operational Metrics - based on real data
655 - leadTime: Math.floor(8 * (2 - seasonalFactor)), // Lower lead time for recent periods
656 - cancellationRate:
657 - weeklyBookings > 0
658 - ? Math.floor((weeklyCancelled / weeklyBookings) * 100)
659 - : 0,
660 - efficiency: Math.floor(87 * seasonalFactor),
661 - });
572 + // Restore departures count if departures_table has them by bucket.
573 + if (departuresTable.length > 0) {
574 + const depCountSeries: SeriesPoint[] = (() => {
575 + const m = new Map<string, number>();
576 + for (const d of departuresTable) {
577 + const date = (d?.date || "").slice(0, 10);
578 + if (!date) continue;
579 + m.set(date, (m.get(date) || 0) + 1);
662 580 }
663 - } else if (viewType === "monthly") {
664 - const months = 12;
665 - for (let i = months - 1; i >= 0; i--) {
666 - const monthDate = new Date(today);
667 - monthDate.setMonth(monthDate.getMonth() - i);
581 + return Array.from(m.entries()).map(([date, value]) => ({
582 + date,
583 + label: date,
584 + value,
585 + }));
586 + })();
587 + const depBucketed = bucketSeries(depCountSeries, effectiveView);
588 + breakdownData.forEach((row) => {
589 + const hit = depBucketed.find((p) => p.label === row.period);
590 + row.departures = hit?.value ?? 0;
591 + });
592 + }
668 593
669 - // Calculate monthly distribution from totals with realistic patterns
670 - const monthIndex = months - i - 1; // 0 for most recent month
671 - const seasonalFactor = 0.6 + (monthIndex / months) * 0.8; // Recent months have higher activity
672 - const monthlyBookings = Math.floor(baseBookings * seasonalFactor);
673 - const monthlyRevenue = Math.floor(baseRevenue * seasonalFactor);
674 - const monthlyConfirmed = Math.floor(monthlyBookings * 0.8);
675 - const monthlyPending = Math.floor(monthlyBookings * 0.15);
676 - const monthlyCancelled =
677 - monthlyBookings - monthlyConfirmed - monthlyPending;
594 + // Suppress legacy unused references to avoid TS dead-code warnings
595 + // until the rest of the table renderer is also pruned.
596 + void occupancyTrend;
678 597
679 - data.push({
680 - period: monthDate.toLocaleDateString("en-US", {
681 - month: "long",
682 - year: "numeric",
683 - }),
684 - fullDate: monthDate.toISOString().split("T")[0],
685 - // Booking Overview - based on real data
686 - bookings: monthlyBookings,
687 - confirmed: monthlyConfirmed,
688 - pending: monthlyPending,
689 - cancelled: monthlyCancelled,
690 - // Revenue Analysis - based on real data
691 - revenue: monthlyRevenue,
692 - collected: Math.floor(monthlyRevenue * 0.85),
693 - outstanding: Math.floor(monthlyRevenue * 0.15),
694 - avgBookingValue:
695 - monthlyBookings > 0
696 - ? Math.floor(monthlyRevenue / monthlyBookings)
697 - : 0,
698 - // Trip Performance - based on real data
699 - tripBookings: monthlyBookings,
700 - topTrip:
701 - reportData?.trip_performance?.topTrips?.[i % 3]?.label || "No Data",
702 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
703 - // Departure Management - based on real data
704 - departures: Math.floor(baseDepartures * seasonalFactor),
705 - capacity: Math.floor(baseCapacity * seasonalFactor),
706 - booked: Math.floor(
707 - baseCapacity * seasonalFactor * (baseOccupancy / 100),
708 - ),
709 - // Customer Insights - derived from bookings
710 - newCustomers: Math.floor(monthlyBookings * 0.3),
711 - returningCustomers: Math.floor(monthlyBookings * 0.7),
712 - customerSatisfaction: Math.floor(90 * seasonalFactor),
713 - // Operational Metrics - based on real data
714 - leadTime: Math.floor(10 * (2 - seasonalFactor)), // Lower lead time for recent periods
715 - cancellationRate:
716 - monthlyBookings > 0
717 - ? Math.floor((monthlyCancelled / monthlyBookings) * 100)
718 - : 0,
719 - efficiency: Math.floor(88 * seasonalFactor),
720 - });
721 - }
722 - }
723 -
724 - return data;
725 - };
726 -
727 - const breakdownData = generateBreakdownData();
728 -
729 - // Get global currency settings - using all available Yatra currency settings
730 598 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
731 - const currencyPosition =
732 - (window as any)?.yatraAdmin?.currencyPosition ||
733 - (window as any)?.yatraAdmin?.currency_position ||
734 - "before";
735 - const decimalPlaces = Number(
736 - (window as any)?.yatraAdmin?.decimalPlaces ||
737 - (window as any)?.yatraAdmin?.currency_decimals ||
738 - 2,
739 - );
740 - const thousandSeparator =
741 - (window as any)?.yatraAdmin?.thousandSeparator || ",";
742 - const decimalSeparator = (window as any)?.yatraAdmin?.decimalSeparator || ".";
599 + const formatCurrencyAmount = (amount: number) =>
600 + formatYatraMoney(Number(amount) || 0, globalCurrency, {
601 + zeroAsUnknown: false,
602 + });
743 603
744 - const formatCurrencyAmount = (amount: number) => {
745 - if (!amount || amount === 0) return getCurrencySymbol(globalCurrency) + "0";
746 -
747 - const numPrice = Number(amount) || 0;
748 -
749 - // Format the number with proper separators
750 - const formattedAmount = new Intl.NumberFormat(undefined, {
751 - minimumFractionDigits: decimalPlaces,
752 - maximumFractionDigits: decimalPlaces,
753 - })
754 - .format(numPrice)
755 - .replace(/,/g, "TEMP_THOUSAND")
756 - .replace(/\./g, decimalSeparator)
757 - .replace(/TEMP_THOUSAND/g, thousandSeparator);
758 -
759 - // Get currency symbol
760 - const currencySymbol = getCurrencySymbol(globalCurrency);
761 -
762 - // Apply currency position
763 - if (currencyPosition === "after" || currencyPosition === "right") {
764 - return `${formattedAmount} ${currencySymbol}`;
765 - } else {
766 - return `${currencySymbol}${formattedAmount}`;
767 - }
768 - };
769 -
770 604 // Render different table headers and columns based on category
771 605 const renderTableHeaders = () => {
772 606 switch (selectedCategory) {
773 607 case "booking-overview":
@@ -790,8 +624,13 @@
790 624 </th>
791 625 </tr>
792 626 );
793 627 case "revenue-analysis":
628 + // Dropped "Collected" / "Outstanding" columns: we don't track
629 + // per-period collected vs. outstanding at the trend level. The
630 + // payment_status block in the Revenue Analysis section shows
631 + // the period-aggregate paid/pending/refunded split — that's
632 + // the right place for it.
794 633 return (
795 634 <tr>
796 635 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
797 636 {__("Period", "yatra")}
@@ -799,19 +638,19 @@
799 638 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
800 639 {__("Total Revenue", "yatra")}
801 640 </th>
802 641 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
803 - {__("Collected", "yatra")}
642 + {__("Bookings", "yatra")}
804 643 </th>
805 644 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
806 - {__("Outstanding", "yatra")}
807 - </th>
808 - <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
809 645 {__("Avg Booking Value", "yatra")}
810 646 </th>
811 647 </tr>
812 648 );
813 649 case "trip-performance":
650 + // Dropped "Top Trip" column: top trip is a period-level concept,
651 + // not a per-bucket one. The Top Trips card above the table
652 + // shows the real ranking.
814 653 return (
815 654 <tr>
816 655 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
817 656 {__("Period", "yatra")}
@@ -819,11 +658,8 @@
819 658 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
820 659 {__("Trip Bookings", "yatra")}
821 660 </th>
822 661 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
823 - {__("Top Trip", "yatra")}
824 - </th>
825 - <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
826 662 {__("Occupancy Rate", "yatra")}
827 663 </th>
828 664 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
829 665 {__("Revenue", "yatra")}
@@ -941,14 +777,13 @@
941 777 <>
942 778 <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-green-600 dark:text-green-400">
943 779 {formatCurrencyAmount(row.revenue)}
944 780 </td>
945 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-blue-600 dark:text-blue-400">
946 - {formatCurrencyAmount(row.collected)}
781 + <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
782 + <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400">
783 + {row.bookings}
784 + </span>
947 785 </td>
948 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-orange-600 dark:text-orange-400">
949 - {formatCurrencyAmount(row.outstanding)}
950 - </td>
951 786 <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-purple-600 dark:text-purple-400">
952 787 {formatCurrencyAmount(row.avgBookingValue)}
953 788 </td>
954 789 </>
@@ -956,17 +791,12 @@
956 791 {selectedCategory === "trip-performance" && (
957 792 <>
958 793 <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
959 794 <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400">
960 - {row.tripBookings}
795 + {row.bookings}
961 796 </span>
962 797 </td>
963 798 <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
964 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-400">
965 - {row.topTrip}
966 - </span>
967 - </td>
968 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
969 799 <div className="flex items-center">
970 800 <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
971 801 <div
972 802 className="bg-green-500 h-2 rounded-full"
@@ -1005,83 +835,57 @@
1005 835 <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
1006 836 <div
1007 837 className="bg-green-500 h-2 rounded-full"
1008 838 style={{
1009 - width: `${Math.round((row.booked / row.capacity) * 100)}%`,
839 + width:
840 + row.capacity > 0
841 + ? `${Math.round((row.booked / row.capacity) * 100)}%`
842 + : "0%",
1010 843 }}
1011 844 ></div>
1012 845 </div>
1013 846 <span className="text-xs font-medium">
1014 - {Math.round((row.booked / row.capacity) * 100)}%
847 + {row.capacity > 0
848 + ? Math.round((row.booked / row.capacity) * 100)
849 + : 0}
850 + %
1015 851 </span>
1016 852 </div>
1017 853 </td>
1018 854 </>
1019 855 )}
1020 - {selectedCategory === "customer-insights" && (
1021 - <>
1022 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1023 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-400">
1024 - {row.newCustomers}
1025 - </span>
1026 - </td>
1027 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1028 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400">
1029 - {row.returningCustomers}
1030 - </span>
1031 - </td>
1032 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1033 - <div className="flex items-center">
1034 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
1035 - <div
1036 - className="bg-green-500 h-2 rounded-full"
1037 - style={{ width: `${row.customerSatisfaction}%` }}
1038 - ></div>
1039 - </div>
1040 - <span className="text-xs font-medium">
1041 - {row.customerSatisfaction}%
1042 - </span>
1043 - </div>
1044 - </td>
1045 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-green-600 dark:text-green-400">
1046 - {formatCurrencyAmount(row.revenue)}
1047 - </td>
1048 - </>
1049 - )}
1050 - {selectedCategory === "operational-metrics" && (
1051 - <>
1052 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1053 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-400">
1054 - {row.leadTime}
1055 - </span>
1056 - </td>
1057 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1058 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-400">
1059 - {row.cancellationRate}%
1060 - </span>
1061 - </td>
1062 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1063 - <div className="flex items-center">
1064 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
1065 - <div
1066 - className="bg-green-500 h-2 rounded-full"
1067 - style={{ width: `${row.efficiency}%` }}
1068 - ></div>
1069 - </div>
1070 - <span className="text-xs font-medium">{row.efficiency}%</span>
1071 - </div>
1072 - </td>
1073 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1074 - <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-400">
1075 - {row.bookings}
1076 - </span>
1077 - </td>
1078 - </>
1079 - )}
1080 856 </tr>
1081 857 ));
1082 858 };
1083 859
860 + // Customer-Insights + Operational-Metrics no longer ship a per-period
861 + // breakdown table — we don't aggregate those metrics day-by-day yet,
862 + // and the previous version filled them with synthetic numbers. Show
863 + // a calm note pointing the operator at the summary cards above.
864 + const categoriesWithoutBreakdown = new Set([
865 + "customer-insights",
866 + "operational-metrics",
867 + ]);
868 + if (categoriesWithoutBreakdown.has(selectedCategory)) {
869 + return (
870 + <div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
871 + {__(
872 + "Per-period breakdown isn't available for this section. The summary cards above show the aggregate for the selected date range.",
873 + "yatra",
874 + )}
875 + </div>
876 + );
877 + }
878 +
879 + // Empty-state for ranges with no data — better than a 0-bar table.
880 + if (breakdownData.length === 0) {
881 + return (
882 + <div className="rounded-lg border border-dashed border-gray-300 dark:border-gray-700 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
883 + {__("No data in this date range yet.", "yatra")}
884 + </div>
885 + );
886 + }
887 +
1084 888 return (
1085 889 <div className="overflow-x-auto">
1086 890 <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
1087 891 <thead className="bg-gray-50 dark:bg-gray-800">
@@ -1194,10 +998,19 @@
1194 998
1195 999 const eventStats = getEventStats();
1196 1000 const recentEvents = getRecentEvents();
1197 1001
1198 - // Check if Facebook Pixel is configured
1199 - if (!facebookPixelData.pixel_id) {
1002 + // Empty-state gate.
1003 + // Three conditions all funnel into the same "Not Configured" screen
1004 + // because, from the operator's point of view, they look the same:
1005 + // 1. Pro plugin isn't installed/activated at all
1006 + // 2. Pro is installed but the Facebook Pixel module is toggled off
1007 + // under Modules — pre-existing pixel_id stays in the DB and
1008 + // would otherwise let this tab render with stale data
1009 + // 3. Module is on but no Pixel ID has been saved yet
1010 + const fbModuleActive =
1011 + isProPluginActive() && isModuleActive("facebook_pixel");
1012 + if (!fbModuleActive || !facebookPixelData.pixel_id) {
1200 1013 return (
1201 1014 <div className="text-center py-12">
1202 1015 <div className="mx-auto w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
1203 1016 <svg
@@ -1211,18 +1024,27 @@
1211 1024 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
1212 1025 {__("Facebook Pixel Not Configured", "yatra")}
1213 1026 </h3>
1214 1027 <p className="text-gray-600 dark:text-gray-400 mb-4">
1215 - {__(
1216 - "Configure your Facebook Pixel in Settings to start tracking conversion events.",
1217 - "yatra",
1218 - )}
1028 + {!fbModuleActive
1029 + ? __(
1030 + "Enable the Facebook Pixel module under Modules and add your Pixel ID in Settings to start tracking conversion events.",
1031 + "yatra",
1032 + )
1033 + : __(
1034 + "Configure your Facebook Pixel in Settings to start tracking conversion events.",
1035 + "yatra",
1036 + )}
1219 1037 </p>
1220 1038 <a
1221 - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=settings#integration`}
1039 + href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=${
1040 + !fbModuleActive ? "modules" : "settings#integration"
1041 + }`}
1222 1042 className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
1223 1043 >
1224 - {__("Configure Facebook Pixel", "yatra")}
1044 + {!fbModuleActive
1045 + ? __("Open Modules", "yatra")
1046 + : __("Configure Facebook Pixel", "yatra")}
1225 1047 </a>
1226 1048 </div>
1227 1049 );
1228 1050 }
@@ -1710,22 +1532,34 @@
1710 1532 isLoading: isGALoading,
1711 1533 } = useQuery({
1712 1534 queryKey: ["google-analytics-status"],
1713 1535 queryFn: async () => {
1714 - const response = await apiService.getGoogleAnalyticsSettings();
1715 - return response?.data || {};
1536 + const raw = await apiService.getGoogleAnalyticsSettings();
1537 + const payload = unwrapApiPayload<Record<string, unknown>>(raw);
1538 + return (payload && typeof payload === "object" ? payload : {}) as Record<
1539 + string,
1540 + unknown
1541 + >;
1716 1542 },
1717 1543 refetchInterval: 30000, // Refresh every 30 seconds
1718 1544 });
1719 1545
1720 1546 // Always use fresh data from API
1721 - const googleAnalyticsData = freshGAData || {};
1547 + const googleAnalyticsData = (freshGAData || {}) as {
1548 + measurement_id?: string;
1549 + eventLogs?: Array<{ status?: string; [key: string]: unknown }>;
1550 + connectionStatus?: {
1551 + measurementConnected?: boolean;
1552 + apiSecretConnected?: boolean;
1553 + };
1554 + [key: string]: unknown;
1555 + };
1722 1556
1723 1557 const getEventStats = () => {
1724 1558 const logs = googleAnalyticsData.eventLogs || [];
1725 1559 return {
1726 - success: logs.filter((log: any) => log.status === "success").length,
1727 - errors: logs.filter((log: any) => log.status === "error").length,
1560 + success: logs.filter((log) => log.status === "success").length,
1561 + errors: logs.filter((log) => log.status === "error").length,
1728 1562 total: logs.length,
1729 1563 };
1730 1564 };
1731 1565
@@ -1736,10 +1570,12 @@
1736 1570
1737 1571 const clearGALogs = async () => {
1738 1572 setClearingLogs(true);
1739 1573 try {
1740 - const response = await apiService.clearGoogleAnalyticsEventLogs();
1741 - if (response.success) {
1574 + const raw = (await apiService.clearGoogleAnalyticsEventLogs()) as {
1575 + success?: boolean;
1576 + };
1577 + if (raw?.success) {
1742 1578 showToast(__("Event logs cleared successfully.", "yatra"), "success");
1743 1579 // Refetch fresh data to update the UI
1744 1580 await refetchGAData();
1745 1581 }
@@ -1752,10 +1588,16 @@
1752 1588
1753 1589 const eventStats = getEventStats();
1754 1590 const recentEvents = getRecentEvents();
1755 1591
1756 - // Check if Google Analytics is configured
1757 - if (!googleAnalyticsData.measurement_id) {
1592 + // Empty-state gate. Mirrors the Facebook Pixel tab: same three
1593 + // funnel paths (no Pro, module disabled, no Measurement ID) → same
1594 + // visual treatment. Splits the CTA + body between
1595 + // "Enable the module" vs. "Configure in Settings" based on which
1596 + // condition tripped.
1597 + const gaModuleActive =
1598 + isProPluginActive() && isModuleActive("google_analytics");
1599 + if (!gaModuleActive || !googleAnalyticsData.measurement_id) {
1758 1600 return (
1759 1601 <div className="text-center py-12">
1760 1602 <div className="mx-auto w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
1761 1603 <svg
@@ -1772,18 +1614,27 @@
1772 1614 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
1773 1615 {__("Google Analytics 4 Not Configured", "yatra")}
1774 1616 </h3>
1775 1617 <p className="text-gray-600 dark:text-gray-400 mb-4">
1776 - {__(
1777 - "Configure your Google Analytics 4 in Settings to start tracking conversion events.",
1778 - "yatra",
1779 - )}
1618 + {!gaModuleActive
1619 + ? __(
1620 + "Enable the Google Analytics 4 module under Modules and add your Measurement ID in Settings to start tracking conversion events.",
1621 + "yatra",
1622 + )
1623 + : __(
1624 + "Configure your Google Analytics 4 in Settings to start tracking conversion events.",
1625 + "yatra",
1626 + )}
1780 1627 </p>
1781 1628 <a
1782 - href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=settings#integration`}
1629 + href={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=${
1630 + !gaModuleActive ? "modules" : "settings#integration"
1631 + }`}
1783 1632 className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
1784 1633 >
1785 - {__("Configure Google Analytics 4", "yatra")}
1634 + {!gaModuleActive
1635 + ? __("Open Modules", "yatra")
1636 + : __("Configure Google Analytics 4", "yatra")}
1786 1637 </a>
1787 1638 </div>
1788 1639 );
1789 1640 }
@@ -2263,12 +2114,32 @@
2263 2114 );
2264 2115 };
2265 2116
2266 2117 const TravelBookingReports: React.FC = () => {
2267 - const [selectedCategory, setSelectedCategory] = useState("booking-overview");
2118 + // Cap-filter the category list. canCap honors the WP admin fallback
2119 + // (admins see everything), the Team module's userCaps array (granular
2120 + // role-based), and falls back to default-allow when no team module is
2121 + // installed (so non-Team installs keep their pre-existing behavior).
2122 + const visibleCategories = useMemo(
2123 + () => TravelReportCategories.filter((c) => canCap(c.cap)),
2124 + [],
2125 + );
2126 +
2127 + const [selectedCategory, setSelectedCategory] = useState(
2128 + () => visibleCategories[0]?.id || "booking-overview",
2129 + );
2268 2130 const [dateRange, setDateRange] = useState("last_30_days");
2269 2131 const [viewType, setViewType] = useState("summary"); // 'summary', 'daily', 'weekly', 'monthly'
2270 2132
2133 + // If the operator's currently-selected tab becomes invalid (e.g.
2134 + // role changed mid-session), fall back to the first visible tab.
2135 + React.useEffect(() => {
2136 + if (!visibleCategories.some((c) => c.id === selectedCategory)) {
2137 + const fallback = visibleCategories[0]?.id;
2138 + if (fallback) setSelectedCategory(fallback);
2139 + }
2140 + }, [visibleCategories, selectedCategory]);
2141 +
2271 2142 // Fetch real data from Yatra ReportsController using apiClient
2272 2143 const { data: reportData, isLoading } = useQuery({
2273 2144 queryKey: ["yatra-travel-reports", dateRange],
2274 2145 queryFn: async () => {
@@ -2305,10 +2176,10 @@
2305 2176 start.setDate(today.getDate() - 30);
2306 2177 }
2307 2178
2308 2179 return {
2309 - start: start.toISOString().split("T")[0],
2310 - end: today.toISOString().split("T")[0],
2180 + start: formatDateForInput(start),
2181 + end: formatDateForInput(today),
2311 2182 };
2312 2183 }
2313 2184
2314 2185 // Travel Business KPIs
@@ -2332,80 +2203,121 @@
2332 2203 upcomingDepartures: reportData.operational_stats?.upcomingDepartures || 0,
2333 2204 };
2334 2205 }, [reportData]);
2335 2206
2336 - // Get global currency settings - using all available Yatra currency settings
2337 2207 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
2338 - const currencyPosition =
2339 - (window as any)?.yatraAdmin?.currencyPosition ||
2340 - (window as any)?.yatraAdmin?.currency_position ||
2341 - "before";
2342 - const decimalPlaces = Number(
2343 - (window as any)?.yatraAdmin?.decimalPlaces ||
2344 - (window as any)?.yatraAdmin?.currency_decimals ||
2345 - 2,
2346 - );
2347 - const thousandSeparator =
2348 - (window as any)?.yatraAdmin?.thousandSeparator || ",";
2349 - const decimalSeparator = (window as any)?.yatraAdmin?.decimalSeparator || ".";
2208 + const formatCurrencyAmount = (amount: number) =>
2209 + formatYatraMoney(Number(amount) || 0, globalCurrency, {
2210 + zeroAsUnknown: false,
2211 + });
2350 2212
2351 - const formatCurrencyAmount = (amount: number) => {
2352 - if (!amount || amount === 0) return getCurrencySymbol(globalCurrency) + "0";
2213 + // CSV export — bundles the visible breakdown (or summary KPIs when
2214 + // no breakdown is appropriate) into a downloadable file scoped to
2215 + // the picked date range. Doesn't hit the network; everything we
2216 + // already have in memory is enough.
2217 + const handleExportCsv = () => {
2218 + const params = getDateRangeParams(dateRange);
2219 + const rows: (string | number | null | undefined)[][] = [];
2353 2220
2354 - const numPrice = Number(amount) || 0;
2221 + // Top KPI summary first — operators paste this into the email
2222 + // before the detail table.
2223 + rows.push(["Yatra Reports — Summary"]);
2224 + rows.push([`Date range: ${params.start} to ${params.end}`]);
2225 + rows.push([]);
2226 + rows.push(["Metric", "Value"]);
2227 + rows.push(["Total bookings", travelKPIs.totalBookings]);
2228 + rows.push(["Total revenue", travelKPIs.totalRevenue]);
2229 + rows.push(["Avg booking value", travelKPIs.avgBookingValue]);
2230 + rows.push(["Occupancy rate (%)", travelKPIs.occupancyRate]);
2231 + rows.push(["Cancellation rate (%)", travelKPIs.cancellationRate]);
2232 + rows.push(["Upcoming departures", travelKPIs.upcomingDepartures]);
2233 + rows.push([]);
2355 2234
2356 - // Format the number with proper separators
2357 - const formattedAmount = new Intl.NumberFormat(undefined, {
2358 - minimumFractionDigits: decimalPlaces,
2359 - maximumFractionDigits: decimalPlaces,
2360 - })
2361 - .format(numPrice)
2362 - .replace(/,/g, "TEMP_THOUSAND")
2363 - .replace(/\./g, decimalSeparator)
2364 - .replace(/TEMP_THOUSAND/g, thousandSeparator);
2235 + // Booking trend (day-level) if the operator has revenue/booking data.
2236 + const trend: any[] = reportData?.booking_trend || [];
2237 + const revenueTrend: any[] = reportData?.revenue_trend || [];
2238 + if (trend.length) {
2239 + rows.push(["Day", "Bookings", "Revenue"]);
2240 + trend.forEach((row: any, i: number) => {
2241 + rows.push([
2242 + row.date || row.label,
2243 + row.value,
2244 + revenueTrend[i]?.value ?? "",
2245 + ]);
2246 + });
2247 + rows.push([]);
2248 + }
2365 2249
2366 - // Get currency symbol
2367 - const currencySymbol = getCurrencySymbol(globalCurrency);
2250 + // Top trips
2251 + const tp = reportData?.trip_performance || [];
2252 + if (tp.length) {
2253 + rows.push(["Trip", "Bookings", "Revenue", "Occupancy %"]);
2254 + tp.forEach((t: any) => {
2255 + rows.push([t.label, t.value, t.revenue, t.occupancy]);
2256 + });
2257 + rows.push([]);
2258 + }
2368 2259
2369 - // Apply currency position
2370 - if (currencyPosition === "after" || currencyPosition === "right") {
2371 - return `${formattedAmount} ${currencySymbol}`;
2372 - } else {
2373 - return `${currencySymbol}${formattedAmount}`;
2260 + // Payment methods
2261 + const pm = reportData?.payment_methods || [];
2262 + if (pm.length) {
2263 + rows.push(["Payment method", "Count", "Revenue"]);
2264 + pm.forEach((m: any) => {
2265 + rows.push([m.method, m.count, m.revenue]);
2266 + });
2267 + rows.push([]);
2374 2268 }
2269 +
2270 + // Top destinations
2271 + const dest = reportData?.top_destinations || [];
2272 + if (dest.length) {
2273 + rows.push(["Destination", "Bookings", "Revenue"]);
2274 + dest.forEach((d: any) => {
2275 + rows.push([d.label, d.value, d.revenue]);
2276 + });
2277 + rows.push([]);
2278 + }
2279 +
2280 + const blob = buildCsv(rows);
2281 + downloadCsv(blob, csvFilename("reports", params.start, params.end));
2375 2282 };
2376 2283
2377 2284 return (
2378 2285 <div className="space-y-6">
2379 - {/* Header */}
2380 - <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
2286 + {/* ── HEADER ──────────────────────────────────────────────────── */}
2287 + <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
2381 2288 <div>
2382 - <h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
2289 + <h1 className="flex items-center gap-3 text-2xl font-bold text-gray-900 dark:text-white">
2383 2290 <SVGIcons.BarChart />
2384 - Travel Booking Reports
2291 + {__("Travel Booking Reports", "yatra")}
2385 2292 </h1>
2386 - <p className="text-gray-600 dark:text-gray-400">
2387 - Essential analytics for your travel booking business
2293 + <p className="text-sm text-gray-600 dark:text-gray-400">
2294 + {__(
2295 + "Essential analytics for your travel booking business.",
2296 + "yatra",
2297 + )}
2388 2298 </p>
2389 2299 </div>
2390 2300
2391 - <div className="flex items-center gap-3">
2301 + <div className="flex flex-wrap items-center gap-2">
2392 2302 <select
2393 2303 value={dateRange}
2394 2304 onChange={(e) => setDateRange(e.target.value)}
2395 - className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400"
2305 + className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
2306 + aria-label={__("Date range", "yatra")}
2396 2307 >
2397 - <option value="today">Today</option>
2398 - <option value="last_7_days">Last 7 Days</option>
2399 - <option value="last_30_days">Last 30 Days</option>
2400 - <option value="last_90_days">Last 90 Days</option>
2401 - <option value="this_year">This Year</option>
2308 + <option value="today">{__("Today", "yatra")}</option>
2309 + <option value="last_7_days">{__("Last 7 days", "yatra")}</option>
2310 + <option value="last_30_days">{__("Last 30 days", "yatra")}</option>
2311 + <option value="last_90_days">{__("Last 90 days", "yatra")}</option>
2312 + <option value="this_year">{__("This year", "yatra")}</option>
2402 2313 </select>
2403 2314
2404 2315 <select
2405 2316 value={viewType}
2406 2317 onChange={(e) => setViewType(e.target.value)}
2407 - className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-400"
2318 + className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
2319 + aria-label={__("Breakdown view", "yatra")}
2408 2320 >
2409 2321 <option value="summary">{__("Summary View", "yatra")}</option>
2410 2322 <option value="daily">{__("Daily Breakdown", "yatra")}</option>
2411 2323 <option value="weekly">{__("Weekly Breakdown", "yatra")}</option>
@@ -2410,8 +2322,19 @@
2410 2322 <option value="daily">{__("Daily Breakdown", "yatra")}</option>
2411 2323 <option value="weekly">{__("Weekly Breakdown", "yatra")}</option>
2412 2324 <option value="monthly">{__("Monthly Breakdown", "yatra")}</option>
2413 2325 </select>
2326 +
2327 + <Button
2328 + type="button"
2329 + variant="outline"
2330 + size="sm"
2331 + onClick={handleExportCsv}
2332 + disabled={isLoading || !reportData}
2333 + title={__("Download this report's data as CSV", "yatra")}
2334 + >
2335 + {__("Export CSV", "yatra")}
2336 + </Button>
2414 2337 </div>
2415 2338 </div>
2416 2339
2417 2340 {/* Key Performance Indicators */}
@@ -2552,9 +2475,9 @@
2552 2475 value={selectedCategory}
2553 2476 onChange={(e) => setSelectedCategory(e.target.value)}
2554 2477 aria-label={__("Report section", "yatra")}
2555 2478 >
2556 - {TravelReportCategories.map((category) => (
2479 + {visibleCategories.map((category) => (
2557 2480 <option key={category.id} value={category.id}>
2558 2481 {__(category.title, "yatra")}
2559 2482 </option>
2560 2483 ))}
@@ -2564,9 +2487,9 @@
2564 2487 <nav
2565 2488 className="hidden md:flex min-w-0 flex-nowrap items-stretch gap-1 overflow-x-auto overflow-y-hidden scroll-smooth px-4 pe-6 pb-1 sm:px-6 sm:pe-8 [scrollbar-width:thin] [-webkit-overflow-scrolling:touch]"
2566 2489 aria-label={__("Report sections", "yatra")}
2567 2490 >
2568 - {TravelReportCategories.map((category) => (
2491 + {visibleCategories.map((category) => (
2569 2492 <button
2570 2493 key={category.id}
2571 2494 type="button"
2572 2495 onClick={() => setSelectedCategory(category.id)}
@@ -2882,8 +2805,219 @@
2882 2805 {(
2883 2806 reportData?.booking_stats?.cancellationRate || 0
2884 2807 ).toFixed(1)}
2885 2808 %
2809 + </p>
2810 + </div>
2811 + </div>
2812 + </div>
2813 + )}
2814 +
2815 + {/* ── REVENUE ANALYSIS — Payment method breakdown ─────────
2816 + Operators routinely want to know which gateways are
2817 + pulling weight (and which they could turn off). The
2818 + data is computed in the backend per request. */}
2819 + {selectedCategory === "revenue-analysis" &&
2820 + Array.isArray(reportData?.payment_methods) &&
2821 + reportData.payment_methods.length > 0 && (
2822 + <div className="mt-6">
2823 + <h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
2824 + {__("Payment Methods", "yatra")}
2825 + </h3>
2826 + <p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
2827 + {__(
2828 + "Bookings and gross revenue split by payment gateway. Ranked by revenue.",
2829 + "yatra",
2830 + )}
2831 + </p>
2832 + <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
2833 + {reportData.payment_methods.slice(0, 6).map((m: any) => (
2834 + <div
2835 + key={m.method}
2836 + className="rounded-lg border border-gray-100 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
2837 + >
2838 + <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
2839 + {m.method}
2840 + </p>
2841 + <p className="mt-1 text-lg font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">
2842 + {formatCurrencyAmount(m.revenue)}
2843 + </p>
2844 + <p className="text-xs text-gray-500 dark:text-gray-400">
2845 + {m.count} {__("bookings", "yatra")}
2846 + </p>
2847 + </div>
2848 + ))}
2849 + </div>
2850 + </div>
2851 + )}
2852 +
2853 + {/* ── TRIP PERFORMANCE — Top destinations ────────────────
2854 + Geographic concentration. Useful when paired with
2855 + Top Trips: a single trip can dominate a destination,
2856 + or a destination can have a long tail of small wins. */}
2857 + {selectedCategory === "trip-performance" &&
2858 + Array.isArray(reportData?.top_destinations) &&
2859 + reportData.top_destinations.length > 0 && (
2860 + <div className="mt-6">
2861 + <h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
2862 + {__("Top Destinations", "yatra")}
2863 + </h3>
2864 + <p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
2865 + {__(
2866 + "Booking count and revenue by primary destination. Useful for spotting geographic concentration.",
2867 + "yatra",
2868 + )}
2869 + </p>
2870 + <div className="space-y-2">
2871 + {reportData.top_destinations.map((d: any) => {
2872 + const max = Math.max(
2873 + 1,
2874 + ...reportData.top_destinations.map(
2875 + (x: any) => x.value,
2876 + ),
2877 + );
2878 + return (
2879 + <div
2880 + key={d.label}
2881 + className="flex items-center gap-3"
2882 + >
2883 + <div className="w-32 truncate text-sm text-gray-700 dark:text-gray-200">
2884 + {d.label}
2885 + </div>
2886 + <div className="relative h-3 flex-1 rounded-full bg-gray-100 dark:bg-gray-700">
2887 + <div
2888 + className="h-3 rounded-full bg-blue-500"
2889 + style={{
2890 + width: `${Math.max(2, (d.value / max) * 100)}%`,
2891 + }}
2892 + />
2893 + </div>
2894 + <div className="w-16 text-right text-sm tabular-nums text-gray-700 dark:text-gray-200">
2895 + {d.value}
2896 + </div>
2897 + <div className="w-24 text-right text-sm font-medium tabular-nums text-emerald-600 dark:text-emerald-400">
2898 + {formatCurrencyAmount(d.revenue || 0)}
2899 + </div>
2900 + </div>
2901 + );
2902 + })}
2903 + </div>
2904 + </div>
2905 + )}
2906 +
2907 + {/* ── OPERATIONAL METRICS — Lead time histogram ──────────
2908 + How far in advance customers book. Same-day = last-
2909 + minute demand. >quarter = need solid deposit policy. */}
2910 + {selectedCategory === "operational-metrics" &&
2911 + reportData?.lead_time && (
2912 + <div className="mt-6">
2913 + <h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
2914 + {__("Booking Lead Time", "yatra")}
2915 + </h3>
2916 + <p className="mb-3 text-xs text-gray-500 dark:text-gray-400">
2917 + {__(
2918 + "Time between booking creation and travel date. Average:",
2919 + "yatra",
2920 + )}{" "}
2921 + <span className="font-medium text-gray-700 dark:text-gray-200">
2922 + {Number(reportData.lead_time.averageDays || 0).toFixed(
2923 + 1,
2924 + )}{" "}
2925 + {__("days", "yatra")}
2926 + </span>{" "}
2927 + ({reportData.lead_time.sampleSize}{" "}
2928 + {__("bookings sampled", "yatra")})
2929 + </p>
2930 + <div className="space-y-2">
2931 + {(reportData.lead_time.buckets || []).map((b: any) => {
2932 + const total = (
2933 + reportData.lead_time.buckets || []
2934 + ).reduce((s: number, x: any) => s + x.value, 0);
2935 + const pct =
2936 + total > 0 ? Math.round((b.value / total) * 100) : 0;
2937 + return (
2938 + <div
2939 + key={b.label}
2940 + className="flex items-center gap-3"
2941 + >
2942 + <div className="w-44 truncate text-sm text-gray-700 dark:text-gray-200">
2943 + {b.label}
2944 + </div>
2945 + <div className="relative h-3 flex-1 rounded-full bg-gray-100 dark:bg-gray-700">
2946 + <div
2947 + className="h-3 rounded-full"
2948 + style={{
2949 + width: `${Math.max(2, pct)}%`,
2950 + background: b.color || "#3b82f6",
2951 + }}
2952 + />
2953 + </div>
2954 + <div className="w-16 text-right text-sm tabular-nums text-gray-700 dark:text-gray-200">
2955 + {b.value}
2956 + </div>
2957 + <div className="w-12 text-right text-xs text-gray-500 dark:text-gray-400">
2958 + {pct}%
2959 + </div>
2960 + </div>
2961 + );
2962 + })}
2963 + </div>
2964 + </div>
2965 + )}
2966 +
2967 + {/* ── OPERATIONAL METRICS — Refunds summary ──────────────
2968 + Refunds are distinct from cancellations: cancelling
2969 + doesn't necessarily refund (deposit policy). Track
2970 + separately so finance has a clean view.
2971 +
2972 + Renders unconditionally on this tab — falls back to
2973 + zero KPIs when the backend hasn't shipped the
2974 + `refunds` block yet (e.g. PHP-FPM cached an older
2975 + copy of the controller). Visible empty-state beats
2976 + silently hiding the section. */}
2977 + {selectedCategory === "operational-metrics" && (
2978 + <div className="mt-6">
2979 + <h3 className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
2980 + {__("Refunds", "yatra")}
2981 + </h3>
2982 + <div className="grid grid-cols-1 gap-3 md:grid-cols-4">
2983 + <div className="rounded-lg border border-gray-100 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
2984 + <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
2985 + {__("Refunds issued", "yatra")}
2986 + </p>
2987 + <p className="mt-1 text-lg font-semibold tabular-nums text-red-600 dark:text-red-400">
2988 + {reportData?.refunds?.count ?? 0}
2989 + </p>
2990 + </div>
2991 + <div className="rounded-lg border border-gray-100 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
2992 + <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
2993 + {__("Refund total", "yatra")}
2994 + </p>
2995 + <p className="mt-1 text-lg font-semibold tabular-nums text-red-600 dark:text-red-400">
2996 + {formatCurrencyAmount(
2997 + Number(reportData?.refunds?.total) || 0,
2998 + )}
2999 + </p>
3000 + </div>
3001 + <div className="rounded-lg border border-gray-100 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
3002 + <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
3003 + {__("Refund rate", "yatra")}
3004 + </p>
3005 + <p className="mt-1 text-lg font-semibold tabular-nums text-amber-600 dark:text-amber-400">
3006 + {Number(reportData?.refunds?.refundRate || 0).toFixed(
3007 + 1,
3008 + )}
3009 + %
3010 + </p>
3011 + </div>
3012 + <div className="rounded-lg border border-gray-100 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
3013 + <p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
3014 + {__("Avg refund", "yatra")}
3015 + </p>
3016 + <p className="mt-1 text-lg font-semibold tabular-nums text-gray-700 dark:text-gray-200">
3017 + {formatCurrencyAmount(
3018 + Number(reportData?.refunds?.avgRefund) || 0,
3019 + )}
2886 3020 </p>
2887 3021 </div>
2888 3022 </div>
2889 3023 </div>