PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/Dashboard.tsx +464 -342 3.0.3 → 3.0.17 View file →
@@ -1,10 +1,32 @@
1 1 /**
2 - * Dashboard Page
3 - * Clean, minimal SaaS-style dashboard with proper alignment
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.
4 26 */
5 27
6 -import React from "react";
28 +import React, { useMemo, useState } from "react";
7 29 import { useQuery } from "@tanstack/react-query";
8 30 import {
9 31 MapPin,
10 32 Calendar,
@@ -11,16 +33,17 @@
11 33 DollarSign,
12 34 Users,
13 35 Plane,
14 36 TrendingUp,
15 - Info,
16 37 Activity,
17 - Clock,
18 38 CheckCircle,
19 - // AlertCircle available for future use
39 + Plus,
40 + X,
20 41 } from "lucide-react";
21 42 import { __ } from "../lib/i18n";
43 +import { formatDateForInput, toDateValue } from "../lib/dateFormat";
22 44 import { usePermissions } from "../hooks/usePermissions";
45 +import { TodaysBriefCard } from "../components/ai/TodaysBriefCard";
23 46 import { StatCard } from "../components/common/StatCard";
24 47 import {
25 48 Card,
26 49 CardContent,
@@ -35,8 +58,9 @@
35 58 import BookingStatusChart from "../components/charts/BookingStatusChart";
36 59 import { UpcomingDepartures } from "../components/dashboard/UpcomingDepartures";
37 60 import { PendingPayments } from "../components/dashboard/PendingPayments";
38 61 import { RecentBookings } from "../components/dashboard/RecentBookings";
62 +import RoleDashboard from "./RoleDashboard";
39 63 import { apiClient } from "../lib/api-client";
40 64 import {
41 65 formatYatraMoney,
42 66 readYatraCurrencyPositionFromWindow,
@@ -41,9 +65,71 @@
41 65 formatYatraMoney,
42 66 readYatraCurrencyPositionFromWindow,
43 67 } from "../lib/currency-display";
44 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 +// ---------------------------------------------------------------------------
45 129 // Skeleton components
130 +// ---------------------------------------------------------------------------
131 +
46 132 const SkeletonStatCard = () => (
47 133 <Card>
48 134 <CardContent className="pt-6">
49 135 <div className="flex items-center justify-between">
@@ -56,21 +142,57 @@
56 142 </CardContent>
57 143 </Card>
58 144 );
59 145
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 -);
146 +// ---------------------------------------------------------------------------
147 +// Dashboard page
148 +// ---------------------------------------------------------------------------
69 149
150 +const WELCOME_DISMISSED_KEY = "yatra:dashboard:welcome-dismissed:v1";
151 +
70 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 +
71 170 const { can } = usePermissions();
72 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 +
73 195 const defaultCurrency =
74 196 (window as any)?.yatraAdmin?.currency ||
75 197 (window as any)?.yatraBookingData?.currency ||
76 198 "USD";
@@ -75,9 +197,8 @@
75 197 (window as any)?.yatraBookingData?.currency ||
76 198 "USD";
77 199
78 200 const currencyPosition = readYatraCurrencyPositionFromWindow();
79 -
80 201 const currencyDecimalsRaw =
81 202 (window as any)?.yatraAdmin?.decimalPlaces ??
82 203 (window as any)?.yatraAdmin?.currency_decimals ??
83 204 (window as any)?.yatraBookingData?.decimalPlaces ??
@@ -90,19 +211,22 @@
90 211 formatYatraMoney(Number(amount) || 0, defaultCurrency, {
91 212 zeroAsUnknown: false,
92 213 });
93 214
94 - // Fetch booking statistics (totals, revenue, status breakdown, upcoming)
95 - const { data: bookingStats, isLoading } = useQuery({
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({
96 221 queryKey: ["dashboard-booking-stats"],
97 222 queryFn: async () => {
98 223 const response = await apiClient.get("/bookings/stats");
99 - // REST returns a flat stats object; some endpoints nest under `data` — prefer the flat body.
100 224 return response?.data ?? response ?? {};
101 225 },
226 + enabled: isWpAdmin,
102 227 });
103 228
104 - // Fetch total trips count
105 229 const { data: tripsSummary } = useQuery({
106 230 queryKey: ["dashboard-trips-total"],
107 231 queryFn: async () => {
108 232 const response = await apiClient.get("/trips", {
@@ -109,12 +233,11 @@
109 233 params: { per_page: 1 },
110 234 });
111 235 return { total: response?.total ?? 0 };
112 236 },
113 - enabled: can("yatra_view_trips"),
237 + enabled: isWpAdmin && can("yatra_view_trips"),
114 238 });
115 239
116 - // Fetch total customers count
117 240 const { data: customersSummary } = useQuery({
118 241 queryKey: ["dashboard-customers-total"],
119 242 queryFn: async () => {
120 243 const response = await apiClient.get("/customers", {
@@ -121,23 +244,39 @@
121 244 params: { per_page: 1 },
122 245 });
123 246 return { total: response?.total ?? 0 };
124 247 },
125 - enabled: can("yatra_view_bookings"),
248 + enabled: isWpAdmin && can("yatra_view_bookings"),
126 249 });
127 250
128 - // Fetch bookings chart data: bookings per month for the last 6 months (including current)
129 - const { data: bookingsData } = useQuery({
130 - queryKey: ["bookings-chart"],
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],
131 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 () => {
132 274 const now = new Date();
133 275 const pad = (n: number) => String(n).padStart(2, "0");
134 276
135 - // Get a recent batch of bookings and aggregate by created_at month
136 277 const response = await apiClient.get("/bookings", {
137 - params: {
138 - per_page: 500,
139 - },
278 + params: { per_page: 500 },
140 279 });
141 280
142 281 const items: any[] = response?.data || [];
143 282 const counts: Record<string, number> = {};
@@ -143,11 +282,12 @@
143 282 const counts: Record<string, number> = {};
144 283 const amounts: Record<string, number> = {};
145 284
146 285 items.forEach((b) => {
147 - // Group by created_at month; fall back to travel_date
148 286 const dateStr = b.created_at || b.travel_date;
149 - const date = dateStr ? new Date(dateStr) : null;
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;
150 290 if (!date || Number.isNaN(date.getTime())) return;
151 291 const ym = `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
152 292 counts[ym] = (counts[ym] || 0) + 1;
153 293 const amount = Number(b.total_amount ?? 0) || 0;
@@ -153,9 +293,8 @@
153 293 const amount = Number(b.total_amount ?? 0) || 0;
154 294 amounts[ym] = (amounts[ym] || 0) + amount;
155 295 });
156 296
157 - // Build the last 6 calendar months (including current), oldest first
158 297 const months: { label: string; count: number; amount: number }[] = [];
159 298 for (let i = 5; i >= 0; i--) {
160 299 const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
161 300 const ym = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
@@ -171,25 +310,21 @@
171 310 }
172 311
173 312 return months;
174 313 },
175 - enabled: can("yatra_view_bookings"),
314 + enabled: isWpAdmin && can("yatra_view_bookings") && range === "all_time",
176 315 });
177 316
178 - // Derive booking status breakdown from bookingStats.by_status
179 - // MUST be before any conditional returns (React Hooks rule)
180 - const statusData = React.useMemo(() => {
317 + // Status breakdown — same /bookings/stats source.
318 + const statusData = useMemo(() => {
181 319 const byStatus = (bookingStats as any)?.by_status || {};
182 -
183 320 const getCount = (key: string) => {
184 321 const entry = byStatus[key];
185 322 if (!entry) return 0;
186 - // entry is an object like { status: 'pending', count: '5' }
187 323 const raw = (entry as any).count;
188 324 const n = typeof raw === "string" ? parseInt(raw, 10) : Number(raw ?? 0);
189 325 return Number.isNaN(n) ? 0 : n;
190 326 };
191 -
192 327 return [
193 328 {
194 329 label: __("Pending", "yatra"),
195 330 value: getCount("pending"),
@@ -217,22 +352,18 @@
217 352 },
218 353 ];
219 354 }, [bookingStats]);
220 355
221 - // Fetch popular destinations (aggregate from trips API)
356 + // Popular destinations — derived from trips list (still bounded by
357 + // 50; tracked in audit as a server-aggregate follow-up).
222 358 const { data: destinationsData } = useQuery({
223 - queryKey: ["popular-destinations"],
359 + queryKey: ["dashboard-destinations"],
224 360 queryFn: async () => {
225 361 const response = await apiClient.get("/trips", {
226 - params: {
227 - per_page: 50,
228 - },
362 + params: { per_page: 50 },
229 363 });
230 -
231 364 const trips = response?.data || [];
232 365 const counts: Record<string, number> = {};
233 -
234 - // Each trip may have destinations array (from TripController prepare_item_for_response)
235 366 trips.forEach((trip: any) => {
236 367 const destinations = trip.destinations || [];
237 368 destinations.forEach((dest: any) => {
238 369 const name = dest?.name || dest?.destination_name || "";
@@ -239,9 +370,8 @@
239 370 if (!name) return;
240 371 counts[name] = (counts[name] || 0) + 1;
241 372 });
242 373 });
243 -
244 374 const palette = [
245 375 "#3b82f6",
246 376 "#10b981",
247 377 "#f59e0b",
@@ -248,39 +378,41 @@
248 378 "#ef4444",
249 379 "#6366f1",
250 380 "#14b8a6",
251 381 ];
252 - const entries = Object.entries(counts)
382 + return Object.entries(counts)
253 383 .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 - }));
384 + .slice(0, 6)
385 + .map(([name, value], index) => ({
386 + label: name,
387 + value,
388 + color: palette[index % palette.length],
389 + }));
261 390 },
262 - enabled: can("yatra_view_trips"),
391 + enabled: isWpAdmin && can("yatra_view_trips"),
263 392 });
264 393
265 - // Fetch upcoming departures (real data from /departures)
266 394 const { data: departures } = useQuery({
267 - queryKey: ["upcoming-departures"],
395 + queryKey: ["dashboard-upcoming-departures"],
268 396 queryFn: async () => {
269 397 const today = new Date();
270 - const todayStr = today.toISOString().split("T")[0];
271 -
398 + const todayStr = formatDateForInput(today);
399 + // Fetch all FUTURE departures, not only status === "upcoming". A departure
400 + // that a booking has filled flips to status "full" (booked >= capacity),
401 + // and a single group-of-x booking fills a small/private departure in one
402 + // go — so filtering to "upcoming" here silently dropped fully-booked
403 + // upcoming tours from the dashboard while partially-booked ones stayed. We
404 + // keep upcoming + full (a full trip is still an upcoming trip, just sold
405 + // out) and exclude only cancelled/trash/past.
272 406 const response = await apiClient.get("/departures", {
273 407 params: {
274 - status: "upcoming",
275 408 date_from: todayStr,
276 409 include_past: false,
277 410 },
278 411 });
279 -
280 - const items = response?.data || [];
281 -
282 - // Map API departures into widget-friendly shape
412 + const items = (response?.data || []).filter(
413 + (d: any) => d?.status === "upcoming" || d?.status === "full",
414 + );
283 415 return items.map((d: any) => {
284 416 const tripTitle = d?.trip?.title || d?.trip_title || d?.title || "";
285 417 const destination =
286 418 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
@@ -285,9 +417,16 @@
285 417 const destination =
286 418 (d?.trip?.destinations && d.trip.destinations[0]?.name) ||
287 419 d?.destination ||
288 420 undefined;
421 + // `/departures` returns max_capacity / available_capacity. Neither was
422 + // listed here, so every card fell back to 0 capacity — which made
423 + // available go negative (0 - bookings) and pinned occupancy at 0%, even
424 + // though the trip page computed it correctly from the same rows. The
425 + // API's own names come first; the rest remain as fallbacks for any other
426 + // shape that reaches this mapper.
289 427 const totalSpots =
428 + d?.max_capacity ??
290 429 d?.total_spots ??
291 430 d?.capacity ??
292 431 d?.total_seats ??
293 432 d?.max_travelers ??
@@ -292,13 +431,13 @@
292 431 d?.total_seats ??
293 432 d?.max_travelers ??
294 433 0;
295 434 const availableSpots =
435 + d?.available_capacity ??
296 436 d?.available_spots ??
297 437 d?.available_seats ??
298 438 d?.remaining_slots ??
299 - totalSpots - (d?.bookings_count || 0);
300 -
439 + totalSpots - (d?.booked_count ?? d?.bookings_count ?? 0);
301 440 return {
302 441 id: d.id,
303 442 trip_id: d.trip_id || d?.trip?.id,
304 443 trip_title: tripTitle,
@@ -309,43 +448,34 @@
309 448 destination,
310 449 };
311 450 });
312 451 },
313 - enabled: can("yatra_view_trips"),
452 + enabled: isWpAdmin && can("yatra_view_trips"),
314 453 });
315 454
316 - // Fetch pending payments (real data)
317 455 const { data: pendingPayments } = useQuery({
318 - queryKey: ["pending-payments"],
456 + queryKey: ["dashboard-pending-payments"],
319 457 queryFn: async () => {
320 458 const response = await apiClient.get("/payments", {
321 - params: {
322 - status: "pending",
323 - per_page: 5,
324 - },
459 + params: { status: "pending", per_page: 5 },
325 460 });
326 461 return response?.data || [];
327 462 },
328 - enabled: can("yatra_view_bookings"),
463 + enabled: isWpAdmin && can("yatra_view_bookings"),
329 464 });
330 465
331 - // Fetch recent bookings (real data, latest 5) and map to widget shape
332 466 const { data: recentBookings } = useQuery({
333 - queryKey: ["recent-bookings"],
467 + queryKey: ["dashboard-recent-bookings"],
334 468 queryFn: async () => {
335 469 const response = await apiClient.get("/bookings", {
336 - params: {
337 - per_page: 5,
338 - },
470 + params: { per_page: 5 },
339 471 });
340 472 const items = response?.data || [];
341 -
342 473 return items.map((b: any) => ({
343 474 id: b.id,
344 475 booking_id: b.reference || `BK-${b.id}`,
345 476 customer_name: b.customer_name || b.contact_first_name || "",
346 477 trip_title: b.trip_title || "",
347 - // Prefer created_at, fallback to travel_date, otherwise empty string
348 478 booking_date: b.created_at || b.travel_date || "",
349 479 total_amount: b.total_amount ?? 0,
350 480 status: (b.status || "pending") as
351 481 | "confirmed"
@@ -353,207 +483,288 @@
353 483 | "cancelled"
354 484 | "completed",
355 485 }));
356 486 },
357 - enabled: can("yatra_view_bookings"),
487 + enabled: isWpAdmin && can("yatra_view_bookings"),
358 488 });
359 489
360 - // Show skeleton while loading - AFTER all hooks
361 - if (isLoading) {
490 + // Build trend props for KPI cards. Only present when a real range is
491 + // picked (period-over-period only makes sense against a comparable
492 + // window, not "all time").
493 + const trendLabel = `vs prev. ${range === "today" ? "day" : range === "this_year" ? "year" : range.replace("last_", "").replace("_", " ")}`;
494 + const revenueChange = Number((reportData as any)?.revenue_stats?.change ?? 0);
495 + const totalChange = Number(
496 + (reportData as any)?.booking_stats?.totalChange ?? 0,
497 + );
498 + const conversionChange = Number(
499 + (reportData as any)?.booking_stats?.conversionRateChange ?? 0,
500 + );
501 + const hasPeriodData = range !== "all_time" && !!reportData;
502 +
503 + // Navigate to a sub-page on the admin SPA.
504 + const goTo = (subpage: string, extras: Record<string, string> = {}) => {
505 + const admin = (window as any)?.yatraAdmin;
506 + const baseUrl = admin?.siteUrl || "";
507 + const qs = Object.entries(extras)
508 + .map(([k, v]) => `&${k}=${encodeURIComponent(v)}`)
509 + .join("");
510 + window.location.href = `${baseUrl}/wp-admin/admin.php?page=yatra&subpage=${subpage}${qs}`;
511 + };
512 +
513 + // Non-admin team members get the focused, role-filtered dashboard.
514 + // Placed after every hook call above so hook order stays stable
515 + // across renders (see header comment).
516 + if (!isWpAdmin) {
517 + return <RoleDashboard />;
518 + }
519 +
520 + if (bookingStatsLoading) {
362 521 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 />
522 + <div className="space-y-4">
523 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
524 + {Array.from({ length: 5 }).map((_, i) => (
525 + <SkeletonStatCard key={i} />
526 + ))}
370 527 </div>
371 -
372 - {/* Skeleton for Quick Stats */}
373 528 <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>
529 + <CardContent className="p-6">
530 + <Skeleton className="h-64 w-full" />
384 531 </CardContent>
385 532 </Card>
533 + </div>
534 + );
535 + }
386 536
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>
537 + return (
538 + <div className="space-y-4">
539 + {/* ── HEADER ──────────────────────────────────────────────────── */}
540 + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
541 + <div>
542 + <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
543 + {__("Dashboard", "yatra")}
544 + </h1>
545 + <p className="text-xs text-gray-500 dark:text-gray-400">
546 + {__("Operations overview at a glance.", "yatra")}
547 + </p>
405 548 </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>
549 + <div className="flex items-center gap-2">
550 + {/* Date range */}
551 + <select
552 + value={range}
553 + onChange={(e) => setRange(e.target.value as DashboardRange)}
554 + className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
555 + aria-label={__("Date range", "yatra")}
556 + >
557 + {(() => {
558 + const labels = rangeLabels();
559 + return (Object.keys(labels) as DashboardRange[]).map((k) => (
560 + <option key={k} value={k}>
561 + {labels[k]}
562 + </option>
563 + ));
564 + })()}
565 + </select>
566 + {/* Primary action — most common task on a travel dashboard
567 + is "I want to add a new trip". Surface it as a primary
568 + button so it's always one click away. */}
569 + <Button
570 + type="button"
571 + onClick={() => goTo("trips", { action: "add" })}
572 + className="inline-flex items-center gap-1"
573 + >
574 + <Plus className="h-4 w-4" />
575 + {__("Add Trip", "yatra")}
576 + </Button>
445 577 </div>
446 578 </div>
447 - );
448 - }
449 579
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>
580 + {/* ── AI BRIEF (renders nothing when module disabled) ─────────── */}
581 + <TodaysBriefCard />
582 +
583 + {/* ── WELCOME (dismissible) ──────────────────────────────────── */}
584 + {!welcomeDismissed && (
585 + <Card className="border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 dark:border-blue-800 dark:from-blue-900/20 dark:to-indigo-900/20">
586 + <CardContent className="p-4">
587 + <div className="flex items-start justify-between gap-3">
588 + <div className="flex items-start gap-3">
589 + <Activity className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
590 + <div>
591 + <h3 className="mb-1 font-semibold text-gray-900 dark:text-white">
592 + {__("Welcome to Yatra", "yatra")}
593 + </h3>
594 + <p className="text-sm text-gray-600 dark:text-gray-300">
595 + {__(
596 + "Real-time insights for your travel business. Monitor performance, track bookings, and manage operations from one place.",
597 + "yatra",
598 + )}
599 + </p>
600 + </div>
468 601 </div>
602 + <button
603 + type="button"
604 + onClick={dismissWelcome}
605 + className="rounded-md p-1 text-gray-500 transition-colors hover:bg-blue-100 hover:text-gray-700 dark:hover:bg-blue-900/30 dark:hover:text-gray-200"
606 + aria-label={__("Dismiss welcome message", "yatra")}
607 + >
608 + <X className="h-4 w-4" />
609 + </button>
469 610 </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>
611 + </CardContent>
612 + </Card>
613 + )}
477 614
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">
615 + {/* ── KPI STRIP ───────────────────────────────────────────────── */}
616 + <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-5">
617 + <ConditionalRender capability="yatra_view_trips">
481 618 <StatCard
482 619 title={__("Total Trips", "yatra")}
483 620 value={tripsSummary?.total || 0}
484 621 icon={MapPin}
485 622 color="blue"
486 - loading={isLoading}
623 + loading={!tripsSummary}
624 + onClick={() => goTo("trips")}
487 625 />
488 - </div>
626 + </ConditionalRender>
489 627
490 - <div className="flex-1 min-w-0">
628 + <ConditionalRender capability="yatra_view_bookings">
491 629 <StatCard
492 630 title={__("Total Bookings", "yatra")}
493 631 value={bookingStats?.total || 0}
494 632 icon={Calendar}
495 633 color="green"
496 - loading={isLoading}
634 + loading={bookingStatsLoading}
635 + trend={
636 + hasPeriodData
637 + ? {
638 + value: totalChange,
639 + isPositive: totalChange >= 0,
640 + label: trendLabel,
641 + }
642 + : undefined
643 + }
644 + onClick={() => goTo("bookings")}
497 645 />
498 - </div>
646 + </ConditionalRender>
499 647
500 - <div className="flex-1 min-w-0">
648 + <ConditionalRender capability="yatra_view_financial_reports">
501 649 <StatCard
502 650 title={__("Booked Revenue", "yatra")}
503 651 value={formatCurrencyAmount(bookingStats?.total_revenue || 0)}
504 652 icon={DollarSign}
505 653 color="purple"
506 - loading={isLoading}
654 + loading={bookingStatsLoading}
655 + tooltip={__(
656 + "Total value of confirmed bookings — the gross revenue you're entitled to. Not yet net of cancellations or refunds.",
657 + "yatra",
658 + )}
659 + trend={
660 + hasPeriodData
661 + ? {
662 + value: revenueChange,
663 + isPositive: revenueChange >= 0,
664 + label: trendLabel,
665 + }
666 + : undefined
667 + }
668 + onClick={() => goTo("payments")}
507 669 />
508 - </div>
670 + </ConditionalRender>
509 671
510 - <div className="flex-1 min-w-0">
672 + <ConditionalRender capability="yatra_view_financial_reports">
511 673 <StatCard
512 674 title={__("Collected Revenue", "yatra")}
513 675 value={formatCurrencyAmount(bookingStats?.total_collected || 0)}
514 676 icon={DollarSign}
515 677 color="green"
516 - loading={isLoading}
678 + loading={bookingStatsLoading}
679 + tooltip={__(
680 + "Cash already received — what's actually in your accounts. The gap between Booked and Collected is your accounts-receivable (pending payments + scheduled instalments).",
681 + "yatra",
682 + )}
683 + onClick={() => goTo("payments")}
517 684 />
518 - </div>
685 + </ConditionalRender>
519 686
520 - <div className="flex-1 min-w-0">
687 + <ConditionalRender capability="yatra_view_customers">
521 688 <StatCard
522 689 title={__("Total Customers", "yatra")}
523 690 value={customersSummary?.total || 0}
524 691 icon={Users}
525 692 color="orange"
526 - loading={isLoading}
693 + loading={!customersSummary}
694 + onClick={() => goTo("customers")}
527 695 />
528 - </div>
696 + </ConditionalRender>
697 + </div>
529 698
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 - />
699 + {/* ── SECONDARY KPI ROW (period-aware) ────────────────────────── */}
700 + {/* These are operational + revenue indicators. Wrap each card in
701 + * the appropriate cap — revenue-flavoured for finance roles
702 + * (Accountant), conversion / occupancy / cancellation for
703 + * operational roles (Sales Agent / Manager / Marketing). */}
704 + {hasPeriodData && (
705 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
706 + <ConditionalRender capability="yatra_view_operational_reports">
707 + <StatCard
708 + title={__("Conversion Rate", "yatra")}
709 + value={`${Number((reportData as any)?.booking_stats?.conversionRate || 0).toFixed(1)}%`}
710 + icon={TrendingUp}
711 + color="green"
712 + tooltip={__(
713 + "Share of bookings in this period that landed in a confirmed or completed state. Higher is better.",
714 + "yatra",
715 + )}
716 + trend={{
717 + value: conversionChange,
718 + isPositive: conversionChange >= 0,
719 + label: trendLabel,
720 + }}
721 + />
722 + </ConditionalRender>
723 + <ConditionalRender capability="yatra_view_financial_reports">
724 + <StatCard
725 + title={__("Avg Booking Value", "yatra")}
726 + value={formatCurrencyAmount(
727 + Number((reportData as any)?.revenue_stats?.average || 0),
728 + )}
729 + icon={DollarSign}
730 + color="purple"
731 + tooltip={__(
732 + "Mean revenue per booking in this period. A rising AOV with stable booking count is the cleanest growth signal.",
733 + "yatra",
734 + )}
735 + />
736 + </ConditionalRender>
737 + <ConditionalRender capability="yatra_view_operational_reports">
738 + <StatCard
739 + title={__("Occupancy Rate", "yatra")}
740 + value={`${Number((reportData as any)?.operational_stats?.occupancyRate || 0).toFixed(1)}%`}
741 + icon={Plane}
742 + color="blue"
743 + tooltip={__(
744 + "Booked seats / total seats across upcoming departures. Capacity utilisation indicator.",
745 + "yatra",
746 + )}
747 + />
748 + </ConditionalRender>
749 + <ConditionalRender capability="yatra_view_operational_reports">
750 + <StatCard
751 + title={__("Cancellation Rate", "yatra")}
752 + value={`${Number((reportData as any)?.booking_stats?.cancellationRate || 0).toFixed(1)}%`}
753 + icon={CheckCircle}
754 + color="red"
755 + tooltip={__(
756 + "Share of bookings that ended cancelled. Watch the trend — a spike usually points at a specific trip or timing issue.",
757 + "yatra",
758 + )}
759 + />
760 + </ConditionalRender>
538 761 </div>
762 + )}
539 763
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 */}
764 + {/* ── MAIN GRID: charts (left) + widgets (right) ──────────────── */}
765 + <div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
766 + <div className="space-y-3 lg:col-span-7">
556 767 <ConditionalRender capability="yatra_view_bookings">
557 768 <Card>
558 769 <CardHeader>
559 770 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
@@ -559,9 +770,9 @@
559 770 <CardTitle>{__("Bookings Overview", "yatra")}</CardTitle>
560 771 </CardHeader>
561 772 <CardContent className="pb-2">
562 773 <BookingsOverviewChart
563 - data={bookingsData || []}
774 + data={bookingsChartData || []}
564 775 currency={defaultCurrency}
565 776 currencyPosition={currencyPosition}
566 777 currencyDecimals={currencyDecimals}
567 778 />
@@ -568,10 +779,9 @@
568 779 </CardContent>
569 780 </Card>
570 781 </ConditionalRender>
571 782
572 - {/* Booking Status and Popular Destinations - Side by Side */}
573 - <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
783 + <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
574 784 <ConditionalRender capability="yatra_view_bookings">
575 785 <Card>
576 786 <CardHeader>
577 787 <CardTitle>{__("Booking Status", "yatra")}</CardTitle>
@@ -581,142 +791,54 @@
581 791 </CardContent>
582 792 </Card>
583 793 </ConditionalRender>
584 794
585 - <ConditionalRender capability="yatra_view_trips">
795 + <ConditionalRender capability="yatra_view_operational_reports">
586 796 <Card>
587 797 <CardHeader>
588 798 <CardTitle>{__("Popular Destinations", "yatra")}</CardTitle>
589 799 </CardHeader>
590 800 <CardContent className="pb-2">
591 - <SimpleBarChart
592 - data={destinationsData || []}
593 - title=""
594 - height={180}
595 - showValues={true}
596 - />
801 + {destinationsData && destinationsData.length > 0 ? (
802 + <SimpleBarChart
803 + data={destinationsData || []}
804 + title=""
805 + height={180}
806 + showValues={true}
807 + />
808 + ) : (
809 + <div className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
810 + {__("No destination data yet.", "yatra")}
811 + </div>
812 + )}
597 813 </CardContent>
598 814 </Card>
599 815 </ConditionalRender>
600 816 </div>
601 817
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 818 <ConditionalRender capability="yatra_view_bookings">
627 819 <RecentBookings
628 820 bookings={recentBookings || []}
629 - loading={isLoading}
821 + loading={bookingStatsLoading}
630 822 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}`;
823 + goTo("bookings", { action: "view", id: String(booking.id) });
634 824 }}
635 825 />
636 826 </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 827 </div>
705 828
706 - {/* Right Column - Widgets (5 columns) */}
707 - <div className="lg:col-span-5 space-y-3">
708 - <ConditionalRender capability="yatra_view_trips">
829 + <div className="space-y-3 lg:col-span-5">
830 + <ConditionalRender capability="yatra_view_departures">
709 831 <UpcomingDepartures
710 832 departures={departures || []}
711 - loading={isLoading}
833 + loading={bookingStatsLoading}
712 834 />
713 835 </ConditionalRender>
714 836
715 - <ConditionalRender capability="yatra_view_bookings">
837 + <ConditionalRender capability="yatra_view_financial_reports">
716 838 <PendingPayments
717 839 payments={pendingPayments || []}
718 - loading={isLoading}
840 + loading={bookingStatsLoading}
719 841 />
720 842 </ConditionalRender>
721 843 </div>
722 844 </div>