PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
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 / Customers.tsx

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

911 lines 27.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Customers Page
3 * Clean, minimal SaaS-style customers management page with dynamic data
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 ArrowUpDown,
11 ArrowDown,
12 ArrowUp,
13 Eye,
14 Edit,
15 Trash2,
16 MapPin,
17 Phone,
18 Check,
19 PauseCircle,
20 ShieldX,
21 } from "lucide-react";
22 import {
23 Pagination,
24 SearchFilterToolbar,
25 BulkActionToolbar,
26 Table as SharedTable,
27 } from "../components/shared";
28 import { __ } from "../lib/i18n";
29 import { apiService } from "../lib/api-client";
30 import { getErrorContext } from "../lib/errors";
31 import { usePermissions } from "../hooks/usePermissions";
32 import { useToast } from "../components/ui/toast";
33 import { Button } from "../components/ui/button";
34 import { Select } from "../components/ui/select";
35 import { PageHeader } from "../components/common/PageHeader";
36 import { Card, CardContent } from "../components/ui/card";
37 import { ConditionalRender } from "../components/ui/conditional-render";
38 import { Skeleton } from "../components/ui/skeleton";
39 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
40 import {
41 Table,
42 TableBody,
43 TableCell,
44 TableHead,
45 TableHeader,
46 TableRow,
47 } from "../components/ui/table";
48 import { getCurrencySymbol, getCurrency } from "../data/currencies";
49 import { formatDate as formatDateUtil } from "../lib/dateFormat";
50
51 interface Customer {
52 id: number;
53 user_id: number;
54 first_name: string;
55 last_name: string;
56 name: string;
57 email: string;
58 phone: string;
59 country: string;
60 city?: string;
61 total_bookings: number;
62 total_spent: number;
63 loyalty_tier: string;
64 status: string;
65 created_at: string;
66 last_booking_date?: string;
67 }
68
69 interface CustomersResponse {
70 data: Customer[];
71 total: number;
72 page: number;
73 per_page: number;
74 pages: number;
75 }
76
77 const Customers: React.FC = () => {
78 const [searchTerm, setSearchTerm] = useState("");
79 const [statusFilter, setStatusFilter] = useState("all");
80 const [loyaltyFilter, setLoyaltyFilter] = useState("all");
81 const [sortBy, setSortBy] = useState("created_at");
82 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
83 const [page, setPage] = useState(1);
84 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
85 const [bulkAction, setBulkAction] = useState("");
86 const [isBulkPending, setIsBulkPending] = useState(false);
87 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
88 const [customerToDelete, setCustomerToDelete] = useState<Customer | null>(
89 null,
90 );
91 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
92 const { can, isPro } = usePermissions();
93 const queryClient = useQueryClient();
94 const { showToast } = useToast();
95 const translations = (window as any)?.yatraAdmin?.translations || {};
96
97 // Helper function for translations
98 const __ = (text: string) => translations[text] || text;
99
100 const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || "";
101
102 const [visibleColumns, setVisibleColumns] = useState(() => {
103 if (typeof window === "undefined") {
104 return {
105 customer: true,
106 location: true,
107 total_bookings: true,
108 total_spent: true,
109 loyalty: true,
110 status: true,
111 created_at: true,
112 };
113 }
114 const saved = window.localStorage.getItem(
115 "yatra-customers-visible-columns",
116 );
117 return saved
118 ? JSON.parse(saved)
119 : {
120 customer: true,
121 location: true,
122 total_bookings: true,
123 total_spent: true,
124 loyalty: true,
125 status: true,
126 created_at: true,
127 };
128 });
129
130 // Build query params
131 const queryParams = useMemo(() => {
132 const params: Record<string, any> = {
133 page,
134 per_page: 10,
135 orderby: sortBy,
136 order: sortOrder,
137 };
138
139 if (searchTerm) {
140 params.search = searchTerm;
141 }
142
143 if (statusFilter !== "all") {
144 params.status = statusFilter;
145 }
146
147 if (loyaltyFilter !== "all") {
148 params.loyalty_tier = loyaltyFilter;
149 }
150
151 return params;
152 }, [searchTerm, statusFilter, loyaltyFilter, sortBy, sortOrder, page]);
153
154 // Fetch customers from API
155 const { data, isLoading, error } = useQuery<CustomersResponse>({
156 queryKey: ["customers", queryParams],
157 queryFn: async () => {
158 const paramsObj: Record<string, any> = {};
159 Object.entries(queryParams).forEach(([key, value]) => {
160 if (value !== undefined && value !== null && value !== "") {
161 paramsObj[key] = value;
162 }
163 });
164
165 return await apiService.getCustomers(paramsObj);
166 },
167 enabled: can("yatra_view_bookings"),
168 });
169
170 // Fetch customer stats (counts)
171 const { data: statsRaw } = useQuery({
172 queryKey: ["customers-stats"],
173 queryFn: async () => {
174 return await apiService.getCustomerStats();
175 },
176 enabled: can("yatra_view_bookings"),
177 });
178 const statsData: any = statsRaw ?? {};
179
180 // Delete mutation
181 const deleteMutation = useMutation({
182 mutationFn: async (id: number) => {
183 await apiService.deleteCustomer(id);
184 },
185 onSuccess: () => {
186 queryClient.invalidateQueries({ queryKey: ["customers"] });
187 setDeleteDialogOpen(false);
188 setCustomerToDelete(null);
189 showToast(__("Customer deleted successfully"), "success");
190 },
191 onError: (error: any) => {
192 showToast(error?.message || __("Failed to delete customer"), "error");
193 },
194 });
195
196 const customers = data?.data || [];
197 const total = data?.total || 0;
198 const totalPages = data?.pages || Math.ceil(total / 10);
199 const errorContext = getErrorContext(error);
200
201 const formatDate = (dateString: string | null | undefined) => {
202 return formatDateUtil(dateString);
203 };
204
205 const formatPrice = (price: number, currencyCode: string = "USD") => {
206 const symbol = getCurrencySymbol(currencyCode);
207 const currencyData = getCurrency(currencyCode);
208 const decimals = currencyData?.decimalDigits ?? 2;
209
210 return `${symbol}${new Intl.NumberFormat(undefined, {
211 minimumFractionDigits: decimals,
212 maximumFractionDigits: decimals,
213 }).format(price)}`;
214 };
215
216 const getStatusBadge = (status: string) => {
217 const statusMap: Record<string, { className: string; label: string }> = {
218 active: {
219 className:
220 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
221 label: __("Active"),
222 },
223 inactive: {
224 className:
225 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
226 label: __("Inactive"),
227 },
228 blocked: {
229 className:
230 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
231 label: __("Blocked"),
232 },
233 };
234
235 const statusInfo = statusMap[status] || {
236 className:
237 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
238 label: status,
239 };
240
241 return (
242 <span
243 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
244 >
245 {statusInfo.label}
246 </span>
247 );
248 };
249
250 const getLoyaltyBadge = (tier: string) => {
251 const tierMap: Record<string, { className: string; icon: string }> = {
252 bronze: {
253 className:
254 "bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400",
255 icon: "🥉",
256 },
257 silver: {
258 className:
259 "bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-300",
260 icon: "🥈",
261 },
262 gold: {
263 className:
264 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
265 icon: "🥇",
266 },
267 platinum: {
268 className:
269 "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400",
270 icon: "💎",
271 },
272 };
273
274 const tierInfo = tierMap[tier] || tierMap["bronze"];
275
276 return (
277 <span
278 className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium ${tierInfo.className}`}
279 >
280 <span>{tierInfo.icon}</span>
281 <span className="capitalize">{tier}</span>
282 </span>
283 );
284 };
285
286 const handleEdit = (customer: Customer) => {
287 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=edit&id=${customer.id}`;
288 };
289
290 const handleDelete = (customer: Customer) => {
291 setCustomerToDelete(customer);
292 setDeleteDialogOpen(true);
293 };
294
295 const confirmDelete = () => {
296 if (customerToDelete) {
297 deleteMutation.mutate(customerToDelete.id);
298 }
299 };
300
301 const handleView = (customer: Customer) => {
302 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=view&id=${customer.id}`;
303 };
304
305 const handleCreateCustomer = () => {
306 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=create`;
307 };
308
309 const handleResetFilters = () => {
310 setSearchTerm("");
311 setStatusFilter("all");
312 setLoyaltyFilter("all");
313 setSortBy("created_at");
314 setSortOrder("desc");
315 setPage(1);
316 };
317
318 const handleSort = (field: string) => {
319 if (sortBy === field) {
320 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
321 } else {
322 setSortBy(field);
323 setSortOrder("desc");
324 }
325 };
326
327 const getSortIcon = (field: string) => {
328 if (sortBy !== field) {
329 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
330 }
331 return sortOrder === "asc" ? (
332 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
333 ) : (
334 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
335 );
336 };
337
338 const hasFilters =
339 searchTerm ||
340 statusFilter !== "all" ||
341 loyaltyFilter !== "all" ||
342 sortBy !== "created_at" ||
343 sortOrder !== "desc";
344
345 const toggleColumn = (key: string) => {
346 const next = {
347 ...visibleColumns,
348 [key]: !visibleColumns[key as keyof typeof visibleColumns],
349 };
350 setVisibleColumns(next);
351 if (typeof window !== "undefined") {
352 window.localStorage.setItem(
353 "yatra-customers-visible-columns",
354 JSON.stringify(next),
355 );
356 }
357 };
358
359 const handleSelectItem = (id: string | number, checked: boolean) => {
360 setSelectedIds((prev) =>
361 checked ? [...prev, id] : prev.filter((v) => v !== id),
362 );
363 };
364
365 const handleSelectAll = (checked: boolean) => {
366 if (checked) {
367 setSelectedIds(customers.map((customer) => customer.id));
368 } else {
369 setSelectedIds([]);
370 }
371 };
372
373 const isAllSelected =
374 customers.length > 0 && selectedIds.length === customers.length;
375
376 const updateStatusForIds = async (
377 ids: (string | number)[],
378 newStatus: string,
379 ) => {
380 await Promise.all(
381 ids.map(async (id) => {
382 await apiService.updateCustomerStatus(id, newStatus);
383 }),
384 );
385 };
386
387 const handleBulkApply = async () => {
388 if (!bulkAction || selectedIds.length === 0) {
389 return;
390 }
391
392 try {
393 setIsBulkPending(true);
394
395 if (bulkAction === "delete") {
396 await Promise.all(
397 selectedIds.map((id) => apiService.deleteCustomer(id)),
398 );
399 showToast(__("Selected customers deleted successfully"), "success");
400 } else {
401 await updateStatusForIds(selectedIds, bulkAction);
402 showToast(__("Customer statuses updated successfully"), "success");
403 }
404
405 queryClient.invalidateQueries({ queryKey: ["customers"] });
406 setSelectedIds([]);
407 setBulkAction("");
408 } catch (error: any) {
409 showToast(
410 error?.message || __("Failed to perform bulk action on customers"),
411 "error",
412 );
413 } finally {
414 setIsBulkPending(false);
415 }
416 };
417
418 const allBulkActionOptions = [
419 { value: "active", label: __("Mark as Active") },
420 { value: "inactive", label: __("Mark as Inactive") },
421 { value: "blocked", label: __("Mark as Blocked") },
422 { value: "delete", label: __("Delete permanently") },
423 ];
424
425 const getBulkActionOptionsForStatus = (view: string) => {
426 switch (view) {
427 case "active":
428 return allBulkActionOptions.filter((opt) =>
429 ["inactive", "blocked", "delete"].includes(opt.value),
430 );
431 case "inactive":
432 return allBulkActionOptions.filter((opt) =>
433 ["active", "blocked", "delete"].includes(opt.value),
434 );
435 case "blocked":
436 return allBulkActionOptions.filter((opt) =>
437 ["active", "inactive", "delete"].includes(opt.value),
438 );
439 default:
440 return allBulkActionOptions;
441 }
442 };
443
444 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
445
446 const statusOptions = [
447 { value: "all", label: __("All Status") },
448 { value: "active", label: __("Active") },
449 { value: "inactive", label: __("Inactive") },
450 { value: "blocked", label: __("Blocked") },
451 ];
452
453 const sortOptions = [
454 { value: "created_at", label: __("Registration Date") },
455 { value: "name", label: __("Name") },
456 { value: "email", label: __("Email") },
457 { value: "country", label: __("Country") },
458 { value: "total_bookings", label: __("Bookings") },
459 { value: "total_spent", label: __("Total Spent") },
460 { value: "status", label: __("Status") },
461 ];
462
463 // Helper functions};
464
465 const columnOptions = [
466 {
467 key: "customer",
468 label: __("Customer"),
469 visible: visibleColumns.customer,
470 },
471 {
472 key: "location",
473 label: __("Location"),
474 visible: visibleColumns.location,
475 },
476 {
477 key: "total_bookings",
478 label: __("Bookings"),
479 visible: visibleColumns.total_bookings,
480 },
481 {
482 key: "total_spent",
483 label: __("Total Spent"),
484 visible: visibleColumns.total_spent,
485 },
486 { key: "loyalty", label: __("Loyalty"), visible: visibleColumns.loyalty },
487 { key: "status", label: __("Status"), visible: visibleColumns.status },
488 {
489 key: "created_at",
490 label: __("Registered"),
491 visible: visibleColumns.created_at,
492 },
493 ];
494
495 const columns = [
496 {
497 key: "customer",
498 label: __("Customer"),
499 sortable: true,
500 visible: visibleColumns.customer,
501 render: (customer: Customer) => (
502 <div>
503 <div className="font-medium text-gray-900 dark:text-white">
504 <a
505 href={`${baseAdminUrl}?page=yatra&subpage=customers&action=view&id=${customer.id}`}
506 className="hover:underline underline-offset-2 text-blue-600 dark:text-blue-400"
507 >
508 {customer.name || `${customer.first_name} ${customer.last_name}`}
509 </a>
510 </div>
511 <div className="text-sm text-gray-500 dark:text-gray-400">
512 {customer.email}
513 </div>
514 {customer.phone && (
515 <div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
516 <Phone className="w-3 h-3" />
517 {customer.phone}
518 </div>
519 )}
520 </div>
521 ),
522 },
523 {
524 key: "location",
525 label: __("Location"),
526 sortable: true,
527 visible: visibleColumns.location,
528 render: (customer: Customer) =>
529 customer.country ? (
530 <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
531 <MapPin className="w-3.5 h-3.5" />
532 <span>
533 {customer.city ? `${customer.city}, ` : ""}
534 {customer.country}
535 </span>
536 </div>
537 ) : (
538 <span className="text-gray-400">-</span>
539 ),
540 },
541 isPro && {
542 key: "total_bookings",
543 label: __("Bookings"),
544 sortable: true,
545 visible: visibleColumns.total_bookings,
546 render: (customer: Customer) => (
547 <span className="text-gray-600 dark:text-gray-400">
548 {customer.total_bookings}
549 </span>
550 ),
551 },
552 isPro && {
553 key: "total_spent",
554 label: __("Total Spent"),
555 sortable: true,
556 visible: visibleColumns.total_spent,
557 render: (customer: Customer) => (
558 <span className="font-medium">{formatPrice(customer.total_spent)}</span>
559 ),
560 },
561 {
562 key: "loyalty",
563 label: __("Loyalty"),
564 sortable: false,
565 visible: visibleColumns.loyalty,
566 render: (customer: Customer) =>
567 getLoyaltyBadge(customer.loyalty_tier || "bronze"),
568 },
569 {
570 key: "status",
571 label: __("Status"),
572 sortable: true,
573 visible: visibleColumns.status,
574 render: (customer: Customer) => getStatusBadge(customer.status),
575 },
576 {
577 key: "created_at",
578 label: __("Registered"),
579 sortable: true,
580 visible: visibleColumns.created_at,
581 render: (customer: Customer) => (
582 <span className="text-gray-500 dark:text-gray-400 text-sm">
583 {formatDate(customer.created_at)}
584 </span>
585 ),
586 },
587 ].filter(Boolean);
588
589 const actions = [
590 {
591 key: "view",
592 label: __("View"),
593 icon: <Eye className="w-4 h-4" />,
594 onClick: (customer: Customer) => handleView(customer),
595 },
596 {
597 key: "edit",
598 label: __("Edit"),
599 icon: <Edit className="w-4 h-4" />,
600 onClick: (customer: Customer) => handleEdit(customer),
601 condition: () => can("yatra_edit_bookings"),
602 },
603 {
604 key: "mark_active",
605 label: __("Mark as Active"),
606 icon: <Check className="w-4 h-4" />,
607 onClick: async (customer: Customer) => {
608 await updateStatusForIds([customer.id], "active");
609 queryClient.invalidateQueries({ queryKey: ["customers"] });
610 },
611 condition: (customer: Customer) =>
612 can("yatra_edit_bookings") && customer.status !== "active",
613 },
614 {
615 key: "mark_inactive",
616 label: __("Mark as Inactive"),
617 icon: <PauseCircle className="w-4 h-4" />,
618 onClick: async (customer: Customer) => {
619 await updateStatusForIds([customer.id], "inactive");
620 queryClient.invalidateQueries({ queryKey: ["customers"] });
621 },
622 condition: (customer: Customer) =>
623 can("yatra_edit_bookings") && customer.status !== "inactive",
624 },
625 {
626 key: "mark_blocked",
627 label: __("Mark as Blocked"),
628 icon: <ShieldX className="w-4 h-4" />,
629 onClick: async (customer: Customer) => {
630 await updateStatusForIds([customer.id], "blocked");
631 queryClient.invalidateQueries({ queryKey: ["customers"] });
632 },
633 condition: (customer: Customer) =>
634 can("yatra_edit_bookings") && customer.status !== "blocked",
635 },
636 {
637 key: "delete",
638 label: __("Delete"),
639 icon: <Trash2 className="w-4 h-4" />,
640 onClick: (customer: Customer) => handleDelete(customer),
641 variant: "destructive" as const,
642 condition: () => can("yatra_delete_bookings"),
643 },
644 ];
645
646 // Skeleton loader
647 const renderSkeleton = () => (
648 <Table>
649 <TableHeader>
650 <TableRow>
651 <TableHead className="w-[250px]">{__("Customer")}</TableHead>
652 <TableHead>{__("Location")}</TableHead>
653 {isPro && <TableHead>{__("Bookings")}</TableHead>}
654 {isPro && <TableHead>{__("Total Spent")}</TableHead>}
655 <TableHead>{__("Loyalty")}</TableHead>
656 <TableHead>{__("Status")}</TableHead>
657 <TableHead>{__("Registered")}</TableHead>
658 <TableHead className="text-right w-[100px]">
659 {__("Actions")}
660 </TableHead>
661 </TableRow>
662 </TableHeader>
663 <TableBody>
664 {[...Array(5)].map((_, i) => (
665 <TableRow key={i}>
666 <TableCell>
667 <div className="space-y-2">
668 <Skeleton className="h-4 w-32" />
669 <Skeleton className="h-3 w-40" />
670 </div>
671 </TableCell>
672 <TableCell>
673 <Skeleton className="h-4 w-24" />
674 </TableCell>
675 {isPro && (
676 <TableCell>
677 <Skeleton className="h-4 w-8" />
678 </TableCell>
679 )}
680 {isPro && (
681 <TableCell>
682 <Skeleton className="h-4 w-16" />
683 </TableCell>
684 )}
685 <TableCell>
686 <Skeleton className="h-6 w-16 rounded" />
687 </TableCell>
688 <TableCell>
689 <Skeleton className="h-6 w-14 rounded" />
690 </TableCell>
691 <TableCell>
692 <Skeleton className="h-4 w-20" />
693 </TableCell>
694 <TableCell>
695 <div className="flex justify-end gap-1">
696 <Skeleton className="h-8 w-8 rounded" />
697 <Skeleton className="h-8 w-8 rounded" />
698 <Skeleton className="h-8 w-8 rounded" />
699 </div>
700 </TableCell>
701 </TableRow>
702 ))}
703 </TableBody>
704 </Table>
705 );
706
707 return (
708 <div className="space-y-3">
709 <PageHeader
710 title={__("Customers")}
711 description={__("Manage your customer database")}
712 actionCapability="yatra_edit_bookings"
713 actions={
714 <Button
715 onClick={handleCreateCustomer}
716 className="flex items-center gap-2"
717 >
718 <Plus className="w-4 h-4" />
719 {__("Add New Customer")}
720 </Button>
721 }
722 />
723
724 <div className="space-y-3">
725 {/* Search and Filters Card */}
726 <ConditionalRender capability="yatra_view_bookings">
727 <Card>
728 <CardContent className="p-4">
729 <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-center">
730 {/* Search & status/sort (handled by toolbar) */}
731 <div className={isPro ? "lg:col-span-8" : "lg:col-span-12"}>
732 <SearchFilterToolbar
733 searchTerm={searchTerm}
734 onSearchChange={setSearchTerm}
735 statusFilter={statusFilter}
736 onStatusChange={(value) => {
737 setStatusFilter(value);
738 setPage(1);
739 }}
740 statusOptions={statusOptions}
741 sortBy={sortBy}
742 onSortByChange={(field) => {
743 setSortBy(field);
744 setPage(1);
745 }}
746 sortOrder={sortOrder}
747 onSortOrderChange={(order) => {
748 setSortOrder(order);
749 setPage(1);
750 }}
751 sortOptions={sortOptions}
752 onResetFilters={handleResetFilters}
753 hasFilters={!!hasFilters}
754 placeholder={__("Search customers...")}
755 />
756 </div>
757
758 {/* Loyalty Filter */}
759 {isPro && (
760 <div className="lg:col-span-4">
761 <Select
762 value={loyaltyFilter}
763 onChange={(e) => {
764 setLoyaltyFilter(e.target.value);
765 setPage(1);
766 }}
767 className="w-full"
768 >
769 <option value="all">{__("All Tiers")}</option>
770 <option value="bronze">{__("Bronze")}</option>
771 <option value="silver">{__("Silver")}</option>
772 <option value="gold">{__("Gold")}</option>
773 <option value="platinum">{__("Platinum")}</option>
774 </Select>
775 </div>
776 )}
777 </div>
778 </CardContent>
779 </Card>
780 </ConditionalRender>
781
782 {/* Bulk Actions Toolbar */}
783 <ConditionalRender capability="yatra_view_bookings">
784 <BulkActionToolbar
785 selectedIds={selectedIds}
786 bulkAction={bulkAction}
787 setBulkAction={setBulkAction}
788 onApply={handleBulkApply}
789 onClearSelection={() => setSelectedIds([])}
790 statusFilter={statusFilter}
791 setStatusFilter={(value) => {
792 setStatusFilter(value);
793 setPage(1);
794 }}
795 statusOptions={[
796 { key: "all", label: __("All"), count: statsData?.all ?? 0 },
797 {
798 key: "active",
799 label: __("Active"),
800 count: statsData?.active ?? 0,
801 },
802 {
803 key: "inactive",
804 label: __("Inactive"),
805 count: statsData?.inactive ?? 0,
806 },
807 {
808 key: "blocked",
809 label: __("Blocked"),
810 count: statsData?.blocked ?? 0,
811 },
812 ]}
813 showColumnsDropdown={showColumnsDropdown}
814 setShowColumnsDropdown={setShowColumnsDropdown}
815 columnOptions={columnOptions}
816 onToggleColumn={toggleColumn}
817 bulkMutationPending={isBulkPending}
818 totalItems={total}
819 bulkActionOptions={bulkActionOptions}
820 />
821 </ConditionalRender>
822
823 {/* Customers Table Card */}
824 <ConditionalRender capability="yatra_view_bookings">
825 <Card>
826 <CardContent className="p-0">
827 {isLoading ? (
828 renderSkeleton()
829 ) : (
830 <SharedTable
831 data={customers}
832 columns={columns as any}
833 actions={actions as any}
834 isLoading={isLoading}
835 isError={!!error}
836 errorText={__("Error loading customers")}
837 errorDetails={errorContext.details}
838 errorRequestInfo={errorContext.requestInfo}
839 errorDescription={__(
840 "We couldn’t connect to the customers service. Please refresh or try again shortly.",
841 )}
842 onRetry={() =>
843 queryClient.invalidateQueries({ queryKey: ["customers"] })
844 }
845 emptyText={__("No customers found")}
846 emptyDescription={
847 hasFilters
848 ? __("Try adjusting your filters to see more results.")
849 : __("Customers will appear here when bookings are made")
850 }
851 onCreateClick={
852 can("yatra_edit_bookings")
853 ? handleCreateCustomer
854 : undefined
855 }
856 onSort={handleSort}
857 getSortIcon={getSortIcon}
858 selectedItemIds={selectedIds}
859 onSelectItem={handleSelectItem}
860 onSelectAll={handleSelectAll}
861 isAllSelected={isAllSelected}
862 getItemId={(customer: Customer) => customer.id}
863 skeletonRows={5}
864 capability="yatra_view_bookings"
865 />
866 )}
867 </CardContent>
868 </Card>
869
870 {/* Pagination */}
871 {total > 0 && (
872 <div className="mt-4">
873 <Pagination
874 currentPage={page}
875 totalPages={totalPages}
876 totalItems={total}
877 itemsPerPage={10}
878 onPageChange={(newPage) => setPage(newPage)}
879 itemName={__("customers")}
880 />
881 </div>
882 )}
883 </ConditionalRender>
884 </div>
885
886 {/* Delete Confirmation Dialog */}
887 <ConfirmationDialog
888 isOpen={deleteDialogOpen}
889 onClose={() => setDeleteDialogOpen(false)}
890 onConfirm={confirmDelete}
891 title={__("Delete Customer")}
892 description={
893 customerToDelete
894 ? __(
895 "Are you sure you want to delete this customer? This action cannot be undone.",
896 ) + ` (${customerToDelete.name || customerToDelete.email})`
897 : __(
898 "Are you sure you want to delete this customer? This action cannot be undone.",
899 )
900 }
901 confirmText={__("Delete")}
902 cancelText={__("Cancel")}
903 variant="danger"
904 isLoading={deleteMutation.isPending}
905 />
906 </div>
907 );
908 };
909
910 export default Customers;
911