| @@ -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> |