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

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