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 / RoleDashboard.tsx

RoleDashboard.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/RoleDashboard.tsx

507 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Role-aware Dashboard for non-administrator team members.
3 *
4 * The main Dashboard at `/pages/Dashboard.tsx` is built around the
5 * assumption that the operator can see everything — every KPI card,
6 * every chart, every widget. For a granular role like Accountant,
7 * Sales Agent, or Guide, that assumption breaks down: most of the
8 * widgets render empty (no cap → nothing) and the page looks like
9 * a half-loaded skeleton.
10 *
11 * This component takes the opposite approach: instead of starting
12 * from the full layout and hiding what the user can't see, it starts
13 * from the role's caps and builds a compact, focused dashboard
14 * tailored to what that role actually does day-to-day.
15 *
16 * Routing:
17 * `Dashboard.tsx` detects `!window.yatraAdmin.isWpAdmin` and
18 * delegates to this component. WP administrators always get the
19 * full Dashboard (they should see everything they own).
20 *
21 * Layout philosophy:
22 * - Welcome hero with the user's name + their role label + a clear
23 * "this is what you can do" framing.
24 * - 2–3 high-signal KPIs sized for the role's day-to-day decisions.
25 * - Two-column main area: a recent-activity widget the role cares
26 * about most + a secondary widget.
27 * - Quick-action buttons that link to the pages the role can access.
28 *
29 * No upsell, no "you don't have access to this" messages — the
30 * Yatra menu already cap-gates pages, the API already cap-gates
31 * mutations. This page just tells the user what they CAN do, not
32 * what they can't.
33 *
34 * @since 3.5.0
35 */
36
37 import React, { useMemo } from "react";
38 import { useQuery } from "@tanstack/react-query";
39 import {
40 Calendar,
41 DollarSign,
42 Users,
43 Plane,
44 TrendingUp,
45 CheckCircle,
46 Mail,
47 MessageSquare,
48 CreditCard,
49 BarChart3,
50 RotateCcw,
51 Shield,
52 ArrowRight,
53 } from "lucide-react";
54 import { __, sprintf, brandName } from "../lib/i18n";
55 import { StatCard } from "../components/common/StatCard";
56 import {
57 Card,
58 CardContent,
59 CardHeader,
60 CardTitle,
61 CardDescription,
62 } from "../components/ui/card";
63 import { Button } from "../components/ui/button";
64 import { Badge } from "../components/ui/badge";
65 import { Skeleton } from "../components/ui/skeleton";
66 import { RecentBookings } from "../components/dashboard/RecentBookings";
67 import { UpcomingDepartures } from "../components/dashboard/UpcomingDepartures";
68 import { PendingPayments } from "../components/dashboard/PendingPayments";
69 import { apiClient } from "../lib/api-client";
70 import {
71 formatYatraMoney,
72 readYatraCurrencyPositionFromWindow,
73 } from "../lib/currency-display";
74 import { canCap } from "../hooks/useCapabilities";
75 import { navigateMenu } from "../hooks/useNavigate";
76
77 /**
78 * Resolve the user's primary Yatra role label from window.yatraAdmin.
79 * Looks at `roles` (array of WP role slugs) for any `yatra_*` entry,
80 * then maps to a human label.
81 */
82 function getPrimaryRoleLabel(): { slug: string; label: string } {
83 const roles = (window.yatraAdmin as { roles?: string[] } | undefined)?.roles;
84 if (!Array.isArray(roles))
85 return { slug: "", label: __("Team member", "yatra") };
86
87 // Yatra system roles, in priority order — first match wins. Display
88 // labels mirror RoleProvisioner::roleLabels() so the front-end UX
89 // stays consistent with the server-side role bundle.
90 const systemRoles: Array<{ slug: string; label: string }> = [
91 { slug: "yatra_owner", label: __("Owner", "yatra") },
92 { slug: "yatra_manager", label: __("Manager", "yatra") },
93 { slug: "yatra_sales_agent", label: __("Sales Agent", "yatra") },
94 { slug: "yatra_front_desk", label: __("Front Desk", "yatra") },
95 { slug: "yatra_guide", label: __("Guide", "yatra") },
96 { slug: "yatra_accountant", label: __("Accountant", "yatra") },
97 { slug: "yatra_marketing", label: __("Marketing", "yatra") },
98 { slug: "yatra_auditor", label: __("Auditor", "yatra") },
99 ];
100 for (const candidate of systemRoles) {
101 if (roles.includes(candidate.slug)) return candidate;
102 }
103 // Custom yatra_* role — humanize the slug.
104 const custom = roles.find((r) => r.startsWith("yatra_"));
105 if (custom) {
106 const label = custom
107 .replace(/^yatra_/, "")
108 .replace(/_/g, " ")
109 .replace(/\b\w/g, (c) => c.toUpperCase());
110 return { slug: custom, label };
111 }
112 return { slug: "", label: __("Team member", "yatra") };
113 }
114
115 const RoleDashboard: React.FC = () => {
116 const role = useMemo(() => getPrimaryRoleLabel(), []);
117 const displayName =
118 (window.yatraAdmin as { currentUserDisplayName?: string } | undefined)
119 ?.currentUserDisplayName || __("there", "yatra");
120
121 const defaultCurrency =
122 (window as { yatraAdmin?: { currency?: string } })?.yatraAdmin?.currency ||
123 "USD";
124 const currencyPosition = readYatraCurrencyPositionFromWindow();
125 const currencyDecimalsRaw = window.yatraAdmin as
126 | { decimalPlaces?: number; currency_decimals?: number }
127 | undefined;
128 const currencyDecimals = Number.isFinite(
129 Number(
130 currencyDecimalsRaw?.decimalPlaces ??
131 currencyDecimalsRaw?.currency_decimals,
132 ),
133 )
134 ? Number(
135 currencyDecimalsRaw?.decimalPlaces ??
136 currencyDecimalsRaw?.currency_decimals,
137 )
138 : 2;
139
140 const formatCurrencyAmount = (amount: number) =>
141 formatYatraMoney(Number(amount) || 0, defaultCurrency, {
142 zeroAsUnknown: false,
143 });
144
145 // --- Cap-gated queries -------------------------------------------------
146 // Only fetch what the user can actually see. `enabled` skips the
147 // request when the cap isn't held, so we never spam the API with
148 // requests that would 403 anyway.
149
150 const { data: bookingStats, isLoading: bookingStatsLoading } = useQuery({
151 queryKey: ["role-dashboard-booking-stats"],
152 queryFn: async () => {
153 const response = await apiClient.get("/bookings/stats");
154 return response?.data ?? response ?? {};
155 },
156 enabled: canCap("yatra_view_bookings"),
157 });
158
159 const { data: reportData } = useQuery({
160 queryKey: ["role-dashboard-reports"],
161 queryFn: async () => {
162 const response = await apiClient.get("/reports");
163 return response?.data ?? response ?? {};
164 },
165 enabled:
166 canCap("yatra_view_financial_reports") ||
167 canCap("yatra_view_operational_reports"),
168 staleTime: 60_000,
169 });
170
171 const recentBookings = useMemo(
172 () =>
173 (bookingStats as { recent_bookings?: any[] } | undefined)
174 ?.recent_bookings || [],
175 [bookingStats],
176 );
177
178 const { data: departures } = useQuery({
179 queryKey: ["role-dashboard-departures"],
180 queryFn: async () => {
181 const response = await apiClient.get("/departures/upcoming");
182 return response?.data ?? response ?? [];
183 },
184 enabled: canCap("yatra_view_departures"),
185 });
186
187 const { data: pendingPayments } = useQuery({
188 queryKey: ["role-dashboard-pending-payments"],
189 queryFn: async () => {
190 const response = await apiClient.get("/payments/pending");
191 return response?.data ?? response ?? [];
192 },
193 enabled: canCap("yatra_view_financial_reports"),
194 });
195
196 // --- Quick actions ------------------------------------------------------
197 // Each action declares the cap it needs. Filtered at render time.
198 const quickActions: Array<{
199 label: string;
200 icon: React.ComponentType<{ className?: string }>;
201 cap: string;
202 onClick: () => void;
203 }> = [
204 {
205 label: __("Bookings", "yatra"),
206 icon: Calendar,
207 cap: "yatra_view_bookings",
208 onClick: () => navigateMenu("bookings"),
209 },
210 {
211 label: __("Enquiries", "yatra"),
212 icon: MessageSquare,
213 cap: "yatra_view_enquiries",
214 onClick: () => navigateMenu("enquiries"),
215 },
216 {
217 label: __("Departures", "yatra"),
218 icon: Plane,
219 cap: "yatra_view_departures",
220 onClick: () => navigateMenu("departures"),
221 },
222 {
223 label: __("Customers", "yatra"),
224 icon: Users,
225 cap: "yatra_view_customers",
226 onClick: () => navigateMenu("customers"),
227 },
228 {
229 label: __("Payments", "yatra"),
230 icon: CreditCard,
231 cap: "yatra_view_financial_reports",
232 onClick: () => navigateMenu("payments"),
233 },
234 {
235 label: __("Reports", "yatra"),
236 icon: BarChart3,
237 cap: "yatra_view_operational_reports",
238 onClick: () => navigateMenu("reports"),
239 },
240 {
241 label: __("Email Campaigns", "yatra"),
242 icon: Mail,
243 cap: "yatra_manage_email_automation",
244 onClick: () => navigateMenu("email-automation"),
245 },
246 {
247 label: __("Audit Log", "yatra"),
248 icon: Shield,
249 cap: "yatra_view_audit_log",
250 onClick: () => navigateMenu("team", { tab: "audit" }),
251 },
252 ];
253 const visibleActions = quickActions.filter((a) => canCap(a.cap));
254
255 // --- Stat cards --------------------------------------------------------
256 // Same cap-gating pattern. Each card declares which cap it needs and
257 // we render only the ones the user can see. Layout adapts: 2-col on
258 // small viewports, 3-col on large.
259
260 const showBookingsCount = canCap("yatra_view_bookings");
261 const showBookedRevenue = canCap("yatra_view_financial_reports");
262 const showCollectedRevenue = canCap("yatra_view_financial_reports");
263 const showConversion = canCap("yatra_view_operational_reports");
264 const showOccupancy = canCap("yatra_view_operational_reports");
265
266 return (
267 <div className="space-y-5">
268 {/* HERO — role-aware welcome */}
269 <Card className="border-blue-200 bg-gradient-to-br from-blue-50 via-white to-indigo-50 dark:border-blue-800 dark:from-blue-900/20 dark:via-gray-900 dark:to-indigo-900/20">
270 <CardContent className="p-5">
271 <div className="flex items-start justify-between gap-3 flex-wrap">
272 <div className="flex items-start gap-3 min-w-0">
273 <div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center flex-shrink-0">
274 <Shield className="w-6 h-6 text-blue-600 dark:text-blue-400" />
275 </div>
276 <div className="min-w-0">
277 <h1 className="text-xl font-semibold text-gray-900 dark:text-white">
278 {__("Welcome back,", "yatra")} {displayName}.
279 </h1>
280 <p className="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
281 {__("You're signed in as", "yatra")}{" "}
282 <Badge className="bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 ml-1">
283 {role.label}
284 </Badge>
285 <span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
286 {__(
287 "Your dashboard is tailored to the surfaces your role can access.",
288 "yatra",
289 )}
290 </span>
291 </p>
292 </div>
293 </div>
294 </div>
295 </CardContent>
296 </Card>
297
298 {/* QUICK ACTIONS — link grid to pages the user can access */}
299 {visibleActions.length > 0 && (
300 <Card>
301 <CardHeader className="pb-3">
302 <CardTitle className="text-base">
303 {__("Quick actions", "yatra")}
304 </CardTitle>
305 <CardDescription>
306 {__(
307 "Jump straight to the surfaces you use most. Filtered to what your role can open.",
308 "yatra",
309 )}
310 </CardDescription>
311 </CardHeader>
312 <CardContent>
313 <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
314 {visibleActions.map((action) => {
315 const Icon = action.icon;
316 return (
317 <button
318 key={action.label}
319 type="button"
320 onClick={action.onClick}
321 className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:bg-blue-50/40 dark:hover:border-blue-700 dark:hover:bg-blue-900/10 transition-colors text-left group"
322 >
323 <div className="w-9 h-9 rounded-md bg-gray-100 dark:bg-gray-800 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30 flex items-center justify-center flex-shrink-0 transition-colors">
324 <Icon className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400" />
325 </div>
326 <span className="text-sm font-medium text-gray-900 dark:text-white truncate">
327 {action.label}
328 </span>
329 <ArrowRight className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 ml-auto flex-shrink-0" />
330 </button>
331 );
332 })}
333 </div>
334 </CardContent>
335 </Card>
336 )}
337
338 {/* KPI strip — adaptive grid based on how many cards the role can see */}
339 {(showBookingsCount ||
340 showBookedRevenue ||
341 showCollectedRevenue ||
342 showConversion ||
343 showOccupancy) && (
344 <div
345 className={`grid gap-3 ${
346 [
347 showBookingsCount,
348 showBookedRevenue,
349 showCollectedRevenue,
350 showConversion,
351 showOccupancy,
352 ].filter(Boolean).length >= 4
353 ? "grid-cols-2 md:grid-cols-3 lg:grid-cols-5"
354 : "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
355 }`}
356 >
357 {showBookingsCount && (
358 <StatCard
359 title={__("Bookings", "yatra")}
360 value={
361 (bookingStats as { total?: number } | undefined)?.total ?? 0
362 }
363 icon={Calendar}
364 color="green"
365 loading={bookingStatsLoading}
366 onClick={() => navigateMenu("bookings")}
367 />
368 )}
369 {showBookedRevenue && (
370 <StatCard
371 title={__("Booked Revenue", "yatra")}
372 value={formatCurrencyAmount(
373 (bookingStats as { total_revenue?: number } | undefined)
374 ?.total_revenue || 0,
375 )}
376 icon={DollarSign}
377 color="purple"
378 loading={bookingStatsLoading}
379 tooltip={__(
380 "Total value of confirmed bookings — gross, before refunds.",
381 "yatra",
382 )}
383 onClick={() => navigateMenu("payments")}
384 />
385 )}
386 {showCollectedRevenue && (
387 <StatCard
388 title={__("Collected Revenue", "yatra")}
389 value={formatCurrencyAmount(
390 (bookingStats as { total_collected?: number } | undefined)
391 ?.total_collected || 0,
392 )}
393 icon={DollarSign}
394 color="green"
395 loading={bookingStatsLoading}
396 tooltip={__(
397 "Cash already received — actually in your accounts.",
398 "yatra",
399 )}
400 onClick={() => navigateMenu("payments")}
401 />
402 )}
403 {showConversion && (
404 <StatCard
405 title={__("Conversion", "yatra")}
406 value={`${Number(
407 (
408 reportData as
409 | { booking_stats?: { conversionRate?: number } }
410 | undefined
411 )?.booking_stats?.conversionRate || 0,
412 ).toFixed(1)}%`}
413 icon={TrendingUp}
414 color="blue"
415 tooltip={__(
416 "Share of bookings that landed in confirmed or completed.",
417 "yatra",
418 )}
419 />
420 )}
421 {showOccupancy && (
422 <StatCard
423 title={__("Occupancy", "yatra")}
424 value={`${Number(
425 (
426 reportData as
427 | { operational_stats?: { occupancyRate?: number } }
428 | undefined
429 )?.operational_stats?.occupancyRate || 0,
430 ).toFixed(1)}%`}
431 icon={CheckCircle}
432 color="orange"
433 tooltip={__(
434 "Booked seats vs total seats across upcoming departures.",
435 "yatra",
436 )}
437 />
438 )}
439 </div>
440 )}
441
442 {/* Main two-column area — only render columns with visible widgets */}
443 <div className="grid grid-cols-1 lg:grid-cols-12 gap-3">
444 <div className="space-y-3 lg:col-span-7">
445 {canCap("yatra_view_bookings") &&
446 (bookingStatsLoading ? (
447 <Card>
448 <CardContent className="p-4">
449 <Skeleton className="h-40 w-full" />
450 </CardContent>
451 </Card>
452 ) : (
453 <RecentBookings
454 bookings={recentBookings || []}
455 loading={bookingStatsLoading}
456 onView={(booking) =>
457 navigateMenu("bookings", {
458 action: "view",
459 id: String(booking.id),
460 })
461 }
462 />
463 ))}
464 </div>
465
466 <div className="space-y-3 lg:col-span-5">
467 {canCap("yatra_view_departures") && (
468 <UpcomingDepartures departures={departures || []} loading={false} />
469 )}
470 {canCap("yatra_view_financial_reports") && (
471 <PendingPayments payments={pendingPayments || []} loading={false} />
472 )}
473 </div>
474 </div>
475
476 {/* Empty state — when the role can't see any widget at all (rare,
477 * e.g. Marketing with no booking access). Fall back to a helpful
478 * pointer rather than an empty page. */}
479 {visibleActions.length === 0 &&
480 !showBookingsCount &&
481 !showBookedRevenue &&
482 !showConversion && (
483 <Card className="border-dashed">
484 <CardContent className="p-8 text-center">
485 <RotateCcw className="w-8 h-8 text-gray-300 mx-auto mb-3" />
486 <h3 className="text-base font-semibold text-gray-900 dark:text-white">
487 {__("Your role doesn't expose dashboard widgets yet.", "yatra")}
488 </h3>
489 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-md mx-auto">
490 {sprintf(
491 /* translators: %s: brand name */
492 __(
493 "Talk to your account owner — they can grant additional capabilities to your user from %s → Team & Access.",
494 "yatra",
495 ),
496 brandName(),
497 )}
498 </p>
499 </CardContent>
500 </Card>
501 )}
502 </div>
503 );
504 };
505
506 export default RoleDashboard;
507