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

832 lines 30.3 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 { usePermissions } from "../hooks/usePermissions";
44 import { TodaysBriefCard } from "../components/ai/TodaysBriefCard";
45 import { StatCard } from "../components/common/StatCard";
46 import {
47 Card,
48 CardContent,
49 CardHeader,
50 CardTitle,
51 } from "../components/ui/card";
52 import { Button } from "../components/ui/button";
53 import { ConditionalRender } from "../components/ui/conditional-render";
54 import { Skeleton } from "../components/ui/skeleton";
55 import { SimpleBarChart } from "../components/charts/SimpleBarChart";
56 import BookingsOverviewChart from "../components/charts/BookingsOverviewChart";
57 import BookingStatusChart from "../components/charts/BookingStatusChart";
58 import { UpcomingDepartures } from "../components/dashboard/UpcomingDepartures";
59 import { PendingPayments } from "../components/dashboard/PendingPayments";
60 import { RecentBookings } from "../components/dashboard/RecentBookings";
61 import RoleDashboard from "./RoleDashboard";
62 import { apiClient } from "../lib/api-client";
63 import {
64 formatYatraMoney,
65 readYatraCurrencyPositionFromWindow,
66 } from "../lib/currency-display";
67
68 // ---------------------------------------------------------------------------
69 // Date-range presets
70 //
71 // Same options as the Reports page so operators learn the model once.
72 // Stored in state so the rest of the dashboard can scope its queries to
73 // the picked window. The "all_time" option keeps backwards-compat with
74 // the previous Dashboard behaviour (no date scoping) — it's the
75 // default until the operator picks something else.
76 // ---------------------------------------------------------------------------
77
78 type DashboardRange =
79 | "all_time"
80 | "today"
81 | "last_7_days"
82 | "last_30_days"
83 | "last_90_days"
84 | "this_year";
85
86 // Translatable labels for the date-range presets.
87 // gettext extractors only pick up calls where the msgid is a literal,
88 // so we wrap each label inside `__()` directly here rather than store
89 // raw strings and translate them at the call site (which would emit
90 // `__(variable, ...)` — invisible to make-pot). Renders are still
91 // cheap; this function runs once per render of the <select>.
92 const rangeLabels = (): Record<DashboardRange, string> => ({
93 all_time: __("All time", "yatra"),
94 today: __("Today", "yatra"),
95 last_7_days: __("Last 7 days", "yatra"),
96 last_30_days: __("Last 30 days", "yatra"),
97 last_90_days: __("Last 90 days", "yatra"),
98 this_year: __("This year", "yatra"),
99 });
100
101 function getDateBounds(range: DashboardRange): { from?: string; to?: string } {
102 if (range === "all_time") return {};
103 const today = new Date();
104 const start = new Date(today);
105 switch (range) {
106 case "today":
107 start.setHours(0, 0, 0, 0);
108 break;
109 case "last_7_days":
110 start.setDate(today.getDate() - 7);
111 break;
112 case "last_30_days":
113 start.setDate(today.getDate() - 30);
114 break;
115 case "last_90_days":
116 start.setDate(today.getDate() - 90);
117 break;
118 case "this_year":
119 start.setMonth(0, 1);
120 break;
121 }
122 const fmt = (d: Date) =>
123 `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}`;
124 return { from: fmt(start), to: fmt(today) };
125 }
126
127 // ---------------------------------------------------------------------------
128 // Skeleton components
129 // ---------------------------------------------------------------------------
130
131 const SkeletonStatCard = () => (
132 <Card>
133 <CardContent className="pt-6">
134 <div className="flex items-center justify-between">
135 <div className="flex-1">
136 <Skeleton className="h-4 w-24 mb-2" />
137 <Skeleton className="h-8 w-32" />
138 </div>
139 <Skeleton className="w-12 h-12 rounded-full" />
140 </div>
141 </CardContent>
142 </Card>
143 );
144
145 // ---------------------------------------------------------------------------
146 // Dashboard page
147 // ---------------------------------------------------------------------------
148
149 const WELCOME_DISMISSED_KEY = "yatra:dashboard:welcome-dismissed:v1";
150
151 const Dashboard: React.FC = () => {
152 // Role-aware split: the full admin dashboard (charts, KPIs, ops widgets)
153 // is built for site owners/managers. Non-admin team members (Sales,
154 // Accountant, Guide, etc.) see a focused, cap-filtered RoleDashboard
155 // instead — they shouldn't see KPI cards or charts they can't read.
156 //
157 // Earlier this gate sat BEFORE any hooks because `isWpAdmin` is a
158 // stable server-injected flag and the component never flips between
159 // dashboards within a single mount — so hook order WAS consistent in
160 // practice. ESLint can't statically prove that, so it (correctly,
161 // defensively) flagged every subsequent hook as conditional. Moved
162 // below the hook calls; every useQuery now reads `enabled: isWpAdmin`
163 // so non-admin renders don't fire the admin-only endpoints, and the
164 // RoleDashboard branch returns immediately after all hooks are
165 // registered.
166 const isWpAdmin = !!(
167 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 const date = dateStr ? new Date(dateStr) : null;
288 if (!date || Number.isNaN(date.getTime())) return;
289 const ym = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
290 counts[ym] = (counts[ym] || 0) + 1;
291 const amount = Number(b.total_amount ?? 0) || 0;
292 amounts[ym] = (amounts[ym] || 0) + amount;
293 });
294
295 const months: { label: string; count: number; amount: number }[] = [];
296 for (let i = 5; i >= 0; i--) {
297 const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
298 const ym = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
299 const label = d.toLocaleDateString(undefined, {
300 month: "short",
301 year: "numeric",
302 });
303 months.push({
304 label,
305 count: counts[ym] || 0,
306 amount: amounts[ym] || 0,
307 });
308 }
309
310 return months;
311 },
312 enabled: isWpAdmin && can("yatra_view_bookings") && range === "all_time",
313 });
314
315 // Status breakdown — same /bookings/stats source.
316 const statusData = useMemo(() => {
317 const byStatus = (bookingStats as any)?.by_status || {};
318 const getCount = (key: string) => {
319 const entry = byStatus[key];
320 if (!entry) return 0;
321 const raw = (entry as any).count;
322 const n = typeof raw === "string" ? parseInt(raw, 10) : Number(raw ?? 0);
323 return Number.isNaN(n) ? 0 : n;
324 };
325 return [
326 {
327 label: __("Pending", "yatra"),
328 value: getCount("pending"),
329 color: "#f59e0b",
330 },
331 {
332 label: __("Confirmed", "yatra"),
333 value: getCount("confirmed"),
334 color: "#10b981",
335 },
336 {
337 label: __("Completed", "yatra"),
338 value: getCount("completed"),
339 color: "#3b82f6",
340 },
341 {
342 label: __("Cancelled", "yatra"),
343 value: getCount("cancelled"),
344 color: "#ef4444",
345 },
346 {
347 label: __("Refunded", "yatra"),
348 value: getCount("refunded"),
349 color: "#a855f7",
350 },
351 ];
352 }, [bookingStats]);
353
354 // Popular destinations — derived from trips list (still bounded by
355 // 50; tracked in audit as a server-aggregate follow-up).
356 const { data: destinationsData } = useQuery({
357 queryKey: ["dashboard-destinations"],
358 queryFn: async () => {
359 const response = await apiClient.get("/trips", {
360 params: { per_page: 50 },
361 });
362 const trips = response?.data || [];
363 const counts: Record<string, number> = {};
364 trips.forEach((trip: any) => {
365 const destinations = trip.destinations || [];
366 destinations.forEach((dest: any) => {
367 const name = dest?.name || dest?.destination_name || "";
368 if (!name) return;
369 counts[name] = (counts[name] || 0) + 1;
370 });
371 });
372 const palette = [
373 "#3b82f6",
374 "#10b981",
375 "#f59e0b",
376 "#ef4444",
377 "#6366f1",
378 "#14b8a6",
379 ];
380 return Object.entries(counts)
381 .sort((a, b) => b[1] - a[1])
382 .slice(0, 6)
383 .map(([name, value], index) => ({
384 label: name,
385 value,
386 color: palette[index % palette.length],
387 }));
388 },
389 enabled: isWpAdmin && can("yatra_view_trips"),
390 });
391
392 const { data: departures } = useQuery({
393 queryKey: ["dashboard-upcoming-departures"],
394 queryFn: async () => {
395 const today = new Date();
396 const todayStr = today.toISOString().split("T")[0];
397 const response = await apiClient.get("/departures", {
398 params: {
399 status: "upcoming",
400 date_from: todayStr,
401 include_past: false,
402 },
403 });
404 const items = response?.data || [];
405 return items.map((d: any) => {
406 const tripTitle = d?.trip?.title || d?.trip_title || d?.title || "";
407 const destination =
408 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
409 d?.destination ||
410 undefined;
411 const totalSpots =
412 d?.total_spots ??
413 d?.capacity ??
414 d?.total_seats ??
415 d?.max_travelers ??
416 0;
417 const availableSpots =
418 d?.available_spots ??
419 d?.available_seats ??
420 d?.remaining_slots ??
421 totalSpots - (d?.bookings_count || 0);
422 return {
423 id: d.id,
424 trip_id: d.trip_id || d?.trip?.id,
425 trip_title: tripTitle,
426 departure_date: d.start_date || d.date || d.departure_date,
427 available_spots: Number.isFinite(availableSpots) ? availableSpots : 0,
428 total_spots: Number.isFinite(totalSpots) ? totalSpots : 0,
429 status: d.status || "upcoming",
430 destination,
431 };
432 });
433 },
434 enabled: isWpAdmin && can("yatra_view_trips"),
435 });
436
437 const { data: pendingPayments } = useQuery({
438 queryKey: ["dashboard-pending-payments"],
439 queryFn: async () => {
440 const response = await apiClient.get("/payments", {
441 params: { status: "pending", per_page: 5 },
442 });
443 return response?.data || [];
444 },
445 enabled: isWpAdmin && can("yatra_view_bookings"),
446 });
447
448 const { data: recentBookings } = useQuery({
449 queryKey: ["dashboard-recent-bookings"],
450 queryFn: async () => {
451 const response = await apiClient.get("/bookings", {
452 params: { per_page: 5 },
453 });
454 const items = response?.data || [];
455 return items.map((b: any) => ({
456 id: b.id,
457 booking_id: b.reference || `BK-${b.id}`,
458 customer_name: b.customer_name || b.contact_first_name || "",
459 trip_title: b.trip_title || "",
460 booking_date: b.created_at || b.travel_date || "",
461 total_amount: b.total_amount ?? 0,
462 status: (b.status || "pending") as
463 | "confirmed"
464 | "pending"
465 | "cancelled"
466 | "completed",
467 }));
468 },
469 enabled: isWpAdmin && can("yatra_view_bookings"),
470 });
471
472 // Build trend props for KPI cards. Only present when a real range is
473 // picked (period-over-period only makes sense against a comparable
474 // window, not "all time").
475 const trendLabel = `vs prev. ${range === "today" ? "day" : range === "this_year" ? "year" : range.replace("last_", "").replace("_", " ")}`;
476 const revenueChange = Number((reportData as any)?.revenue_stats?.change ?? 0);
477 const totalChange = Number(
478 (reportData as any)?.booking_stats?.totalChange ?? 0,
479 );
480 const conversionChange = Number(
481 (reportData as any)?.booking_stats?.conversionRateChange ?? 0,
482 );
483 const hasPeriodData = range !== "all_time" && !!reportData;
484
485 // Navigate to a sub-page on the admin SPA.
486 const goTo = (subpage: string, extras: Record<string, string> = {}) => {
487 const admin = (window as any)?.yatraAdmin;
488 const baseUrl = admin?.siteUrl || "";
489 const qs = Object.entries(extras)
490 .map(([k, v]) => `&${k}=${encodeURIComponent(v)}`)
491 .join("");
492 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=${subpage}${qs}`;
493 };
494
495 // Non-admin team members get the focused, role-filtered dashboard.
496 // Placed after every hook call above so hook order stays stable
497 // across renders (see header comment).
498 if (!isWpAdmin) {
499 return <RoleDashboard />;
500 }
501
502 if (bookingStatsLoading) {
503 return (
504 <div className="space-y-4">
505 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
506 {Array.from({ length: 5 }).map((_, i) => (
507 <SkeletonStatCard key={i} />
508 ))}
509 </div>
510 <Card>
511 <CardContent className="p-6">
512 <Skeleton className="h-64 w-full" />
513 </CardContent>
514 </Card>
515 </div>
516 );
517 }
518
519 return (
520 <div className="space-y-4">
521 {/* ── HEADER ──────────────────────────────────────────────────── */}
522 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
523 <div>
524 <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
525 {__("Dashboard", "yatra")}
526 </h1>
527 <p className="text-xs text-gray-500 dark:text-gray-400">
528 {__("Operations overview at a glance.", "yatra")}
529 </p>
530 </div>
531 <div className="flex items-center gap-2">
532 {/* Date range */}
533 <select
534 value={range}
535 onChange={(e) => setRange(e.target.value as DashboardRange)}
536 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"
537 aria-label={__("Date range", "yatra")}
538 >
539 {(() => {
540 const labels = rangeLabels();
541 return (Object.keys(labels) as DashboardRange[]).map((k) => (
542 <option key={k} value={k}>
543 {labels[k]}
544 </option>
545 ));
546 })()}
547 </select>
548 {/* Primary action — most common task on a travel dashboard
549 is "I want to add a new trip". Surface it as a primary
550 button so it's always one click away. */}
551 <Button
552 type="button"
553 onClick={() => goTo("trips", { action: "add" })}
554 className="inline-flex items-center gap-1"
555 >
556 <Plus className="h-4 w-4" />
557 {__("Add Trip", "yatra")}
558 </Button>
559 </div>
560 </div>
561
562 {/* ── AI BRIEF (renders nothing when module disabled) ─────────── */}
563 <TodaysBriefCard />
564
565 {/* ── WELCOME (dismissible) ──────────────────────────────────── */}
566 {!welcomeDismissed && (
567 <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">
568 <CardContent className="p-4">
569 <div className="flex items-start justify-between gap-3">
570 <div className="flex items-start gap-3">
571 <Activity className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
572 <div>
573 <h3 className="mb-1 font-semibold text-gray-900 dark:text-white">
574 {__("Welcome to Yatra", "yatra")}
575 </h3>
576 <p className="text-sm text-gray-600 dark:text-gray-300">
577 {__(
578 "Real-time insights for your travel business. Monitor performance, track bookings, and manage operations from one place.",
579 "yatra",
580 )}
581 </p>
582 </div>
583 </div>
584 <button
585 type="button"
586 onClick={dismissWelcome}
587 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"
588 aria-label={__("Dismiss welcome message", "yatra")}
589 >
590 <X className="h-4 w-4" />
591 </button>
592 </div>
593 </CardContent>
594 </Card>
595 )}
596
597 {/* ── KPI STRIP ───────────────────────────────────────────────── */}
598 <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
599 <ConditionalRender capability="yatra_view_trips">
600 <StatCard
601 title={__("Total Trips", "yatra")}
602 value={tripsSummary?.total || 0}
603 icon={MapPin}
604 color="blue"
605 loading={!tripsSummary}
606 onClick={() => goTo("trips")}
607 />
608 </ConditionalRender>
609
610 <ConditionalRender capability="yatra_view_bookings">
611 <StatCard
612 title={__("Total Bookings", "yatra")}
613 value={bookingStats?.total || 0}
614 icon={Calendar}
615 color="green"
616 loading={bookingStatsLoading}
617 trend={
618 hasPeriodData
619 ? {
620 value: totalChange,
621 isPositive: totalChange >= 0,
622 label: trendLabel,
623 }
624 : undefined
625 }
626 onClick={() => goTo("bookings")}
627 />
628 </ConditionalRender>
629
630 <ConditionalRender capability="yatra_view_financial_reports">
631 <StatCard
632 title={__("Booked Revenue", "yatra")}
633 value={formatCurrencyAmount(bookingStats?.total_revenue || 0)}
634 icon={DollarSign}
635 color="purple"
636 loading={bookingStatsLoading}
637 tooltip={__(
638 "Total value of confirmed bookings — the gross revenue you're entitled to. Not yet net of cancellations or refunds.",
639 "yatra",
640 )}
641 trend={
642 hasPeriodData
643 ? {
644 value: revenueChange,
645 isPositive: revenueChange >= 0,
646 label: trendLabel,
647 }
648 : undefined
649 }
650 onClick={() => goTo("payments")}
651 />
652 </ConditionalRender>
653
654 <ConditionalRender capability="yatra_view_financial_reports">
655 <StatCard
656 title={__("Collected Revenue", "yatra")}
657 value={formatCurrencyAmount(bookingStats?.total_collected || 0)}
658 icon={DollarSign}
659 color="green"
660 loading={bookingStatsLoading}
661 tooltip={__(
662 "Cash already received — what's actually in your accounts. The gap between Booked and Collected is your accounts-receivable (pending payments + scheduled instalments).",
663 "yatra",
664 )}
665 onClick={() => goTo("payments")}
666 />
667 </ConditionalRender>
668
669 <ConditionalRender capability="yatra_view_customers">
670 <StatCard
671 title={__("Total Customers", "yatra")}
672 value={customersSummary?.total || 0}
673 icon={Users}
674 color="orange"
675 loading={!customersSummary}
676 onClick={() => goTo("customers")}
677 />
678 </ConditionalRender>
679 </div>
680
681 {/* ── SECONDARY KPI ROW (period-aware) ────────────────────────── */}
682 {/* These are operational + revenue indicators. Wrap each card in
683 * the appropriate cap — revenue-flavoured for finance roles
684 * (Accountant), conversion / occupancy / cancellation for
685 * operational roles (Sales Agent / Manager / Marketing). */}
686 {hasPeriodData && (
687 <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
688 <ConditionalRender capability="yatra_view_operational_reports">
689 <StatCard
690 title={__("Conversion Rate", "yatra")}
691 value={`${Number((reportData as any)?.booking_stats?.conversionRate || 0).toFixed(1)}%`}
692 icon={TrendingUp}
693 color="green"
694 tooltip={__(
695 "Share of bookings in this period that landed in a confirmed or completed state. Higher is better.",
696 "yatra",
697 )}
698 trend={{
699 value: conversionChange,
700 isPositive: conversionChange >= 0,
701 label: trendLabel,
702 }}
703 />
704 </ConditionalRender>
705 <ConditionalRender capability="yatra_view_financial_reports">
706 <StatCard
707 title={__("Avg Booking Value", "yatra")}
708 value={formatCurrencyAmount(
709 Number((reportData as any)?.revenue_stats?.average || 0),
710 )}
711 icon={DollarSign}
712 color="purple"
713 tooltip={__(
714 "Mean revenue per booking in this period. A rising AOV with stable booking count is the cleanest growth signal.",
715 "yatra",
716 )}
717 />
718 </ConditionalRender>
719 <ConditionalRender capability="yatra_view_operational_reports">
720 <StatCard
721 title={__("Occupancy Rate", "yatra")}
722 value={`${Number((reportData as any)?.operational_stats?.occupancyRate || 0).toFixed(1)}%`}
723 icon={Plane}
724 color="blue"
725 tooltip={__(
726 "Booked seats / total seats across upcoming departures. Capacity utilisation indicator.",
727 "yatra",
728 )}
729 />
730 </ConditionalRender>
731 <ConditionalRender capability="yatra_view_operational_reports">
732 <StatCard
733 title={__("Cancellation Rate", "yatra")}
734 value={`${Number((reportData as any)?.booking_stats?.cancellationRate || 0).toFixed(1)}%`}
735 icon={CheckCircle}
736 color="red"
737 tooltip={__(
738 "Share of bookings that ended cancelled. Watch the trend — a spike usually points at a specific trip or timing issue.",
739 "yatra",
740 )}
741 />
742 </ConditionalRender>
743 </div>
744 )}
745
746 {/* ── MAIN GRID: charts (left) + widgets (right) ──────────────── */}
747 <div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
748 <div className="space-y-3 lg:col-span-7">
749 <ConditionalRender capability="yatra_view_bookings">
750 <Card>
751 <CardHeader>
752 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
753 </CardHeader>
754 <CardContent className="pb-2">
755 <BookingsOverviewChart
756 data={bookingsChartData || []}
757 currency={defaultCurrency}
758 currencyPosition={currencyPosition}
759 currencyDecimals={currencyDecimals}
760 />
761 </CardContent>
762 </Card>
763 </ConditionalRender>
764
765 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
766 <ConditionalRender capability="yatra_view_bookings">
767 <Card>
768 <CardHeader>
769 <CardTitle>{__("Booking Status", "yatra")}</CardTitle>
770 </CardHeader>
771 <CardContent className="pb-2">
772 <BookingStatusChart data={statusData || []} />
773 </CardContent>
774 </Card>
775 </ConditionalRender>
776
777 <ConditionalRender capability="yatra_view_operational_reports">
778 <Card>
779 <CardHeader>
780 <CardTitle>{__("Popular Destinations", "yatra")}</CardTitle>
781 </CardHeader>
782 <CardContent className="pb-2">
783 {destinationsData && destinationsData.length > 0 ? (
784 <SimpleBarChart
785 data={destinationsData || []}
786 title=""
787 height={180}
788 showValues={true}
789 />
790 ) : (
791 <div className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
792 {__("No destination data yet.", "yatra")}
793 </div>
794 )}
795 </CardContent>
796 </Card>
797 </ConditionalRender>
798 </div>
799
800 <ConditionalRender capability="yatra_view_bookings">
801 <RecentBookings
802 bookings={recentBookings || []}
803 loading={bookingStatsLoading}
804 onView={(booking) => {
805 goTo("bookings", { action: "view", id: String(booking.id) });
806 }}
807 />
808 </ConditionalRender>
809 </div>
810
811 <div className="space-y-3 lg:col-span-5">
812 <ConditionalRender capability="yatra_view_departures">
813 <UpcomingDepartures
814 departures={departures || []}
815 loading={bookingStatsLoading}
816 />
817 </ConditionalRender>
818
819 <ConditionalRender capability="yatra_view_financial_reports">
820 <PendingPayments
821 payments={pendingPayments || []}
822 loading={bookingStatsLoading}
823 />
824 </ConditionalRender>
825 </div>
826 </div>
827 </div>
828 );
829 };
830
831 export default Dashboard;
832