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

728 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Dashboard Page
3 * Clean, minimal SaaS-style dashboard with proper alignment
4 */
5
6 import React from "react";
7 import { useQuery } from "@tanstack/react-query";
8 import {
9 MapPin,
10 Calendar,
11 DollarSign,
12 Users,
13 Plane,
14 TrendingUp,
15 Info,
16 Activity,
17 Clock,
18 CheckCircle,
19 // AlertCircle available for future use
20 } from "lucide-react";
21 import { __ } from "../lib/i18n";
22 import { usePermissions } from "../hooks/usePermissions";
23 import { StatCard } from "../components/common/StatCard";
24 import {
25 Card,
26 CardContent,
27 CardHeader,
28 CardTitle,
29 } from "../components/ui/card";
30 import { Button } from "../components/ui/button";
31 import { ConditionalRender } from "../components/ui/conditional-render";
32 import { Skeleton } from "../components/ui/skeleton";
33 import { SimpleBarChart } from "../components/charts/SimpleBarChart";
34 import BookingsOverviewChart from "../components/charts/BookingsOverviewChart";
35 import BookingStatusChart from "../components/charts/BookingStatusChart";
36 import { UpcomingDepartures } from "../components/dashboard/UpcomingDepartures";
37 import { PendingPayments } from "../components/dashboard/PendingPayments";
38 import { RecentBookings } from "../components/dashboard/RecentBookings";
39 import { apiClient } from "../lib/api-client";
40 import {
41 formatYatraMoney,
42 readYatraCurrencyPositionFromWindow,
43 } from "../lib/currency-display";
44
45 // Skeleton components
46 const SkeletonStatCard = () => (
47 <Card>
48 <CardContent className="pt-6">
49 <div className="flex items-center justify-between">
50 <div className="flex-1">
51 <Skeleton className="h-4 w-24 mb-2" />
52 <Skeleton className="h-8 w-32" />
53 </div>
54 <Skeleton className="w-12 h-12 rounded-full" />
55 </div>
56 </CardContent>
57 </Card>
58 );
59
60 const SkeletonQuickStat = () => (
61 <div className="flex items-center gap-3">
62 <Skeleton className="w-10 h-10 rounded-full" />
63 <div className="flex-1">
64 <Skeleton className="h-3 w-16 mb-1" />
65 <Skeleton className="h-5 w-12" />
66 </div>
67 </div>
68 );
69
70 const Dashboard: React.FC = () => {
71 const { can } = usePermissions();
72
73 const defaultCurrency =
74 (window as any)?.yatraAdmin?.currency ||
75 (window as any)?.yatraBookingData?.currency ||
76 "USD";
77
78 const currencyPosition = readYatraCurrencyPositionFromWindow();
79
80 const currencyDecimalsRaw =
81 (window as any)?.yatraAdmin?.decimalPlaces ??
82 (window as any)?.yatraAdmin?.currency_decimals ??
83 (window as any)?.yatraBookingData?.decimalPlaces ??
84 (window as any)?.yatraBookingData?.currency_decimals;
85 const currencyDecimals = Number.isFinite(Number(currencyDecimalsRaw))
86 ? Number(currencyDecimalsRaw)
87 : 2;
88
89 const formatCurrencyAmount = (amount: number) =>
90 formatYatraMoney(Number(amount) || 0, defaultCurrency, {
91 zeroAsUnknown: false,
92 });
93
94 // Fetch booking statistics (totals, revenue, status breakdown, upcoming)
95 const { data: bookingStats, isLoading } = useQuery({
96 queryKey: ["dashboard-booking-stats"],
97 queryFn: async () => {
98 const response = await apiClient.get("/bookings/stats");
99 // REST returns a flat stats object; some endpoints nest under `data` — prefer the flat body.
100 return response?.data ?? response ?? {};
101 },
102 });
103
104 // Fetch total trips count
105 const { data: tripsSummary } = useQuery({
106 queryKey: ["dashboard-trips-total"],
107 queryFn: async () => {
108 const response = await apiClient.get("/trips", {
109 params: { per_page: 1 },
110 });
111 return { total: response?.total ?? 0 };
112 },
113 enabled: can("yatra_view_trips"),
114 });
115
116 // Fetch total customers count
117 const { data: customersSummary } = useQuery({
118 queryKey: ["dashboard-customers-total"],
119 queryFn: async () => {
120 const response = await apiClient.get("/customers", {
121 params: { per_page: 1 },
122 });
123 return { total: response?.total ?? 0 };
124 },
125 enabled: can("yatra_view_bookings"),
126 });
127
128 // Fetch bookings chart data: bookings per month for the last 6 months (including current)
129 const { data: bookingsData } = useQuery({
130 queryKey: ["bookings-chart"],
131 queryFn: async () => {
132 const now = new Date();
133 const pad = (n: number) => String(n).padStart(2, "0");
134
135 // Get a recent batch of bookings and aggregate by created_at month
136 const response = await apiClient.get("/bookings", {
137 params: {
138 per_page: 500,
139 },
140 });
141
142 const items: any[] = response?.data || [];
143 const counts: Record<string, number> = {};
144 const amounts: Record<string, number> = {};
145
146 items.forEach((b) => {
147 // Group by created_at month; fall back to travel_date
148 const dateStr = b.created_at || b.travel_date;
149 const date = dateStr ? new Date(dateStr) : null;
150 if (!date || Number.isNaN(date.getTime())) return;
151 const ym = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
152 counts[ym] = (counts[ym] || 0) + 1;
153 const amount = Number(b.total_amount ?? 0) || 0;
154 amounts[ym] = (amounts[ym] || 0) + amount;
155 });
156
157 // Build the last 6 calendar months (including current), oldest first
158 const months: { label: string; count: number; amount: number }[] = [];
159 for (let i = 5; i >= 0; i--) {
160 const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
161 const ym = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
162 const label = d.toLocaleDateString(undefined, {
163 month: "short",
164 year: "numeric",
165 });
166 months.push({
167 label,
168 count: counts[ym] || 0,
169 amount: amounts[ym] || 0,
170 });
171 }
172
173 return months;
174 },
175 enabled: can("yatra_view_bookings"),
176 });
177
178 // Derive booking status breakdown from bookingStats.by_status
179 // MUST be before any conditional returns (React Hooks rule)
180 const statusData = React.useMemo(() => {
181 const byStatus = (bookingStats as any)?.by_status || {};
182
183 const getCount = (key: string) => {
184 const entry = byStatus[key];
185 if (!entry) return 0;
186 // entry is an object like { status: 'pending', count: '5' }
187 const raw = (entry as any).count;
188 const n = typeof raw === "string" ? parseInt(raw, 10) : Number(raw ?? 0);
189 return Number.isNaN(n) ? 0 : n;
190 };
191
192 return [
193 {
194 label: __("Pending", "yatra"),
195 value: getCount("pending"),
196 color: "#f59e0b",
197 },
198 {
199 label: __("Confirmed", "yatra"),
200 value: getCount("confirmed"),
201 color: "#10b981",
202 },
203 {
204 label: __("Completed", "yatra"),
205 value: getCount("completed"),
206 color: "#3b82f6",
207 },
208 {
209 label: __("Cancelled", "yatra"),
210 value: getCount("cancelled"),
211 color: "#ef4444",
212 },
213 {
214 label: __("Refunded", "yatra"),
215 value: getCount("refunded"),
216 color: "#a855f7",
217 },
218 ];
219 }, [bookingStats]);
220
221 // Fetch popular destinations (aggregate from trips API)
222 const { data: destinationsData } = useQuery({
223 queryKey: ["popular-destinations"],
224 queryFn: async () => {
225 const response = await apiClient.get("/trips", {
226 params: {
227 per_page: 50,
228 },
229 });
230
231 const trips = response?.data || [];
232 const counts: Record<string, number> = {};
233
234 // Each trip may have destinations array (from TripController prepare_item_for_response)
235 trips.forEach((trip: any) => {
236 const destinations = trip.destinations || [];
237 destinations.forEach((dest: any) => {
238 const name = dest?.name || dest?.destination_name || "";
239 if (!name) return;
240 counts[name] = (counts[name] || 0) + 1;
241 });
242 });
243
244 const palette = [
245 "#3b82f6",
246 "#10b981",
247 "#f59e0b",
248 "#ef4444",
249 "#6366f1",
250 "#14b8a6",
251 ];
252 const entries = Object.entries(counts)
253 .sort((a, b) => b[1] - a[1])
254 .slice(0, 6);
255
256 return entries.map(([name, value], index) => ({
257 label: name,
258 value,
259 color: palette[index % palette.length],
260 }));
261 },
262 enabled: can("yatra_view_trips"),
263 });
264
265 // Fetch upcoming departures (real data from /departures)
266 const { data: departures } = useQuery({
267 queryKey: ["upcoming-departures"],
268 queryFn: async () => {
269 const today = new Date();
270 const todayStr = today.toISOString().split("T")[0];
271
272 const response = await apiClient.get("/departures", {
273 params: {
274 status: "upcoming",
275 date_from: todayStr,
276 include_past: false,
277 },
278 });
279
280 const items = response?.data || [];
281
282 // Map API departures into widget-friendly shape
283 return items.map((d: any) => {
284 const tripTitle = d?.trip?.title || d?.trip_title || d?.title || "";
285 const destination =
286 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
287 d?.destination ||
288 undefined;
289 const totalSpots =
290 d?.total_spots ??
291 d?.capacity ??
292 d?.total_seats ??
293 d?.max_travelers ??
294 0;
295 const availableSpots =
296 d?.available_spots ??
297 d?.available_seats ??
298 d?.remaining_slots ??
299 totalSpots - (d?.bookings_count || 0);
300
301 return {
302 id: d.id,
303 trip_id: d.trip_id || d?.trip?.id,
304 trip_title: tripTitle,
305 departure_date: d.start_date || d.date || d.departure_date,
306 available_spots: Number.isFinite(availableSpots) ? availableSpots : 0,
307 total_spots: Number.isFinite(totalSpots) ? totalSpots : 0,
308 status: d.status || "upcoming",
309 destination,
310 };
311 });
312 },
313 enabled: can("yatra_view_trips"),
314 });
315
316 // Fetch pending payments (real data)
317 const { data: pendingPayments } = useQuery({
318 queryKey: ["pending-payments"],
319 queryFn: async () => {
320 const response = await apiClient.get("/payments", {
321 params: {
322 status: "pending",
323 per_page: 5,
324 },
325 });
326 return response?.data || [];
327 },
328 enabled: can("yatra_view_bookings"),
329 });
330
331 // Fetch recent bookings (real data, latest 5) and map to widget shape
332 const { data: recentBookings } = useQuery({
333 queryKey: ["recent-bookings"],
334 queryFn: async () => {
335 const response = await apiClient.get("/bookings", {
336 params: {
337 per_page: 5,
338 },
339 });
340 const items = response?.data || [];
341
342 return items.map((b: any) => ({
343 id: b.id,
344 booking_id: b.reference || `BK-${b.id}`,
345 customer_name: b.customer_name || b.contact_first_name || "",
346 trip_title: b.trip_title || "",
347 // Prefer created_at, fallback to travel_date, otherwise empty string
348 booking_date: b.created_at || b.travel_date || "",
349 total_amount: b.total_amount ?? 0,
350 status: (b.status || "pending") as
351 | "confirmed"
352 | "pending"
353 | "cancelled"
354 | "completed",
355 }));
356 },
357 enabled: can("yatra_view_bookings"),
358 });
359
360 // Show skeleton while loading - AFTER all hooks
361 if (isLoading) {
362 return (
363 <div className="space-y-6">
364 {/* Skeleton for KPI Cards */}
365 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
366 <SkeletonStatCard />
367 <SkeletonStatCard />
368 <SkeletonStatCard />
369 <SkeletonStatCard />
370 </div>
371
372 {/* Skeleton for Quick Stats */}
373 <Card>
374 <CardHeader>
375 <Skeleton className="h-6 w-32" />
376 </CardHeader>
377 <CardContent>
378 <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
379 <SkeletonQuickStat />
380 <SkeletonQuickStat />
381 <SkeletonQuickStat />
382 <SkeletonQuickStat />
383 </div>
384 </CardContent>
385 </Card>
386
387 {/* Skeleton for Charts */}
388 <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
389 <Card>
390 <CardHeader>
391 <Skeleton className="h-6 w-40" />
392 </CardHeader>
393 <CardContent>
394 <Skeleton className="h-64 w-full" />
395 </CardContent>
396 </Card>
397 <Card>
398 <CardHeader>
399 <Skeleton className="h-6 w-40" />
400 </CardHeader>
401 <CardContent>
402 <Skeleton className="h-64 w-full" />
403 </CardContent>
404 </Card>
405 </div>
406
407 {/* Skeleton for Widgets */}
408 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
409 <Card>
410 <CardHeader>
411 <Skeleton className="h-6 w-40" />
412 </CardHeader>
413 <CardContent>
414 <div className="space-y-4">
415 <Skeleton className="h-16 w-full" />
416 <Skeleton className="h-16 w-full" />
417 <Skeleton className="h-16 w-full" />
418 </div>
419 </CardContent>
420 </Card>
421 <Card>
422 <CardHeader>
423 <Skeleton className="h-6 w-40" />
424 </CardHeader>
425 <CardContent>
426 <div className="space-y-4">
427 <Skeleton className="h-16 w-full" />
428 <Skeleton className="h-16 w-full" />
429 <Skeleton className="h-16 w-full" />
430 </div>
431 </CardContent>
432 </Card>
433 <Card>
434 <CardHeader>
435 <Skeleton className="h-6 w-40" />
436 </CardHeader>
437 <CardContent>
438 <div className="space-y-4">
439 <Skeleton className="h-16 w-full" />
440 <Skeleton className="h-16 w-full" />
441 <Skeleton className="h-16 w-full" />
442 </div>
443 </CardContent>
444 </Card>
445 </div>
446 </div>
447 );
448 }
449
450 return (
451 <div className="space-y-3">
452 {/* Enhanced Welcome Message */}
453 <Card className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-blue-200 dark:border-blue-800">
454 <CardContent className="p-4">
455 <div className="flex items-start justify-between">
456 <div className="flex items-start gap-3">
457 <Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
458 <div>
459 <h3 className="font-semibold text-gray-900 dark:text-white mb-1">
460 {__("Welcome to Yatra Dashboard", "yatra")}
461 </h3>
462 <p className="text-sm text-gray-600 dark:text-gray-300">
463 {__(
464 "Real-time insights for your travel booking business. Monitor performance, track bookings, and manage operations efficiently.",
465 "yatra",
466 )}
467 </p>
468 </div>
469 </div>
470 <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
471 <Activity className="w-4 h-4" />
472 <span>{__("Live Data", "yatra")}</span>
473 </div>
474 </div>
475 </CardContent>
476 </Card>
477
478 {/* Key Metrics - Top Row: single horizontal row (scrolls on small screens) */}
479 <div className="flex flex-nowrap gap-3 overflow-x-auto pb-1">
480 <div className="flex-1 min-w-0">
481 <StatCard
482 title={__("Total Trips", "yatra")}
483 value={tripsSummary?.total || 0}
484 icon={MapPin}
485 color="blue"
486 loading={isLoading}
487 />
488 </div>
489
490 <div className="flex-1 min-w-0">
491 <StatCard
492 title={__("Total Bookings", "yatra")}
493 value={bookingStats?.total || 0}
494 icon={Calendar}
495 color="green"
496 loading={isLoading}
497 />
498 </div>
499
500 <div className="flex-1 min-w-0">
501 <StatCard
502 title={__("Booked Revenue", "yatra")}
503 value={formatCurrencyAmount(bookingStats?.total_revenue || 0)}
504 icon={DollarSign}
505 color="purple"
506 loading={isLoading}
507 />
508 </div>
509
510 <div className="flex-1 min-w-0">
511 <StatCard
512 title={__("Collected Revenue", "yatra")}
513 value={formatCurrencyAmount(bookingStats?.total_collected || 0)}
514 icon={DollarSign}
515 color="green"
516 loading={isLoading}
517 />
518 </div>
519
520 <div className="flex-1 min-w-0">
521 <StatCard
522 title={__("Total Customers", "yatra")}
523 value={customersSummary?.total || 0}
524 icon={Users}
525 color="orange"
526 loading={isLoading}
527 />
528 </div>
529
530 <div className="flex-1 min-w-0">
531 <StatCard
532 title={__("Confirmed Bookings", "yatra")}
533 value={(bookingStats as any)?.by_status?.confirmed?.count || 0}
534 icon={CheckCircle}
535 color="green"
536 loading={isLoading}
537 />
538 </div>
539
540 <div className="flex-1 min-w-0">
541 <StatCard
542 title={__("Pending Bookings", "yatra")}
543 value={(bookingStats as any)?.by_status?.pending?.count || 0}
544 icon={Clock}
545 color="orange"
546 loading={isLoading}
547 />
548 </div>
549 </div>
550
551 {/* Main Content Grid - Optimized Layout */}
552 <div className="grid grid-cols-1 lg:grid-cols-12 gap-3">
553 {/* Left Column - Charts (7 columns) */}
554 <div className="lg:col-span-7 space-y-3">
555 {/* Bookings Overview - Full Width */}
556 <ConditionalRender capability="yatra_view_bookings">
557 <Card>
558 <CardHeader>
559 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
560 </CardHeader>
561 <CardContent className="pb-2">
562 <BookingsOverviewChart
563 data={bookingsData || []}
564 currency={defaultCurrency}
565 currencyPosition={currencyPosition}
566 currencyDecimals={currencyDecimals}
567 />
568 </CardContent>
569 </Card>
570 </ConditionalRender>
571
572 {/* Booking Status and Popular Destinations - Side by Side */}
573 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
574 <ConditionalRender capability="yatra_view_bookings">
575 <Card>
576 <CardHeader>
577 <CardTitle>{__("Booking Status", "yatra")}</CardTitle>
578 </CardHeader>
579 <CardContent className="pb-2">
580 <BookingStatusChart data={statusData || []} />
581 </CardContent>
582 </Card>
583 </ConditionalRender>
584
585 <ConditionalRender capability="yatra_view_trips">
586 <Card>
587 <CardHeader>
588 <CardTitle>{__("Popular Destinations", "yatra")}</CardTitle>
589 </CardHeader>
590 <CardContent className="pb-2">
591 <SimpleBarChart
592 data={destinationsData || []}
593 title=""
594 height={180}
595 showValues={true}
596 />
597 </CardContent>
598 </Card>
599 </ConditionalRender>
600 </div>
601
602 {/* Additional Stats - Integrated into main grid */}
603 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
604 <ConditionalRender capability="yatra_view_bookings">
605 <StatCard
606 title={__("Pending Bookings", "yatra")}
607 value={(bookingStats as any)?.by_status?.pending?.count || 0}
608 icon={TrendingUp}
609 color="orange"
610 loading={isLoading}
611 />
612 </ConditionalRender>
613
614 <ConditionalRender capability="yatra_view_trips">
615 <StatCard
616 title={__("Upcoming Departures", "yatra")}
617 value={bookingStats?.upcoming || 0}
618 icon={Plane}
619 color="green"
620 loading={isLoading}
621 />
622 </ConditionalRender>
623 </div>
624
625 {/* Recent Bookings - Fill the gap */}
626 <ConditionalRender capability="yatra_view_bookings">
627 <RecentBookings
628 bookings={recentBookings || []}
629 loading={isLoading}
630 onView={(booking) => {
631 const admin = (window as any)?.yatraAdmin;
632 const baseUrl = admin?.siteUrl || "";
633 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=bookings&action=view&id=${booking.id}`;
634 }}
635 />
636 </ConditionalRender>
637
638 {/* Quick Actions */}
639 <Card>
640 <CardHeader>
641 <CardTitle className="flex items-center gap-2">
642 <TrendingUp className="w-5 h-5" />
643 {__("Quick Actions", "yatra")}
644 </CardTitle>
645 </CardHeader>
646 <CardContent>
647 <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
648 <Button
649 variant="outline"
650 className="h-auto p-4 flex flex-col items-center gap-2"
651 onClick={() => {
652 const admin = (window as any)?.yatraAdmin;
653 const baseUrl = admin?.siteUrl || "";
654 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=trips&action=add`;
655 }}
656 >
657 <MapPin className="w-5 h-5" />
658 <span className="text-xs">{__("Add Trip", "yatra")}</span>
659 </Button>
660
661 <Button
662 variant="outline"
663 className="h-auto p-4 flex flex-col items-center gap-2"
664 onClick={() => {
665 const admin = (window as any)?.yatraAdmin;
666 const baseUrl = admin?.siteUrl || "";
667 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=bookings`;
668 }}
669 >
670 <Calendar className="w-5 h-5" />
671 <span className="text-xs">
672 {__("View Bookings", "yatra")}
673 </span>
674 </Button>
675
676 <Button
677 variant="outline"
678 className="h-auto p-4 flex flex-col items-center gap-2"
679 onClick={() => {
680 const admin = (window as any)?.yatraAdmin;
681 const baseUrl = admin?.siteUrl || "";
682 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=customers`;
683 }}
684 >
685 <Users className="w-5 h-5" />
686 <span className="text-xs">{__("Customers", "yatra")}</span>
687 </Button>
688
689 <Button
690 variant="outline"
691 className="h-auto p-4 flex flex-col items-center gap-2"
692 onClick={() => {
693 const admin = (window as any)?.yatraAdmin;
694 const baseUrl = admin?.siteUrl || "";
695 window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=reports`;
696 }}
697 >
698 <Activity className="w-5 h-5" />
699 <span className="text-xs">{__("Reports", "yatra")}</span>
700 </Button>
701 </div>
702 </CardContent>
703 </Card>
704 </div>
705
706 {/* Right Column - Widgets (5 columns) */}
707 <div className="lg:col-span-5 space-y-3">
708 <ConditionalRender capability="yatra_view_trips">
709 <UpcomingDepartures
710 departures={departures || []}
711 loading={isLoading}
712 />
713 </ConditionalRender>
714
715 <ConditionalRender capability="yatra_view_bookings">
716 <PendingPayments
717 payments={pendingPayments || []}
718 loading={isLoading}
719 />
720 </ConditionalRender>
721 </div>
722 </div>
723 </div>
724 );
725 };
726
727 export default Dashboard;
728