PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at resources/js/pages/Dashboard.tsx

842 lines 31.0 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 const response = await apiClient.get("/departures", {
400 params: {
401 status: "upcoming",
402 date_from: todayStr,
403 include_past: false,
404 },
405 });
406 const items = response?.data || [];
407 return items.map((d: any) => {
408 const tripTitle = d?.trip?.title || d?.trip_title || d?.title || "";
409 const destination =
410 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
411 d?.destination ||
412 undefined;
413 // `/departures` returns max_capacity / available_capacity. Neither was
414 // listed here, so every card fell back to 0 capacity — which made
415 // available go negative (0 - bookings) and pinned occupancy at 0%, even
416 // though the trip page computed it correctly from the same rows. The
417 // API's own names come first; the rest remain as fallbacks for any other
418 // shape that reaches this mapper.
419 const totalSpots =
420 d?.max_capacity ??
421 d?.total_spots ??
422 d?.capacity ??
423 d?.total_seats ??
424 d?.max_travelers ??
425 0;
426 const availableSpots =
427 d?.available_capacity ??
428 d?.available_spots ??
429 d?.available_seats ??
430 d?.remaining_slots ??
431 totalSpots - (d?.booked_count ?? d?.bookings_count ?? 0);
432 return {
433 id: d.id,
434 trip_id: d.trip_id || d?.trip?.id,
435 trip_title: tripTitle,
436 departure_date: d.start_date || d.date || d.departure_date,
437 available_spots: Number.isFinite(availableSpots) ? availableSpots : 0,
438 total_spots: Number.isFinite(totalSpots) ? totalSpots : 0,
439 status: d.status || "upcoming",
440 destination,
441 };
442 });
443 },
444 enabled: isWpAdmin && can("yatra_view_trips"),
445 });
446
447 const { data: pendingPayments } = useQuery({
448 queryKey: ["dashboard-pending-payments"],
449 queryFn: async () => {
450 const response = await apiClient.get("/payments", {
451 params: { status: "pending", per_page: 5 },
452 });
453 return response?.data || [];
454 },
455 enabled: isWpAdmin && can("yatra_view_bookings"),
456 });
457
458 const { data: recentBookings } = useQuery({
459 queryKey: ["dashboard-recent-bookings"],
460 queryFn: async () => {
461 const response = await apiClient.get("/bookings", {
462 params: { per_page: 5 },
463 });
464 const items = response?.data || [];
465 return items.map((b: any) => ({
466 id: b.id,
467 booking_id: b.reference || `BK-${b.id}`,
468 customer_name: b.customer_name || b.contact_first_name || "",
469 trip_title: b.trip_title || "",
470 booking_date: b.created_at || b.travel_date || "",
471 total_amount: b.total_amount ?? 0,
472 status: (b.status || "pending") as
473 | "confirmed"
474 | "pending"
475 | "cancelled"
476 | "completed",
477 }));
478 },
479 enabled: isWpAdmin && can("yatra_view_bookings"),
480 });
481
482 // Build trend props for KPI cards. Only present when a real range is
483 // picked (period-over-period only makes sense against a comparable
484 // window, not "all time").
485 const trendLabel = `vs prev. ${range === "today" ? "day" : range === "this_year" ? "year" : range.replace("last_", "").replace("_", " ")}`;
486 const revenueChange = Number((reportData as any)?.revenue_stats?.change ?? 0);
487 const totalChange = Number(
488 (reportData as any)?.booking_stats?.totalChange ?? 0,
489 );
490 const conversionChange = Number(
491 (reportData as any)?.booking_stats?.conversionRateChange ?? 0,
492 );
493 const hasPeriodData = range !== "all_time" && !!reportData;
494
495 // Navigate to a sub-page on the admin SPA.
496 const goTo = (subpage: string, extras: Record<string, string> = {}) => {
497 const admin = (window as any)?.yatraAdmin;
498 const baseUrl = admin?.siteUrl || "";
499 const qs = Object.entries(extras)
500 .map(([k, v]) => `&${k}=${encodeURIComponent(v)}`)
501 .join("");
502 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=${subpage}${qs}`;
503 };
504
505 // Non-admin team members get the focused, role-filtered dashboard.
506 // Placed after every hook call above so hook order stays stable
507 // across renders (see header comment).
508 if (!isWpAdmin) {
509 return <RoleDashboard />;
510 }
511
512 if (bookingStatsLoading) {
513 return (
514 <div className="space-y-4">
515 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
516 {Array.from({ length: 5 }).map((_, i) => (
517 <SkeletonStatCard key={i} />
518 ))}
519 </div>
520 <Card>
521 <CardContent className="p-6">
522 <Skeleton className="h-64 w-full" />
523 </CardContent>
524 </Card>
525 </div>
526 );
527 }
528
529 return (
530 <div className="space-y-4">
531 {/* ── HEADER ──────────────────────────────────────────────────── */}
532 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
533 <div>
534 <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
535 {__("Dashboard", "yatra")}
536 </h1>
537 <p className="text-xs text-gray-500 dark:text-gray-400">
538 {__("Operations overview at a glance.", "yatra")}
539 </p>
540 </div>
541 <div className="flex items-center gap-2">
542 {/* Date range */}
543 <select
544 value={range}
545 onChange={(e) => setRange(e.target.value as DashboardRange)}
546 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"
547 aria-label={__("Date range", "yatra")}
548 >
549 {(() => {
550 const labels = rangeLabels();
551 return (Object.keys(labels) as DashboardRange[]).map((k) => (
552 <option key={k} value={k}>
553 {labels[k]}
554 </option>
555 ));
556 })()}
557 </select>
558 {/* Primary action — most common task on a travel dashboard
559 is "I want to add a new trip". Surface it as a primary
560 button so it's always one click away. */}
561 <Button
562 type="button"
563 onClick={() => goTo("trips", { action: "add" })}
564 className="inline-flex items-center gap-1"
565 >
566 <Plus className="h-4 w-4" />
567 {__("Add Trip", "yatra")}
568 </Button>
569 </div>
570 </div>
571
572 {/* ── AI BRIEF (renders nothing when module disabled) ─────────── */}
573 <TodaysBriefCard />
574
575 {/* ── WELCOME (dismissible) ──────────────────────────────────── */}
576 {!welcomeDismissed && (
577 <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">
578 <CardContent className="p-4">
579 <div className="flex items-start justify-between gap-3">
580 <div className="flex items-start gap-3">
581 <Activity className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
582 <div>
583 <h3 className="mb-1 font-semibold text-gray-900 dark:text-white">
584 {__("Welcome to Yatra", "yatra")}
585 </h3>
586 <p className="text-sm text-gray-600 dark:text-gray-300">
587 {__(
588 "Real-time insights for your travel business. Monitor performance, track bookings, and manage operations from one place.",
589 "yatra",
590 )}
591 </p>
592 </div>
593 </div>
594 <button
595 type="button"
596 onClick={dismissWelcome}
597 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"
598 aria-label={__("Dismiss welcome message", "yatra")}
599 >
600 <X className="h-4 w-4" />
601 </button>
602 </div>
603 </CardContent>
604 </Card>
605 )}
606
607 {/* ── KPI STRIP ───────────────────────────────────────────────── */}
608 <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
609 <ConditionalRender capability="yatra_view_trips">
610 <StatCard
611 title={__("Total Trips", "yatra")}
612 value={tripsSummary?.total || 0}
613 icon={MapPin}
614 color="blue"
615 loading={!tripsSummary}
616 onClick={() => goTo("trips")}
617 />
618 </ConditionalRender>
619
620 <ConditionalRender capability="yatra_view_bookings">
621 <StatCard
622 title={__("Total Bookings", "yatra")}
623 value={bookingStats?.total || 0}
624 icon={Calendar}
625 color="green"
626 loading={bookingStatsLoading}
627 trend={
628 hasPeriodData
629 ? {
630 value: totalChange,
631 isPositive: totalChange >= 0,
632 label: trendLabel,
633 }
634 : undefined
635 }
636 onClick={() => goTo("bookings")}
637 />
638 </ConditionalRender>
639
640 <ConditionalRender capability="yatra_view_financial_reports">
641 <StatCard
642 title={__("Booked Revenue", "yatra")}
643 value={formatCurrencyAmount(bookingStats?.total_revenue || 0)}
644 icon={DollarSign}
645 color="purple"
646 loading={bookingStatsLoading}
647 tooltip={__(
648 "Total value of confirmed bookings — the gross revenue you're entitled to. Not yet net of cancellations or refunds.",
649 "yatra",
650 )}
651 trend={
652 hasPeriodData
653 ? {
654 value: revenueChange,
655 isPositive: revenueChange >= 0,
656 label: trendLabel,
657 }
658 : undefined
659 }
660 onClick={() => goTo("payments")}
661 />
662 </ConditionalRender>
663
664 <ConditionalRender capability="yatra_view_financial_reports">
665 <StatCard
666 title={__("Collected Revenue", "yatra")}
667 value={formatCurrencyAmount(bookingStats?.total_collected || 0)}
668 icon={DollarSign}
669 color="green"
670 loading={bookingStatsLoading}
671 tooltip={__(
672 "Cash already received — what's actually in your accounts. The gap between Booked and Collected is your accounts-receivable (pending payments + scheduled instalments).",
673 "yatra",
674 )}
675 onClick={() => goTo("payments")}
676 />
677 </ConditionalRender>
678
679 <ConditionalRender capability="yatra_view_customers">
680 <StatCard
681 title={__("Total Customers", "yatra")}
682 value={customersSummary?.total || 0}
683 icon={Users}
684 color="orange"
685 loading={!customersSummary}
686 onClick={() => goTo("customers")}
687 />
688 </ConditionalRender>
689 </div>
690
691 {/* ── SECONDARY KPI ROW (period-aware) ────────────────────────── */}
692 {/* These are operational + revenue indicators. Wrap each card in
693 * the appropriate cap — revenue-flavoured for finance roles
694 * (Accountant), conversion / occupancy / cancellation for
695 * operational roles (Sales Agent / Manager / Marketing). */}
696 {hasPeriodData && (
697 <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
698 <ConditionalRender capability="yatra_view_operational_reports">
699 <StatCard
700 title={__("Conversion Rate", "yatra")}
701 value={`${Number((reportData as any)?.booking_stats?.conversionRate || 0).toFixed(1)}%`}
702 icon={TrendingUp}
703 color="green"
704 tooltip={__(
705 "Share of bookings in this period that landed in a confirmed or completed state. Higher is better.",
706 "yatra",
707 )}
708 trend={{
709 value: conversionChange,
710 isPositive: conversionChange >= 0,
711 label: trendLabel,
712 }}
713 />
714 </ConditionalRender>
715 <ConditionalRender capability="yatra_view_financial_reports">
716 <StatCard
717 title={__("Avg Booking Value", "yatra")}
718 value={formatCurrencyAmount(
719 Number((reportData as any)?.revenue_stats?.average || 0),
720 )}
721 icon={DollarSign}
722 color="purple"
723 tooltip={__(
724 "Mean revenue per booking in this period. A rising AOV with stable booking count is the cleanest growth signal.",
725 "yatra",
726 )}
727 />
728 </ConditionalRender>
729 <ConditionalRender capability="yatra_view_operational_reports">
730 <StatCard
731 title={__("Occupancy Rate", "yatra")}
732 value={`${Number((reportData as any)?.operational_stats?.occupancyRate || 0).toFixed(1)}%`}
733 icon={Plane}
734 color="blue"
735 tooltip={__(
736 "Booked seats / total seats across upcoming departures. Capacity utilisation indicator.",
737 "yatra",
738 )}
739 />
740 </ConditionalRender>
741 <ConditionalRender capability="yatra_view_operational_reports">
742 <StatCard
743 title={__("Cancellation Rate", "yatra")}
744 value={`${Number((reportData as any)?.booking_stats?.cancellationRate || 0).toFixed(1)}%`}
745 icon={CheckCircle}
746 color="red"
747 tooltip={__(
748 "Share of bookings that ended cancelled. Watch the trend — a spike usually points at a specific trip or timing issue.",
749 "yatra",
750 )}
751 />
752 </ConditionalRender>
753 </div>
754 )}
755
756 {/* ── MAIN GRID: charts (left) + widgets (right) ──────────────── */}
757 <div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
758 <div className="space-y-3 lg:col-span-7">
759 <ConditionalRender capability="yatra_view_bookings">
760 <Card>
761 <CardHeader>
762 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
763 </CardHeader>
764 <CardContent className="pb-2">
765 <BookingsOverviewChart
766 data={bookingsChartData || []}
767 currency={defaultCurrency}
768 currencyPosition={currencyPosition}
769 currencyDecimals={currencyDecimals}
770 />
771 </CardContent>
772 </Card>
773 </ConditionalRender>
774
775 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
776 <ConditionalRender capability="yatra_view_bookings">
777 <Card>
778 <CardHeader>
779 <CardTitle>{__("Booking Status", "yatra")}</CardTitle>
780 </CardHeader>
781 <CardContent className="pb-2">
782 <BookingStatusChart data={statusData || []} />
783 </CardContent>
784 </Card>
785 </ConditionalRender>
786
787 <ConditionalRender capability="yatra_view_operational_reports">
788 <Card>
789 <CardHeader>
790 <CardTitle>{__("Popular Destinations", "yatra")}</CardTitle>
791 </CardHeader>
792 <CardContent className="pb-2">
793 {destinationsData && destinationsData.length > 0 ? (
794 <SimpleBarChart
795 data={destinationsData || []}
796 title=""
797 height={180}
798 showValues={true}
799 />
800 ) : (
801 <div className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
802 {__("No destination data yet.", "yatra")}
803 </div>
804 )}
805 </CardContent>
806 </Card>
807 </ConditionalRender>
808 </div>
809
810 <ConditionalRender capability="yatra_view_bookings">
811 <RecentBookings
812 bookings={recentBookings || []}
813 loading={bookingStatsLoading}
814 onView={(booking) => {
815 goTo("bookings", { action: "view", id: String(booking.id) });
816 }}
817 />
818 </ConditionalRender>
819 </div>
820
821 <div className="space-y-3 lg:col-span-5">
822 <ConditionalRender capability="yatra_view_departures">
823 <UpcomingDepartures
824 departures={departures || []}
825 loading={bookingStatsLoading}
826 />
827 </ConditionalRender>
828
829 <ConditionalRender capability="yatra_view_financial_reports">
830 <PendingPayments
831 payments={pendingPayments || []}
832 loading={bookingStatsLoading}
833 />
834 </ConditionalRender>
835 </div>
836 </div>
837 </div>
838 );
839 };
840
841 export default Dashboard;
842