PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 2.0.1 All 84 releases
yatra / resources / js / pages / Dashboard.tsx

Dashboard.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/Dashboard.tsx

850 lines 31.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Dashboard Page — redesigned 3.0.5
3 *
4 * Layout principles:
5 *
6 * - One header row: page title + date-range picker. Welcome card is
7 * dismissible (state stored in localStorage so the operator doesn't
8 * see it every time after onboarding).
9 *
10 * - KPI strip: five high-signal cards in a single row with period-
11 * over-period deltas from `/reports`. Removes the duplicate
12 * "Pending Bookings" card the old design had at top + side, and
13 * adds tooltips for the confusable "Booked vs Collected Revenue"
14 * pair operators routinely conflate.
15 *
16 * - Main grid: charts on the left (Bookings Overview + Status +
17 * Destinations), operational widgets on the right (Upcoming
18 * Departures, Pending Payments).
19 *
20 * - Tertiary: Recent Bookings + Quick Actions full-width at the
21 * bottom. Quick Actions also surface as compact icon buttons in
22 * the page header for faster access from anywhere on the page.
23 *
24 * - AI Today's Brief is gated on AI module enabled (see
25 * TodaysBriefCard.tsx) — never renders a "buy AI" upsell.
26 */
27
28 import React, { useMemo, useState } from "react";
29 import { useQuery } from "@tanstack/react-query";
30 import {
31 MapPin,
32 Calendar,
33 DollarSign,
34 Users,
35 Plane,
36 TrendingUp,
37 Activity,
38 CheckCircle,
39 Plus,
40 X,
41 } from "lucide-react";
42 import { __ } from "../lib/i18n";
43 import { formatDateForInput, toDateValue } from "../lib/dateFormat";
44 import { usePermissions } from "../hooks/usePermissions";
45 import { TodaysBriefCard } from "../components/ai/TodaysBriefCard";
46 import { StatCard } from "../components/common/StatCard";
47 import {
48 Card,
49 CardContent,
50 CardHeader,
51 CardTitle,
52 } from "../components/ui/card";
53 import { Button } from "../components/ui/button";
54 import { ConditionalRender } from "../components/ui/conditional-render";
55 import { Skeleton } from "../components/ui/skeleton";
56 import { SimpleBarChart } from "../components/charts/SimpleBarChart";
57 import BookingsOverviewChart from "../components/charts/BookingsOverviewChart";
58 import BookingStatusChart from "../components/charts/BookingStatusChart";
59 import { UpcomingDepartures } from "../components/dashboard/UpcomingDepartures";
60 import { PendingPayments } from "../components/dashboard/PendingPayments";
61 import { RecentBookings } from "../components/dashboard/RecentBookings";
62 import RoleDashboard from "./RoleDashboard";
63 import { apiClient } from "../lib/api-client";
64 import {
65 formatYatraMoney,
66 readYatraCurrencyPositionFromWindow,
67 } from "../lib/currency-display";
68
69 // ---------------------------------------------------------------------------
70 // Date-range presets
71 //
72 // Same options as the Reports page so operators learn the model once.
73 // Stored in state so the rest of the dashboard can scope its queries to
74 // the picked window. The "all_time" option keeps backwards-compat with
75 // the previous Dashboard behaviour (no date scoping) — it's the
76 // default until the operator picks something else.
77 // ---------------------------------------------------------------------------
78
79 type DashboardRange =
80 | "all_time"
81 | "today"
82 | "last_7_days"
83 | "last_30_days"
84 | "last_90_days"
85 | "this_year";
86
87 // Translatable labels for the date-range presets.
88 // gettext extractors only pick up calls where the msgid is a literal,
89 // so we wrap each label inside `__()` directly here rather than store
90 // raw strings and translate them at the call site (which would emit
91 // `__(variable, ...)` — invisible to make-pot). Renders are still
92 // cheap; this function runs once per render of the <select>.
93 const rangeLabels = (): Record<DashboardRange, string> => ({
94 all_time: __("All time", "yatra"),
95 today: __("Today", "yatra"),
96 last_7_days: __("Last 7 days", "yatra"),
97 last_30_days: __("Last 30 days", "yatra"),
98 last_90_days: __("Last 90 days", "yatra"),
99 this_year: __("This year", "yatra"),
100 });
101
102 function getDateBounds(range: DashboardRange): { from?: string; to?: string } {
103 if (range === "all_time") return {};
104 const today = new Date();
105 const start = new Date(today);
106 switch (range) {
107 case "today":
108 start.setHours(0, 0, 0, 0);
109 break;
110 case "last_7_days":
111 start.setDate(today.getDate() - 7);
112 break;
113 case "last_30_days":
114 start.setDate(today.getDate() - 30);
115 break;
116 case "last_90_days":
117 start.setDate(today.getDate() - 90);
118 break;
119 case "this_year":
120 start.setMonth(0, 1);
121 break;
122 }
123 const fmt = (d: Date) =>
124 `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}`;
125 return { from: fmt(start), to: fmt(today) };
126 }
127
128 // ---------------------------------------------------------------------------
129 // Skeleton components
130 // ---------------------------------------------------------------------------
131
132 const SkeletonStatCard = () => (
133 <Card>
134 <CardContent className="pt-6">
135 <div className="flex items-center justify-between">
136 <div className="flex-1">
137 <Skeleton className="h-4 w-24 mb-2" />
138 <Skeleton className="h-8 w-32" />
139 </div>
140 <Skeleton className="w-12 h-12 rounded-full" />
141 </div>
142 </CardContent>
143 </Card>
144 );
145
146 // ---------------------------------------------------------------------------
147 // Dashboard page
148 // ---------------------------------------------------------------------------
149
150 const WELCOME_DISMISSED_KEY = "yatra:dashboard:welcome-dismissed:v1";
151
152 const Dashboard: React.FC = () => {
153 // Role-aware split: the full admin dashboard (charts, KPIs, ops widgets)
154 // is built for site owners/managers. Non-admin team members (Sales,
155 // Accountant, Guide, etc.) see a focused, cap-filtered RoleDashboard
156 // instead — they shouldn't see KPI cards or charts they can't read.
157 //
158 // Earlier this gate sat BEFORE any hooks because `isWpAdmin` is a
159 // stable server-injected flag and the component never flips between
160 // dashboards within a single mount — so hook order WAS consistent in
161 // practice. ESLint can't statically prove that, so it (correctly,
162 // defensively) flagged every subsequent hook as conditional. Moved
163 // below the hook calls; every useQuery now reads `enabled: isWpAdmin`
164 // so non-admin renders don't fire the admin-only endpoints, and the
165 // RoleDashboard branch returns immediately after all hooks are
166 // registered.
167 const isWpAdmin = !!(window.yatraAdmin as { isWpAdmin?: boolean } | undefined)
168 ?.isWpAdmin;
169
170 const { can } = usePermissions();
171
172 const [range, setRange] = useState<DashboardRange>("all_time");
173 const dateBounds = useMemo(() => getDateBounds(range), [range]);
174
175 // Welcome card dismissal — sticky per browser. Operators see it once
176 // during onboarding, then never again. localStorage instead of cookie
177 // because dashboards aren't shared and the value never needs to
178 // round-trip to the server.
179 const [welcomeDismissed, setWelcomeDismissed] = useState<boolean>(() => {
180 try {
181 return localStorage.getItem(WELCOME_DISMISSED_KEY) === "1";
182 } catch {
183 return false;
184 }
185 });
186 const dismissWelcome = () => {
187 setWelcomeDismissed(true);
188 try {
189 localStorage.setItem(WELCOME_DISMISSED_KEY, "1");
190 } catch {
191 /* private-mode browser; just no-persist */
192 }
193 };
194
195 const defaultCurrency =
196 (window as any)?.yatraAdmin?.currency ||
197 (window as any)?.yatraBookingData?.currency ||
198 "USD";
199
200 const currencyPosition = readYatraCurrencyPositionFromWindow();
201 const currencyDecimalsRaw =
202 (window as any)?.yatraAdmin?.decimalPlaces ??
203 (window as any)?.yatraAdmin?.currency_decimals ??
204 (window as any)?.yatraBookingData?.decimalPlaces ??
205 (window as any)?.yatraBookingData?.currency_decimals;
206 const currencyDecimals = Number.isFinite(Number(currencyDecimalsRaw))
207 ? Number(currencyDecimalsRaw)
208 : 2;
209
210 const formatCurrencyAmount = (amount: number) =>
211 formatYatraMoney(Number(amount) || 0, defaultCurrency, {
212 zeroAsUnknown: false,
213 });
214
215 // --- Data fetches -------------------------------------------------------
216
217 // Booking stats (totals + by-status). Independent of the date filter
218 // for now — endpoint doesn't yet honour date params. Date-aware
219 // metrics come from /reports below.
220 const { data: bookingStats, isLoading: bookingStatsLoading } = useQuery({
221 queryKey: ["dashboard-booking-stats"],
222 queryFn: async () => {
223 const response = await apiClient.get("/bookings/stats");
224 return response?.data ?? response ?? {};
225 },
226 enabled: isWpAdmin,
227 });
228
229 const { data: tripsSummary } = useQuery({
230 queryKey: ["dashboard-trips-total"],
231 queryFn: async () => {
232 const response = await apiClient.get("/trips", {
233 params: { per_page: 1 },
234 });
235 return { total: response?.total ?? 0 };
236 },
237 enabled: isWpAdmin && can("yatra_view_trips"),
238 });
239
240 const { data: customersSummary } = useQuery({
241 queryKey: ["dashboard-customers-total"],
242 queryFn: async () => {
243 const response = await apiClient.get("/customers", {
244 params: { per_page: 1 },
245 });
246 return { total: response?.total ?? 0 };
247 },
248 enabled: isWpAdmin && can("yatra_view_bookings"),
249 });
250
251 // Period-aware metrics via /reports. Only kicks in when the operator
252 // picked a real range — "all_time" doesn't need a delta.
253 const { data: reportData } = useQuery({
254 queryKey: ["dashboard-reports", dateBounds.from, dateBounds.to],
255 queryFn: async () => {
256 const response = await apiClient.get("/reports", {
257 params: {
258 date_from: dateBounds.from,
259 date_to: dateBounds.to,
260 },
261 });
262 return response?.data ?? response ?? {};
263 },
264 enabled: isWpAdmin && !!dateBounds.from && !!dateBounds.to,
265 staleTime: 60_000,
266 });
267
268 // Bookings chart — when a range is picked, prefer the /reports
269 // `booking_trend` (already day-aligned + period-correct). Otherwise
270 // fall back to the legacy 500-row aggregate.
271 const { data: bookingsChartData } = useQuery({
272 queryKey: ["dashboard-bookings-chart", range],
273 queryFn: async () => {
274 const now = new Date();
275 const pad = (n: number) => String(n).padStart(2, "0");
276
277 const response = await apiClient.get("/bookings", {
278 params: { per_page: 500 },
279 });
280
281 const items: any[] = response?.data || [];
282 const counts: Record<string, number> = {};
283 const amounts: Record<string, number> = {};
284
285 items.forEach((b) => {
286 const dateStr = b.created_at || b.travel_date;
287 // toDateValue: a date-only travel_date fallback (e.g. "2026-08-01") must
288 // bucket in its LOCAL month, not roll back to July in a behind-UTC zone.
289 const date = dateStr ? toDateValue(dateStr) : null;
290 if (!date || Number.isNaN(date.getTime())) return;
291 const ym = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
292 counts[ym] = (counts[ym] || 0) + 1;
293 const amount = Number(b.total_amount ?? 0) || 0;
294 amounts[ym] = (amounts[ym] || 0) + amount;
295 });
296
297 const months: { label: string; count: number; amount: number }[] = [];
298 for (let i = 5; i >= 0; i--) {
299 const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
300 const ym = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
301 const label = d.toLocaleDateString(undefined, {
302 month: "short",
303 year: "numeric",
304 });
305 months.push({
306 label,
307 count: counts[ym] || 0,
308 amount: amounts[ym] || 0,
309 });
310 }
311
312 return months;
313 },
314 enabled: isWpAdmin && can("yatra_view_bookings") && range === "all_time",
315 });
316
317 // Status breakdown — same /bookings/stats source.
318 const statusData = useMemo(() => {
319 const byStatus = (bookingStats as any)?.by_status || {};
320 const getCount = (key: string) => {
321 const entry = byStatus[key];
322 if (!entry) return 0;
323 const raw = (entry as any).count;
324 const n = typeof raw === "string" ? parseInt(raw, 10) : Number(raw ?? 0);
325 return Number.isNaN(n) ? 0 : n;
326 };
327 return [
328 {
329 label: __("Pending", "yatra"),
330 value: getCount("pending"),
331 color: "#f59e0b",
332 },
333 {
334 label: __("Confirmed", "yatra"),
335 value: getCount("confirmed"),
336 color: "#10b981",
337 },
338 {
339 label: __("Completed", "yatra"),
340 value: getCount("completed"),
341 color: "#3b82f6",
342 },
343 {
344 label: __("Cancelled", "yatra"),
345 value: getCount("cancelled"),
346 color: "#ef4444",
347 },
348 {
349 label: __("Refunded", "yatra"),
350 value: getCount("refunded"),
351 color: "#a855f7",
352 },
353 ];
354 }, [bookingStats]);
355
356 // Popular destinations — derived from trips list (still bounded by
357 // 50; tracked in audit as a server-aggregate follow-up).
358 const { data: destinationsData } = useQuery({
359 queryKey: ["dashboard-destinations"],
360 queryFn: async () => {
361 const response = await apiClient.get("/trips", {
362 params: { per_page: 50 },
363 });
364 const trips = response?.data || [];
365 const counts: Record<string, number> = {};
366 trips.forEach((trip: any) => {
367 const destinations = trip.destinations || [];
368 destinations.forEach((dest: any) => {
369 const name = dest?.name || dest?.destination_name || "";
370 if (!name) return;
371 counts[name] = (counts[name] || 0) + 1;
372 });
373 });
374 const palette = [
375 "#3b82f6",
376 "#10b981",
377 "#f59e0b",
378 "#ef4444",
379 "#6366f1",
380 "#14b8a6",
381 ];
382 return Object.entries(counts)
383 .sort((a, b) => b[1] - a[1])
384 .slice(0, 6)
385 .map(([name, value], index) => ({
386 label: name,
387 value,
388 color: palette[index % palette.length],
389 }));
390 },
391 enabled: isWpAdmin && can("yatra_view_trips"),
392 });
393
394 const { data: departures } = useQuery({
395 queryKey: ["dashboard-upcoming-departures"],
396 queryFn: async () => {
397 const today = new Date();
398 const todayStr = formatDateForInput(today);
399 // Fetch all FUTURE departures, not only status === "upcoming". A departure
400 // that a booking has filled flips to status "full" (booked >= capacity),
401 // and a single group-of-x booking fills a small/private departure in one
402 // go — so filtering to "upcoming" here silently dropped fully-booked
403 // upcoming tours from the dashboard while partially-booked ones stayed. We
404 // keep upcoming + full (a full trip is still an upcoming trip, just sold
405 // out) and exclude only cancelled/trash/past.
406 const response = await apiClient.get("/departures", {
407 params: {
408 date_from: todayStr,
409 include_past: false,
410 },
411 });
412 const items = (response?.data || []).filter(
413 (d: any) => d?.status === "upcoming" || d?.status === "full",
414 );
415 return items.map((d: any) => {
416 const tripTitle = d?.trip?.title || d?.trip_title || d?.title || "";
417 const destination =
418 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
419 d?.destination ||
420 undefined;
421 // `/departures` returns max_capacity / available_capacity. Neither was
422 // listed here, so every card fell back to 0 capacity — which made
423 // available go negative (0 - bookings) and pinned occupancy at 0%, even
424 // though the trip page computed it correctly from the same rows. The
425 // API's own names come first; the rest remain as fallbacks for any other
426 // shape that reaches this mapper.
427 const totalSpots =
428 d?.max_capacity ??
429 d?.total_spots ??
430 d?.capacity ??
431 d?.total_seats ??
432 d?.max_travelers ??
433 0;
434 const availableSpots =
435 d?.available_capacity ??
436 d?.available_spots ??
437 d?.available_seats ??
438 d?.remaining_slots ??
439 totalSpots - (d?.booked_count ?? d?.bookings_count ?? 0);
440 return {
441 id: d.id,
442 trip_id: d.trip_id || d?.trip?.id,
443 trip_title: tripTitle,
444 departure_date: d.start_date || d.date || d.departure_date,
445 available_spots: Number.isFinite(availableSpots) ? availableSpots : 0,
446 total_spots: Number.isFinite(totalSpots) ? totalSpots : 0,
447 status: d.status || "upcoming",
448 destination,
449 };
450 });
451 },
452 enabled: isWpAdmin && can("yatra_view_trips"),
453 });
454
455 const { data: pendingPayments } = useQuery({
456 queryKey: ["dashboard-pending-payments"],
457 queryFn: async () => {
458 const response = await apiClient.get("/payments", {
459 params: { status: "pending", per_page: 5 },
460 });
461 return response?.data || [];
462 },
463 enabled: isWpAdmin && can("yatra_view_bookings"),
464 });
465
466 const { data: recentBookings } = useQuery({
467 queryKey: ["dashboard-recent-bookings"],
468 queryFn: async () => {
469 const response = await apiClient.get("/bookings", {
470 params: { per_page: 5 },
471 });
472 const items = response?.data || [];
473 return items.map((b: any) => ({
474 id: b.id,
475 booking_id: b.reference || `BK-${b.id}`,
476 customer_name: b.customer_name || b.contact_first_name || "",
477 trip_title: b.trip_title || "",
478 booking_date: b.created_at || b.travel_date || "",
479 total_amount: b.total_amount ?? 0,
480 status: (b.status || "pending") as
481 | "confirmed"
482 | "pending"
483 | "cancelled"
484 | "completed",
485 }));
486 },
487 enabled: isWpAdmin && can("yatra_view_bookings"),
488 });
489
490 // Build trend props for KPI cards. Only present when a real range is
491 // picked (period-over-period only makes sense against a comparable
492 // window, not "all time").
493 const trendLabel = `vs prev. ${range === "today" ? "day" : range === "this_year" ? "year" : range.replace("last_", "").replace("_", " ")}`;
494 const revenueChange = Number((reportData as any)?.revenue_stats?.change ?? 0);
495 const totalChange = Number(
496 (reportData as any)?.booking_stats?.totalChange ?? 0,
497 );
498 const conversionChange = Number(
499 (reportData as any)?.booking_stats?.conversionRateChange ?? 0,
500 );
501 const hasPeriodData = range !== "all_time" && !!reportData;
502
503 // Navigate to a sub-page on the admin SPA.
504 const goTo = (subpage: string, extras: Record<string, string> = {}) => {
505 const admin = (window as any)?.yatraAdmin;
506 const baseUrl = admin?.siteUrl || "";
507 const qs = Object.entries(extras)
508 .map(([k, v]) => `&${k}=${encodeURIComponent(v)}`)
509 .join("");
510 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=${subpage}${qs}`;
511 };
512
513 // Non-admin team members get the focused, role-filtered dashboard.
514 // Placed after every hook call above so hook order stays stable
515 // across renders (see header comment).
516 if (!isWpAdmin) {
517 return <RoleDashboard />;
518 }
519
520 if (bookingStatsLoading) {
521 return (
522 <div className="space-y-4">
523 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
524 {Array.from({ length: 5 }).map((_, i) => (
525 <SkeletonStatCard key={i} />
526 ))}
527 </div>
528 <Card>
529 <CardContent className="p-6">
530 <Skeleton className="h-64 w-full" />
531 </CardContent>
532 </Card>
533 </div>
534 );
535 }
536
537 return (
538 <div className="space-y-4">
539 {/* ── HEADER ──────────────────────────────────────────────────── */}
540 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
541 <div>
542 <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
543 {__("Dashboard", "yatra")}
544 </h1>
545 <p className="text-xs text-gray-500 dark:text-gray-400">
546 {__("Operations overview at a glance.", "yatra")}
547 </p>
548 </div>
549 <div className="flex items-center gap-2">
550 {/* Date range */}
551 <select
552 value={range}
553 onChange={(e) => setRange(e.target.value as DashboardRange)}
554 className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
555 aria-label={__("Date range", "yatra")}
556 >
557 {(() => {
558 const labels = rangeLabels();
559 return (Object.keys(labels) as DashboardRange[]).map((k) => (
560 <option key={k} value={k}>
561 {labels[k]}
562 </option>
563 ));
564 })()}
565 </select>
566 {/* Primary action — most common task on a travel dashboard
567 is "I want to add a new trip". Surface it as a primary
568 button so it's always one click away. */}
569 <Button
570 type="button"
571 onClick={() => goTo("trips", { action: "add" })}
572 className="inline-flex items-center gap-1"
573 >
574 <Plus className="h-4 w-4" />
575 {__("Add Trip", "yatra")}
576 </Button>
577 </div>
578 </div>
579
580 {/* ── AI BRIEF (renders nothing when module disabled) ─────────── */}
581 <TodaysBriefCard />
582
583 {/* ── WELCOME (dismissible) ──────────────────────────────────── */}
584 {!welcomeDismissed && (
585 <Card className="border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 dark:border-blue-800 dark:from-blue-900/20 dark:to-indigo-900/20">
586 <CardContent className="p-4">
587 <div className="flex items-start justify-between gap-3">
588 <div className="flex items-start gap-3">
589 <Activity className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
590 <div>
591 <h3 className="mb-1 font-semibold text-gray-900 dark:text-white">
592 {__("Welcome to Yatra", "yatra")}
593 </h3>
594 <p className="text-sm text-gray-600 dark:text-gray-300">
595 {__(
596 "Real-time insights for your travel business. Monitor performance, track bookings, and manage operations from one place.",
597 "yatra",
598 )}
599 </p>
600 </div>
601 </div>
602 <button
603 type="button"
604 onClick={dismissWelcome}
605 className="rounded-md p-1 text-gray-500 transition-colors hover:bg-blue-100 hover:text-gray-700 dark:hover:bg-blue-900/30 dark:hover:text-gray-200"
606 aria-label={__("Dismiss welcome message", "yatra")}
607 >
608 <X className="h-4 w-4" />
609 </button>
610 </div>
611 </CardContent>
612 </Card>
613 )}
614
615 {/* ── KPI STRIP ───────────────────────────────────────────────── */}
616 <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
617 <ConditionalRender capability="yatra_view_trips">
618 <StatCard
619 title={__("Total Trips", "yatra")}
620 value={tripsSummary?.total || 0}
621 icon={MapPin}
622 color="blue"
623 loading={!tripsSummary}
624 onClick={() => goTo("trips")}
625 />
626 </ConditionalRender>
627
628 <ConditionalRender capability="yatra_view_bookings">
629 <StatCard
630 title={__("Total Bookings", "yatra")}
631 value={bookingStats?.total || 0}
632 icon={Calendar}
633 color="green"
634 loading={bookingStatsLoading}
635 trend={
636 hasPeriodData
637 ? {
638 value: totalChange,
639 isPositive: totalChange >= 0,
640 label: trendLabel,
641 }
642 : undefined
643 }
644 onClick={() => goTo("bookings")}
645 />
646 </ConditionalRender>
647
648 <ConditionalRender capability="yatra_view_financial_reports">
649 <StatCard
650 title={__("Booked Revenue", "yatra")}
651 value={formatCurrencyAmount(bookingStats?.total_revenue || 0)}
652 icon={DollarSign}
653 color="purple"
654 loading={bookingStatsLoading}
655 tooltip={__(
656 "Total value of confirmed bookings — the gross revenue you're entitled to. Not yet net of cancellations or refunds.",
657 "yatra",
658 )}
659 trend={
660 hasPeriodData
661 ? {
662 value: revenueChange,
663 isPositive: revenueChange >= 0,
664 label: trendLabel,
665 }
666 : undefined
667 }
668 onClick={() => goTo("payments")}
669 />
670 </ConditionalRender>
671
672 <ConditionalRender capability="yatra_view_financial_reports">
673 <StatCard
674 title={__("Collected Revenue", "yatra")}
675 value={formatCurrencyAmount(bookingStats?.total_collected || 0)}
676 icon={DollarSign}
677 color="green"
678 loading={bookingStatsLoading}
679 tooltip={__(
680 "Cash already received — what's actually in your accounts. The gap between Booked and Collected is your accounts-receivable (pending payments + scheduled instalments).",
681 "yatra",
682 )}
683 onClick={() => goTo("payments")}
684 />
685 </ConditionalRender>
686
687 <ConditionalRender capability="yatra_view_customers">
688 <StatCard
689 title={__("Total Customers", "yatra")}
690 value={customersSummary?.total || 0}
691 icon={Users}
692 color="orange"
693 loading={!customersSummary}
694 onClick={() => goTo("customers")}
695 />
696 </ConditionalRender>
697 </div>
698
699 {/* ── SECONDARY KPI ROW (period-aware) ────────────────────────── */}
700 {/* These are operational + revenue indicators. Wrap each card in
701 * the appropriate cap — revenue-flavoured for finance roles
702 * (Accountant), conversion / occupancy / cancellation for
703 * operational roles (Sales Agent / Manager / Marketing). */}
704 {hasPeriodData && (
705 <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
706 <ConditionalRender capability="yatra_view_operational_reports">
707 <StatCard
708 title={__("Conversion Rate", "yatra")}
709 value={`${Number((reportData as any)?.booking_stats?.conversionRate || 0).toFixed(1)}%`}
710 icon={TrendingUp}
711 color="green"
712 tooltip={__(
713 "Share of bookings in this period that landed in a confirmed or completed state. Higher is better.",
714 "yatra",
715 )}
716 trend={{
717 value: conversionChange,
718 isPositive: conversionChange >= 0,
719 label: trendLabel,
720 }}
721 />
722 </ConditionalRender>
723 <ConditionalRender capability="yatra_view_financial_reports">
724 <StatCard
725 title={__("Avg Booking Value", "yatra")}
726 value={formatCurrencyAmount(
727 Number((reportData as any)?.revenue_stats?.average || 0),
728 )}
729 icon={DollarSign}
730 color="purple"
731 tooltip={__(
732 "Mean revenue per booking in this period. A rising AOV with stable booking count is the cleanest growth signal.",
733 "yatra",
734 )}
735 />
736 </ConditionalRender>
737 <ConditionalRender capability="yatra_view_operational_reports">
738 <StatCard
739 title={__("Occupancy Rate", "yatra")}
740 value={`${Number((reportData as any)?.operational_stats?.occupancyRate || 0).toFixed(1)}%`}
741 icon={Plane}
742 color="blue"
743 tooltip={__(
744 "Booked seats / total seats across upcoming departures. Capacity utilisation indicator.",
745 "yatra",
746 )}
747 />
748 </ConditionalRender>
749 <ConditionalRender capability="yatra_view_operational_reports">
750 <StatCard
751 title={__("Cancellation Rate", "yatra")}
752 value={`${Number((reportData as any)?.booking_stats?.cancellationRate || 0).toFixed(1)}%`}
753 icon={CheckCircle}
754 color="red"
755 tooltip={__(
756 "Share of bookings that ended cancelled. Watch the trend — a spike usually points at a specific trip or timing issue.",
757 "yatra",
758 )}
759 />
760 </ConditionalRender>
761 </div>
762 )}
763
764 {/* ── MAIN GRID: charts (left) + widgets (right) ──────────────── */}
765 <div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
766 <div className="space-y-3 lg:col-span-7">
767 <ConditionalRender capability="yatra_view_bookings">
768 <Card>
769 <CardHeader>
770 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
771 </CardHeader>
772 <CardContent className="pb-2">
773 <BookingsOverviewChart
774 data={bookingsChartData || []}
775 currency={defaultCurrency}
776 currencyPosition={currencyPosition}
777 currencyDecimals={currencyDecimals}
778 />
779 </CardContent>
780 </Card>
781 </ConditionalRender>
782
783 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
784 <ConditionalRender capability="yatra_view_bookings">
785 <Card>
786 <CardHeader>
787 <CardTitle>{__("Booking Status", "yatra")}</CardTitle>
788 </CardHeader>
789 <CardContent className="pb-2">
790 <BookingStatusChart data={statusData || []} />
791 </CardContent>
792 </Card>
793 </ConditionalRender>
794
795 <ConditionalRender capability="yatra_view_operational_reports">
796 <Card>
797 <CardHeader>
798 <CardTitle>{__("Popular Destinations", "yatra")}</CardTitle>
799 </CardHeader>
800 <CardContent className="pb-2">
801 {destinationsData && destinationsData.length > 0 ? (
802 <SimpleBarChart
803 data={destinationsData || []}
804 title=""
805 height={180}
806 showValues={true}
807 />
808 ) : (
809 <div className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
810 {__("No destination data yet.", "yatra")}
811 </div>
812 )}
813 </CardContent>
814 </Card>
815 </ConditionalRender>
816 </div>
817
818 <ConditionalRender capability="yatra_view_bookings">
819 <RecentBookings
820 bookings={recentBookings || []}
821 loading={bookingStatsLoading}
822 onView={(booking) => {
823 goTo("bookings", { action: "view", id: String(booking.id) });
824 }}
825 />
826 </ConditionalRender>
827 </div>
828
829 <div className="space-y-3 lg:col-span-5">
830 <ConditionalRender capability="yatra_view_departures">
831 <UpcomingDepartures
832 departures={departures || []}
833 loading={bookingStatsLoading}
834 />
835 </ConditionalRender>
836
837 <ConditionalRender capability="yatra_view_financial_reports">
838 <PendingPayments
839 payments={pendingPayments || []}
840 loading={bookingStatsLoading}
841 />
842 </ConditionalRender>
843 </div>
844 </div>
845 </div>
846 );
847 };
848
849 export default Dashboard;
850