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 +714 -490 3.0.4 → 3.0.17 View file →
@@ -6,8 +6,9 @@
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";
11 12 import { unwrapApiPayload } from "../lib/unwrap-api-payload";
12 13 import { useToast } from "../components/ui/toast";
13 14 import {
@@ -21,8 +22,20 @@
21 22 import { Select } from "../components/ui/select";
22 23 import BookingsOverviewChart from "../components/charts/BookingsOverviewChart";
23 24 import BookingStatusChart from "../components/charts/BookingStatusChart";
24 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";
25 38
26 39 // Skeleton Loading Components
27 40 const SkeletonCard = () => (
28 41 <Card className="p-4">
@@ -214,14 +227,27 @@
214 227 ),
215 228 };
216 229
217 230 // Travel Business Report Categories
218 -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 +}> = [
219 244 {
220 245 id: "booking-overview",
221 246 title: "Booking Overview",
222 247 icon: "Calendar",
223 248 description: "Booking volume, status distribution, trends",
249 + cap: "yatra_view_operational_reports",
224 250 },
225 251 {
226 252 id: "revenue-analysis",
227 253 title: "Revenue Analysis",
@@ -226,8 +252,9 @@
226 252 id: "revenue-analysis",
227 253 title: "Revenue Analysis",
228 254 icon: "DollarSign",
229 255 description: "Revenue trends, payment status, profitability",
256 + cap: "yatra_view_financial_reports",
230 257 },
231 258 {
232 259 id: "trip-performance",
233 260 title: "Trip Performance",
@@ -232,8 +259,9 @@
232 259 id: "trip-performance",
233 260 title: "Trip Performance",
234 261 icon: "MapPin",
235 262 description: "Trip popularity, occupancy rates, capacity utilization",
263 + cap: "yatra_view_operational_reports",
236 264 },
237 265 {
238 266 id: "departure-management",
239 267 title: "Departure Management",
@@ -238,8 +266,9 @@
238 266 id: "departure-management",
239 267 title: "Departure Management",
240 268 icon: "Truck",
241 269 description: "Upcoming departures, capacity planning, scheduling",
270 + cap: "yatra_view_departures",
242 271 },
243 272 {
244 273 id: "customer-insights",
245 274 title: "Customer Insights",
@@ -244,8 +273,9 @@
244 273 id: "customer-insights",
245 274 title: "Customer Insights",
246 275 icon: "Users",
247 276 description: "Customer behavior, retention, demographics",
277 + cap: "yatra_view_customers",
248 278 },
249 279 {
250 280 id: "operational-metrics",
251 281 title: "Operational Metrics",
@@ -250,8 +280,9 @@
250 280 id: "operational-metrics",
251 281 title: "Operational Metrics",
252 282 icon: "Activity",
253 283 description: "Lead times, cancellations, efficiency metrics",
284 + cap: "yatra_view_operational_reports",
254 285 },
255 286 {
256 287 id: "facebook-pixel",
257 288 title: "Facebook Pixel",
@@ -256,8 +287,9 @@
256 287 id: "facebook-pixel",
257 288 title: "Facebook Pixel",
258 289 icon: "Facebook",
259 290 description: "Conversion tracking, event analytics, pixel performance",
291 + cap: "yatra_view_operational_reports",
260 292 },
261 293 {
262 294 id: "google-analytics",
263 295 title: "Google Analytics 4",
@@ -263,18 +295,29 @@
263 295 title: "Google Analytics 4",
264 296 icon: "Google",
265 297 description:
266 298 "Enhanced e-commerce tracking, Measurement Protocol, visitor analytics",
299 + cap: "yatra_view_operational_reports",
267 300 },
268 301 ];
269 302
270 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.
271 314 const DetailedBreakdownChart: React.FC<{
272 315 viewType: string;
273 316 dateRange: string;
274 317 selectedCategory: string;
275 318 reportData?: any;
276 -}> = ({ viewType, dateRange, selectedCategory, reportData }) => {
319 +}> = ({ viewType, selectedCategory, reportData }) => {
277 320 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
278 321 const formatCurrencyAmount = (amount: number) =>
279 322 formatYatraMoney(Number(amount) || 0, globalCurrency, {
280 323 zeroAsUnknown: false,
@@ -279,201 +322,123 @@
279 322 formatYatraMoney(Number(amount) || 0, globalCurrency, {
280 323 zeroAsUnknown: false,
281 324 });
282 325
283 - // Generate chart data based on view type using real API data
284 - const generateChartData = () => {
285 - const data = [];
286 - const today = new Date();
287 -
288 - // Base values from API data - NO FALLBACKS, use real data only
289 - const baseBookings = reportData?.booking_stats?.totalBookings || 0;
290 - const baseRevenue = reportData?.revenue_stats?.totalRevenue || 0;
291 - const baseDepartures = reportData?.departure_stats?.totalDepartures || 0;
292 -
293 - if (viewType === "daily") {
294 - const days =
295 - dateRange === "last_7_days"
296 - ? 7
297 - : dateRange === "last_30_days"
298 - ? 30
299 - : 90;
300 - for (let i = days - 1; i >= 0; i--) {
301 - const date = new Date(today);
302 - date.setDate(date.getDate() - i);
303 -
304 - // Calculate daily values from totals with realistic distribution
305 - const dayIndex = days - i - 1; // 0 for most recent day
306 - const seasonalFactor = 0.8 + (dayIndex / days) * 0.4; // Recent days have higher activity
307 - const dailyBookings = Math.floor(
308 - (baseBookings / days) * seasonalFactor,
309 - );
310 - const dailyRevenue = Math.floor((baseRevenue / days) * seasonalFactor);
311 - const dailyDepartures = Math.floor(
312 - (baseDepartures / days) * seasonalFactor,
313 - );
314 -
315 - data.push({
316 - name: date.toLocaleDateString("en-US", {
317 - month: "short",
318 - day: "numeric",
319 - }),
320 - bookings: dailyBookings,
321 - revenue: dailyRevenue,
322 - departures: dailyDepartures,
323 - });
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[]) || [];
332 + }
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 + }));
324 351 }
325 - } else if (viewType === "weekly") {
326 - const weeks = 8; // Show last 8 weeks for better chart readability
327 - for (let i = weeks - 1; i >= 0; i--) {
328 - const weekStart = new Date(today);
329 - weekStart.setDate(weekStart.getDate() - i * 7);
352 + return (reportData.booking_trend as SeriesPoint[]) || [];
353 + }
354 + return (reportData.booking_trend as SeriesPoint[]) || [];
355 + }, [reportData, selectedCategory]);
330 356
331 - // Calculate weekly values from totals with realistic distribution
332 - const weekIndex = weeks - i - 1; // 0 for most recent week
333 - const seasonalFactor = 0.7 + (weekIndex / weeks) * 0.6; // Recent weeks have higher activity
334 - const weeklyBookings = Math.floor((baseBookings / 4) * seasonalFactor);
335 - const weeklyRevenue = Math.floor((baseRevenue / 4) * seasonalFactor);
336 - const weeklyDepartures = Math.floor(
337 - (baseDepartures / 4) * seasonalFactor,
338 - );
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 + );
339 365
340 - data.push({
341 - name: `W${weeks - i}`,
342 - bookings: weeklyBookings,
343 - revenue: weeklyRevenue,
344 - departures: weeklyDepartures,
345 - });
346 - }
347 - } else if (viewType === "monthly") {
348 - const months = 6; // Show last 6 months for better chart readability
349 - for (let i = months - 1; i >= 0; i--) {
350 - const monthDate = new Date(today);
351 - 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 + }
352 376
353 - // Calculate monthly values from totals with realistic distribution
354 - const monthIndex = months - i - 1; // 0 for most recent month
355 - const seasonalFactor = 0.6 + (monthIndex / months) * 0.8; // Recent months have higher activity
356 - const monthlyBookings = Math.floor(baseBookings * seasonalFactor);
357 - const monthlyRevenue = Math.floor(baseRevenue * seasonalFactor);
358 - 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";
359 381
360 - data.push({
361 - name: monthDate.toLocaleDateString("en-US", { month: "short" }),
362 - bookings: monthlyBookings,
363 - revenue: monthlyRevenue,
364 - departures: monthlyDepartures,
365 - });
366 - }
367 - }
382 + const barClass = isRevenue
383 + ? "bg-emerald-500"
384 + : isDepartures
385 + ? "bg-purple-500"
386 + : "bg-blue-500";
368 387
369 - return data;
370 - };
388 + const valueLabel = (v: number) =>
389 + isRevenue ? formatCurrencyAmount(v) : v.toLocaleString();
371 390
372 - const chartData = generateChartData();
373 - const maxRevenue = Math.max(...chartData.map((d) => d.revenue));
374 - const maxBookings = Math.max(...chartData.map((d) => d.bookings));
375 -
376 391 return (
377 392 <div className="space-y-4">
378 393 <div className="text-center">
379 - <h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
380 - {selectedCategory === "booking-overview" &&
381 - __("Bookings Trend", "yatra")}
382 - {selectedCategory === "revenue-analysis" &&
383 - __("Revenue Trend", "yatra")}
384 - {selectedCategory === "departure-management" &&
385 - __("Departures Trend", "yatra")}
386 - {![
387 - "booking-overview",
388 - "revenue-analysis",
389 - "departure-management",
390 - ].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")}
391 400 </h4>
392 401 </div>
393 402
394 - {/* 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. */}
395 407 <div className="space-y-2">
396 - {chartData.map((item, index) => (
397 - <div key={index} className="flex items-center gap-2">
398 - <div className="w-8 text-xs text-gray-600 dark:text-gray-400 text-right">
399 - {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}
400 415 </div>
401 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-4 relative">
402 - {/* Revenue Bar */}
403 - {selectedCategory === "revenue-analysis" && (
404 - <div
405 - className="bg-green-500 h-4 rounded-full flex items-center justify-end pr-1"
406 - style={{ width: `${(item.revenue / maxRevenue) * 100}%` }}
407 - >
408 - <span className="text-xs text-white font-medium">
409 - {formatCurrencyAmount(item.revenue / 1000)}k
410 - </span>
411 - </div>
412 - )}
413 -
414 - {/* Bookings Bar */}
415 - {(selectedCategory === "booking-overview" ||
416 - !["revenue-analysis", "departure-management"].includes(
417 - selectedCategory,
418 - )) && (
419 - <div
420 - className="bg-blue-500 h-4 rounded-full flex items-center justify-end pr-1"
421 - style={{ width: `${(item.bookings / maxBookings) * 100}%` }}
422 - >
423 - <span className="text-xs text-white font-medium">
424 - {item.bookings}
425 - </span>
426 - </div>
427 - )}
428 -
429 - {/* Departures Bar */}
430 - {selectedCategory === "departure-management" && (
431 - <div
432 - className="bg-purple-500 h-4 rounded-full flex items-center justify-end pr-1"
433 - style={{
434 - width: `${(item.departures / Math.max(...chartData.map((d) => d.departures))) * 100}%`,
435 - }}
436 - >
437 - <span className="text-xs text-white font-medium">
438 - {item.departures}
439 - </span>
440 - </div>
441 - )}
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>
442 427 </div>
443 428 </div>
444 429 ))}
445 430 </div>
446 431
447 - {/* Legend */}
448 - <div className="flex flex-wrap gap-2 text-xs">
449 - {selectedCategory === "revenue-analysis" && (
450 - <div className="flex items-center gap-1">
451 - <div className="w-3 h-3 bg-green-500 rounded"></div>
452 - <span className="text-gray-600 dark:text-gray-400">
453 - {__("Revenue", "yatra")}
454 - </span>
455 - </div>
456 - )}
457 - {(selectedCategory === "booking-overview" ||
458 - !["revenue-analysis", "departure-management"].includes(
459 - selectedCategory,
460 - )) && (
461 - <div className="flex items-center gap-1">
462 - <div className="w-3 h-3 bg-blue-500 rounded"></div>
463 - <span className="text-gray-600 dark:text-gray-400">
464 - {__("Bookings", "yatra")}
465 - </span>
466 - </div>
467 - )}
468 - {selectedCategory === "departure-management" && (
469 - <div className="flex items-center gap-1">
470 - <div className="w-3 h-3 bg-purple-500 rounded"></div>
471 - <span className="text-gray-600 dark:text-gray-400">
472 - {__("Departures", "yatra")}
473 - </span>
474 - </div>
475 - )}
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>
476 441 </div>
477 442 </div>
478 443 );
479 444 };
@@ -478,221 +443,159 @@
478 443 );
479 444 };
480 445
481 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.
482 470 const DetailedBreakdownTable: React.FC<{
483 471 viewType: string;
484 472 dateRange: string;
485 473 selectedCategory: string;
486 474 reportData: any;
487 -}> = ({ viewType, dateRange, selectedCategory, reportData }) => {
488 - // Generate breakdown data based on view type and category using real API data
489 - const generateBreakdownData = () => {
490 - const data = [];
491 - 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";
492 485
493 - // Base values from API data - NO FALLBACKS, use real data only
494 - const baseBookings = reportData?.booking_stats?.totalBookings || 0;
495 - const baseRevenue = reportData?.revenue_stats?.totalRevenue || 0;
496 - // These are available for more granular breakdown if needed
497 - const _baseConfirmed = reportData?.booking_stats?.confirmedBookings || 0;
498 - const _basePending = reportData?.booking_stats?.pendingBookings || 0;
499 - const _baseCancelled = reportData?.booking_stats?.cancelledBookings || 0;
500 - void _baseConfirmed;
501 - void _basePending;
502 - void _baseCancelled; // Suppress unused warnings
503 - const baseDepartures = reportData?.departure_stats?.totalDepartures || 0;
504 - const baseCapacity = reportData?.departure_stats?.totalCapacity || 0;
505 - 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[]) || [];
506 494
507 - if (viewType === "daily") {
508 - const days =
509 - dateRange === "last_7_days"
510 - ? 7
511 - : dateRange === "last_30_days"
512 - ? 30
513 - : 90;
514 - for (let i = days - 1; i >= 0; i--) {
515 - const date = new Date(today);
516 - 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);
517 499
518 - // Calculate daily distribution from totals with realistic patterns
519 - const dayIndex = days - i - 1; // 0 for most recent day
520 - const seasonalFactor = 0.8 + (dayIndex / days) * 0.4; // Recent days have higher activity
521 - const dailyBookings = Math.floor(
522 - (baseBookings / days) * seasonalFactor,
523 - );
524 - const dailyRevenue = Math.floor((baseRevenue / days) * seasonalFactor);
525 - const dailyConfirmed = Math.floor(dailyBookings * 0.8);
526 - const dailyPending = Math.floor(dailyBookings * 0.15);
527 - const dailyCancelled = Math.max(
528 - 0,
529 - dailyBookings - dailyConfirmed - dailyPending,
530 - );
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);
531 523
532 - data.push({
533 - period: date.toLocaleDateString("en-US", {
534 - month: "short",
535 - day: "numeric",
536 - }),
537 - fullDate: date.toISOString().split("T")[0],
538 - // Booking Overview - based on real data
539 - bookings: dailyBookings,
540 - confirmed: dailyConfirmed,
541 - pending: dailyPending,
542 - cancelled: dailyCancelled,
543 - // Revenue Analysis - based on real data
544 - revenue: dailyRevenue,
545 - collected: Math.floor(dailyRevenue * 0.85),
546 - outstanding: Math.floor(dailyRevenue * 0.15),
547 - avgBookingValue:
548 - dailyBookings > 0 ? Math.floor(dailyRevenue / dailyBookings) : 0,
549 - // Trip Performance - based on real data
550 - tripBookings: dailyBookings,
551 - topTrip:
552 - reportData?.trip_performance?.topTrips?.[0]?.label || "No Data",
553 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
554 - // Departure Management - based on real data
555 - departures: Math.floor((baseDepartures / days) * seasonalFactor),
556 - capacity: Math.floor((baseCapacity / days) * seasonalFactor),
557 - booked: Math.floor(
558 - (baseCapacity / days) * seasonalFactor * (baseOccupancy / 100),
559 - ),
560 - // Customer Insights - derived from bookings
561 - newCustomers: Math.floor(dailyBookings * 0.3),
562 - returningCustomers: Math.floor(dailyBookings * 0.7),
563 - customerSatisfaction: Math.floor(90 * seasonalFactor),
564 - // Operational Metrics - based on real data
565 - leadTime: Math.floor(7 * (2 - seasonalFactor)), // Lower lead time for recent periods
566 - cancellationRate:
567 - dailyBookings > 0
568 - ? Math.floor((dailyCancelled / dailyBookings) * 100)
569 - : 0,
570 - efficiency: Math.floor(85 * seasonalFactor),
571 - });
572 - }
573 - } else if (viewType === "weekly") {
574 - const weeks = 12;
575 - for (let i = weeks - 1; i >= 0; i--) {
576 - const weekStart = new Date(today);
577 - weekStart.setDate(weekStart.getDate() - i * 7);
578 - const weekEnd = new Date(weekStart);
579 - 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 + });
580 571
581 - // Calculate weekly distribution from totals with realistic patterns
582 - const weekIndex = weeks - i - 1; // 0 for most recent week
583 - const seasonalFactor = 0.7 + (weekIndex / weeks) * 0.6; // Recent weeks have higher activity
584 - const weeklyBookings = Math.floor((baseBookings / 4) * seasonalFactor);
585 - const weeklyRevenue = Math.floor((baseRevenue / 4) * seasonalFactor);
586 - const weeklyConfirmed = Math.floor(weeklyBookings * 0.8);
587 - const weeklyPending = Math.floor(weeklyBookings * 0.15);
588 - const weeklyCancelled =
589 - weeklyBookings - weeklyConfirmed - weeklyPending;
590 -
591 - data.push({
592 - period: `${weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`,
593 - fullDate: weekStart.toISOString().split("T")[0],
594 - // Booking Overview - based on real data
595 - bookings: weeklyBookings,
596 - confirmed: weeklyConfirmed,
597 - pending: weeklyPending,
598 - cancelled: weeklyCancelled,
599 - // Revenue Analysis - based on real data
600 - revenue: weeklyRevenue,
601 - collected: Math.floor(weeklyRevenue * 0.85),
602 - outstanding: Math.floor(weeklyRevenue * 0.15),
603 - avgBookingValue:
604 - weeklyBookings > 0 ? Math.floor(weeklyRevenue / weeklyBookings) : 0,
605 - // Trip Performance - based on real data
606 - tripBookings: weeklyBookings,
607 - topTrip:
608 - reportData?.trip_performance?.topTrips?.[i % 3]?.label || "No Data",
609 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
610 - // Departure Management - based on real data
611 - departures: Math.floor((baseDepartures / 4) * seasonalFactor),
612 - capacity: Math.floor((baseCapacity / 4) * seasonalFactor),
613 - booked: Math.floor(
614 - (baseCapacity / 4) * seasonalFactor * (baseOccupancy / 100),
615 - ),
616 - // Customer Insights - derived from bookings
617 - newCustomers: Math.floor(weeklyBookings * 0.3),
618 - returningCustomers: Math.floor(weeklyBookings * 0.7),
619 - customerSatisfaction: Math.floor(88 * seasonalFactor),
620 - // Operational Metrics - based on real data
621 - leadTime: Math.floor(8 * (2 - seasonalFactor)), // Lower lead time for recent periods
622 - cancellationRate:
623 - weeklyBookings > 0
624 - ? Math.floor((weeklyCancelled / weeklyBookings) * 100)
625 - : 0,
626 - efficiency: Math.floor(87 * seasonalFactor),
627 - });
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);
628 580 }
629 - } else if (viewType === "monthly") {
630 - const months = 12;
631 - for (let i = months - 1; i >= 0; i--) {
632 - const monthDate = new Date(today);
633 - 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 + }
634 593
635 - // Calculate monthly distribution from totals with realistic patterns
636 - const monthIndex = months - i - 1; // 0 for most recent month
637 - const seasonalFactor = 0.6 + (monthIndex / months) * 0.8; // Recent months have higher activity
638 - const monthlyBookings = Math.floor(baseBookings * seasonalFactor);
639 - const monthlyRevenue = Math.floor(baseRevenue * seasonalFactor);
640 - const monthlyConfirmed = Math.floor(monthlyBookings * 0.8);
641 - const monthlyPending = Math.floor(monthlyBookings * 0.15);
642 - const monthlyCancelled =
643 - 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;
644 597
645 - data.push({
646 - period: monthDate.toLocaleDateString("en-US", {
647 - month: "long",
648 - year: "numeric",
649 - }),
650 - fullDate: monthDate.toISOString().split("T")[0],
651 - // Booking Overview - based on real data
652 - bookings: monthlyBookings,
653 - confirmed: monthlyConfirmed,
654 - pending: monthlyPending,
655 - cancelled: monthlyCancelled,
656 - // Revenue Analysis - based on real data
657 - revenue: monthlyRevenue,
658 - collected: Math.floor(monthlyRevenue * 0.85),
659 - outstanding: Math.floor(monthlyRevenue * 0.15),
660 - avgBookingValue:
661 - monthlyBookings > 0
662 - ? Math.floor(monthlyRevenue / monthlyBookings)
663 - : 0,
664 - // Trip Performance - based on real data
665 - tripBookings: monthlyBookings,
666 - topTrip:
667 - reportData?.trip_performance?.topTrips?.[i % 3]?.label || "No Data",
668 - occupancyRate: Math.floor(baseOccupancy * seasonalFactor),
669 - // Departure Management - based on real data
670 - departures: Math.floor(baseDepartures * seasonalFactor),
671 - capacity: Math.floor(baseCapacity * seasonalFactor),
672 - booked: Math.floor(
673 - baseCapacity * seasonalFactor * (baseOccupancy / 100),
674 - ),
675 - // Customer Insights - derived from bookings
676 - newCustomers: Math.floor(monthlyBookings * 0.3),
677 - returningCustomers: Math.floor(monthlyBookings * 0.7),
678 - customerSatisfaction: Math.floor(90 * seasonalFactor),
679 - // Operational Metrics - based on real data
680 - leadTime: Math.floor(10 * (2 - seasonalFactor)), // Lower lead time for recent periods
681 - cancellationRate:
682 - monthlyBookings > 0
683 - ? Math.floor((monthlyCancelled / monthlyBookings) * 100)
684 - : 0,
685 - efficiency: Math.floor(88 * seasonalFactor),
686 - });
687 - }
688 - }
689 -
690 - return data;
691 - };
692 -
693 - const breakdownData = generateBreakdownData();
694 -
695 598 const globalCurrency = (window as any)?.yatraAdmin?.currency || "USD";
696 599 const formatCurrencyAmount = (amount: number) =>
697 600 formatYatraMoney(Number(amount) || 0, globalCurrency, {
698 601 zeroAsUnknown: false,
@@ -721,8 +624,13 @@
721 624 </th>
722 625 </tr>
723 626 );
724 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.
725 633 return (
726 634 <tr>
727 635 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
728 636 {__("Period", "yatra")}
@@ -730,19 +638,19 @@
730 638 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
731 639 {__("Total Revenue", "yatra")}
732 640 </th>
733 641 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
734 - {__("Collected", "yatra")}
642 + {__("Bookings", "yatra")}
735 643 </th>
736 644 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
737 - {__("Outstanding", "yatra")}
738 - </th>
739 - <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
740 645 {__("Avg Booking Value", "yatra")}
741 646 </th>
742 647 </tr>
743 648 );
744 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.
745 653 return (
746 654 <tr>
747 655 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
748 656 {__("Period", "yatra")}
@@ -750,11 +658,8 @@
750 658 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
751 659 {__("Trip Bookings", "yatra")}
752 660 </th>
753 661 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
754 - {__("Top Trip", "yatra")}
755 - </th>
756 - <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
757 662 {__("Occupancy Rate", "yatra")}
758 663 </th>
759 664 <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
760 665 {__("Revenue", "yatra")}
@@ -872,14 +777,13 @@
872 777 <>
873 778 <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-green-600 dark:text-green-400">
874 779 {formatCurrencyAmount(row.revenue)}
875 780 </td>
876 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-blue-600 dark:text-blue-400">
877 - {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>
878 785 </td>
879 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-orange-600 dark:text-orange-400">
880 - {formatCurrencyAmount(row.outstanding)}
881 - </td>
882 786 <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-purple-600 dark:text-purple-400">
883 787 {formatCurrencyAmount(row.avgBookingValue)}
884 788 </td>
885 789 </>
@@ -887,17 +791,12 @@
887 791 {selectedCategory === "trip-performance" && (
888 792 <>
889 793 <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
890 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">
891 - {row.tripBookings}
795 + {row.bookings}
892 796 </span>
893 797 </td>
894 798 <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
895 - <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">
896 - {row.topTrip}
897 - </span>
898 - </td>
899 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
900 799 <div className="flex items-center">
901 800 <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
902 801 <div
903 802 className="bg-green-500 h-2 rounded-full"
@@ -936,83 +835,57 @@
936 835 <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
937 836 <div
938 837 className="bg-green-500 h-2 rounded-full"
939 838 style={{
940 - width: `${Math.round((row.booked / row.capacity) * 100)}%`,
839 + width:
840 + row.capacity > 0
841 + ? `${Math.round((row.booked / row.capacity) * 100)}%`
842 + : "0%",
941 843 }}
942 844 ></div>
943 845 </div>
944 846 <span className="text-xs font-medium">
945 - {Math.round((row.booked / row.capacity) * 100)}%
847 + {row.capacity > 0
848 + ? Math.round((row.booked / row.capacity) * 100)
849 + : 0}
850 + %
946 851 </span>
947 852 </div>
948 853 </td>
949 854 </>
950 855 )}
951 - {selectedCategory === "customer-insights" && (
952 - <>
953 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
954 - <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">
955 - {row.newCustomers}
956 - </span>
957 - </td>
958 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
959 - <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.returningCustomers}
961 - </span>
962 - </td>
963 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
964 - <div className="flex items-center">
965 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
966 - <div
967 - className="bg-green-500 h-2 rounded-full"
968 - style={{ width: `${row.customerSatisfaction}%` }}
969 - ></div>
970 - </div>
971 - <span className="text-xs font-medium">
972 - {row.customerSatisfaction}%
973 - </span>
974 - </div>
975 - </td>
976 - <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-green-600 dark:text-green-400">
977 - {formatCurrencyAmount(row.revenue)}
978 - </td>
979 - </>
980 - )}
981 - {selectedCategory === "operational-metrics" && (
982 - <>
983 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
984 - <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">
985 - {row.leadTime}
986 - </span>
987 - </td>
988 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
989 - <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">
990 - {row.cancellationRate}%
991 - </span>
992 - </td>
993 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
994 - <div className="flex items-center">
995 - <div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
996 - <div
997 - className="bg-green-500 h-2 rounded-full"
998 - style={{ width: `${row.efficiency}%` }}
999 - ></div>
1000 - </div>
1001 - <span className="text-xs font-medium">{row.efficiency}%</span>
1002 - </div>
1003 - </td>
1004 - <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
1005 - <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">
1006 - {row.bookings}
1007 - </span>
1008 - </td>
1009 - </>
1010 - )}
1011 856 </tr>
1012 857 ));
1013 858 };
1014 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 +
1015 888 return (
1016 889 <div className="overflow-x-auto">
1017 890 <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
1018 891 <thead className="bg-gray-50 dark:bg-gray-800">
@@ -1125,10 +998,19 @@
1125 998
1126 999 const eventStats = getEventStats();
1127 1000 const recentEvents = getRecentEvents();
1128 1001
1129 - // Check if Facebook Pixel is configured
1130 - 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) {
1131 1013 return (
1132 1014 <div className="text-center py-12">
1133 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">
1134 1016 <svg
@@ -1142,18 +1024,27 @@
1142 1024 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
1143 1025 {__("Facebook Pixel Not Configured", "yatra")}
1144 1026 </h3>
1145 1027 <p className="text-gray-600 dark:text-gray-400 mb-4">
1146 - {__(
1147 - "Configure your Facebook Pixel in Settings to start tracking conversion events.",
1148 - "yatra",
1149 - )}
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 + )}
1150 1037 </p>
1151 1038 <a
1152 - 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 + }`}
1153 1042 className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
1154 1043 >
1155 - {__("Configure Facebook Pixel", "yatra")}
1044 + {!fbModuleActive
1045 + ? __("Open Modules", "yatra")
1046 + : __("Configure Facebook Pixel", "yatra")}
1156 1047 </a>
1157 1048 </div>
1158 1049 );
1159 1050 }
@@ -1697,10 +1588,16 @@
1697 1588
1698 1589 const eventStats = getEventStats();
1699 1590 const recentEvents = getRecentEvents();
1700 1591
1701 - // Check if Google Analytics is configured
1702 - 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) {
1703 1600 return (
1704 1601 <div className="text-center py-12">
1705 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">
1706 1603 <svg
@@ -1717,18 +1614,27 @@
1717 1614 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
1718 1615 {__("Google Analytics 4 Not Configured", "yatra")}
1719 1616 </h3>
1720 1617 <p className="text-gray-600 dark:text-gray-400 mb-4">
1721 - {__(
1722 - "Configure your Google Analytics 4 in Settings to start tracking conversion events.",
1723 - "yatra",
1724 - )}
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 + )}
1725 1627 </p>
1726 1628 <a
1727 - 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 + }`}
1728 1632 className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
1729 1633 >
1730 - {__("Configure Google Analytics 4", "yatra")}
1634 + {!gaModuleActive
1635 + ? __("Open Modules", "yatra")
1636 + : __("Configure Google Analytics 4", "yatra")}
1731 1637 </a>
1732 1638 </div>
1733 1639 );
1734 1640 }
@@ -2208,12 +2114,32 @@
2208 2114 );
2209 2115 };
2210 2116
2211 2117 const TravelBookingReports: React.FC = () => {
2212 - 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 + );
2213 2130 const [dateRange, setDateRange] = useState("last_30_days");
2214 2131 const [viewType, setViewType] = useState("summary"); // 'summary', 'daily', 'weekly', 'monthly'
2215 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 +
2216 2142 // Fetch real data from Yatra ReportsController using apiClient
2217 2143 const { data: reportData, isLoading } = useQuery({
2218 2144 queryKey: ["yatra-travel-reports", dateRange],
2219 2145 queryFn: async () => {
@@ -2250,10 +2176,10 @@
2250 2176 start.setDate(today.getDate() - 30);
2251 2177 }
2252 2178
2253 2179 return {
2254 - start: start.toISOString().split("T")[0],
2255 - end: today.toISOString().split("T")[0],
2180 + start: formatDateForInput(start),
2181 + end: formatDateForInput(today),
2256 2182 };
2257 2183 }
2258 2184
2259 2185 // Travel Business KPIs
@@ -2283,39 +2209,115 @@
2283 2209 formatYatraMoney(Number(amount) || 0, globalCurrency, {
2284 2210 zeroAsUnknown: false,
2285 2211 });
2286 2212
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)[][] = [];
2220 +
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([]);
2234 +
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 + }
2249 +
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 + }
2259 +
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([]);
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));
2282 + };
2283 +
2287 2284 return (
2288 2285 <div className="space-y-6">
2289 - {/* Header */}
2290 - <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">
2291 2288 <div>
2292 - <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">
2293 2290 <SVGIcons.BarChart />
2294 - Travel Booking Reports
2291 + {__("Travel Booking Reports", "yatra")}
2295 2292 </h1>
2296 - <p className="text-gray-600 dark:text-gray-400">
2297 - 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 + )}
2298 2298 </p>
2299 2299 </div>
2300 2300
2301 - <div className="flex items-center gap-3">
2301 + <div className="flex flex-wrap items-center gap-2">
2302 2302 <select
2303 2303 value={dateRange}
2304 2304 onChange={(e) => setDateRange(e.target.value)}
2305 - 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")}
2306 2307 >
2307 - <option value="today">Today</option>
2308 - <option value="last_7_days">Last 7 Days</option>
2309 - <option value="last_30_days">Last 30 Days</option>
2310 - <option value="last_90_days">Last 90 Days</option>
2311 - <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>
2312 2313 </select>
2313 2314
2314 2315 <select
2315 2316 value={viewType}
2316 2317 onChange={(e) => setViewType(e.target.value)}
2317 - 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")}
2318 2320 >
2319 2321 <option value="summary">{__("Summary View", "yatra")}</option>
2320 2322 <option value="daily">{__("Daily Breakdown", "yatra")}</option>
2321 2323 <option value="weekly">{__("Weekly Breakdown", "yatra")}</option>
@@ -2320,8 +2322,19 @@
2320 2322 <option value="daily">{__("Daily Breakdown", "yatra")}</option>
2321 2323 <option value="weekly">{__("Weekly Breakdown", "yatra")}</option>
2322 2324 <option value="monthly">{__("Monthly Breakdown", "yatra")}</option>
2323 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>
2324 2337 </div>
2325 2338 </div>
2326 2339
2327 2340 {/* Key Performance Indicators */}
@@ -2462,9 +2475,9 @@
2462 2475 value={selectedCategory}
2463 2476 onChange={(e) => setSelectedCategory(e.target.value)}
2464 2477 aria-label={__("Report section", "yatra")}
2465 2478 >
2466 - {TravelReportCategories.map((category) => (
2479 + {visibleCategories.map((category) => (
2467 2480 <option key={category.id} value={category.id}>
2468 2481 {__(category.title, "yatra")}
2469 2482 </option>
2470 2483 ))}
@@ -2474,9 +2487,9 @@
2474 2487 <nav
2475 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]"
2476 2489 aria-label={__("Report sections", "yatra")}
2477 2490 >
2478 - {TravelReportCategories.map((category) => (
2491 + {visibleCategories.map((category) => (
2479 2492 <button
2480 2493 key={category.id}
2481 2494 type="button"
2482 2495 onClick={() => setSelectedCategory(category.id)}
@@ -2792,8 +2805,219 @@
2792 2805 {(
2793 2806 reportData?.booking_stats?.cancellationRate || 0
2794 2807 ).toFixed(1)}
2795 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 + )}
2796 3020 </p>
2797 3021 </div>
2798 3022 </div>
2799 3023 </div>