PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at resources/js/pages/Customers.tsx

903 lines 27.3 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 { formatYatraMoney } from "../lib/currency-display";
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 formatYatraMoney(Number(price) || 0, currencyCode, { zeroAsUnknown: false });
207
208 const getStatusBadge = (status: string) => {
209 const statusMap: Record<string, { className: string; label: string }> = {
210 active: {
211 className:
212 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
213 label: __("Active"),
214 },
215 inactive: {
216 className:
217 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
218 label: __("Inactive"),
219 },
220 blocked: {
221 className:
222 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
223 label: __("Blocked"),
224 },
225 };
226
227 const statusInfo = statusMap[status] || {
228 className:
229 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
230 label: status,
231 };
232
233 return (
234 <span
235 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
236 >
237 {statusInfo.label}
238 </span>
239 );
240 };
241
242 const getLoyaltyBadge = (tier: string) => {
243 const tierMap: Record<string, { className: string; icon: string }> = {
244 bronze: {
245 className:
246 "bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400",
247 icon: "🥉",
248 },
249 silver: {
250 className:
251 "bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-300",
252 icon: "🥈",
253 },
254 gold: {
255 className:
256 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
257 icon: "🥇",
258 },
259 platinum: {
260 className:
261 "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400",
262 icon: "💎",
263 },
264 };
265
266 const tierInfo = tierMap[tier] || tierMap["bronze"];
267
268 return (
269 <span
270 className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium ${tierInfo.className}`}
271 >
272 <span>{tierInfo.icon}</span>
273 <span className="capitalize">{tier}</span>
274 </span>
275 );
276 };
277
278 const handleEdit = (customer: Customer) => {
279 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=edit&id=${customer.id}`;
280 };
281
282 const handleDelete = (customer: Customer) => {
283 setCustomerToDelete(customer);
284 setDeleteDialogOpen(true);
285 };
286
287 const confirmDelete = () => {
288 if (customerToDelete) {
289 deleteMutation.mutate(customerToDelete.id);
290 }
291 };
292
293 const handleView = (customer: Customer) => {
294 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=view&id=${customer.id}`;
295 };
296
297 const handleCreateCustomer = () => {
298 window.location.href = `${baseAdminUrl}?page=yatra&subpage=customers&action=create`;
299 };
300
301 const handleResetFilters = () => {
302 setSearchTerm("");
303 setStatusFilter("all");
304 setLoyaltyFilter("all");
305 setSortBy("created_at");
306 setSortOrder("desc");
307 setPage(1);
308 };
309
310 const handleSort = (field: string) => {
311 if (sortBy === field) {
312 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
313 } else {
314 setSortBy(field);
315 setSortOrder("desc");
316 }
317 };
318
319 const getSortIcon = (field: string) => {
320 if (sortBy !== field) {
321 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
322 }
323 return sortOrder === "asc" ? (
324 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
325 ) : (
326 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
327 );
328 };
329
330 const hasFilters =
331 searchTerm ||
332 statusFilter !== "all" ||
333 loyaltyFilter !== "all" ||
334 sortBy !== "created_at" ||
335 sortOrder !== "desc";
336
337 const toggleColumn = (key: string) => {
338 const next = {
339 ...visibleColumns,
340 [key]: !visibleColumns[key as keyof typeof visibleColumns],
341 };
342 setVisibleColumns(next);
343 if (typeof window !== "undefined") {
344 window.localStorage.setItem(
345 "yatra-customers-visible-columns",
346 JSON.stringify(next),
347 );
348 }
349 };
350
351 const handleSelectItem = (id: string | number, checked: boolean) => {
352 setSelectedIds((prev) =>
353 checked ? [...prev, id] : prev.filter((v) => v !== id),
354 );
355 };
356
357 const handleSelectAll = (checked: boolean) => {
358 if (checked) {
359 setSelectedIds(customers.map((customer) => customer.id));
360 } else {
361 setSelectedIds([]);
362 }
363 };
364
365 const isAllSelected =
366 customers.length > 0 && selectedIds.length === customers.length;
367
368 const updateStatusForIds = async (
369 ids: (string | number)[],
370 newStatus: string,
371 ) => {
372 await Promise.all(
373 ids.map(async (id) => {
374 await apiService.updateCustomerStatus(id, newStatus);
375 }),
376 );
377 };
378
379 const handleBulkApply = async () => {
380 if (!bulkAction || selectedIds.length === 0) {
381 return;
382 }
383
384 try {
385 setIsBulkPending(true);
386
387 if (bulkAction === "delete") {
388 await Promise.all(
389 selectedIds.map((id) => apiService.deleteCustomer(id)),
390 );
391 showToast(__("Selected customers deleted successfully"), "success");
392 } else {
393 await updateStatusForIds(selectedIds, bulkAction);
394 showToast(__("Customer statuses updated successfully"), "success");
395 }
396
397 queryClient.invalidateQueries({ queryKey: ["customers"] });
398 setSelectedIds([]);
399 setBulkAction("");
400 } catch (error: any) {
401 showToast(
402 error?.message || __("Failed to perform bulk action on customers"),
403 "error",
404 );
405 } finally {
406 setIsBulkPending(false);
407 }
408 };
409
410 const allBulkActionOptions = [
411 { value: "active", label: __("Mark as Active") },
412 { value: "inactive", label: __("Mark as Inactive") },
413 { value: "blocked", label: __("Mark as Blocked") },
414 { value: "delete", label: __("Delete permanently") },
415 ];
416
417 const getBulkActionOptionsForStatus = (view: string) => {
418 switch (view) {
419 case "active":
420 return allBulkActionOptions.filter((opt) =>
421 ["inactive", "blocked", "delete"].includes(opt.value),
422 );
423 case "inactive":
424 return allBulkActionOptions.filter((opt) =>
425 ["active", "blocked", "delete"].includes(opt.value),
426 );
427 case "blocked":
428 return allBulkActionOptions.filter((opt) =>
429 ["active", "inactive", "delete"].includes(opt.value),
430 );
431 default:
432 return allBulkActionOptions;
433 }
434 };
435
436 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
437
438 const statusOptions = [
439 { value: "all", label: __("All Status") },
440 { value: "active", label: __("Active") },
441 { value: "inactive", label: __("Inactive") },
442 { value: "blocked", label: __("Blocked") },
443 ];
444
445 const sortOptions = [
446 { value: "created_at", label: __("Registration Date") },
447 { value: "name", label: __("Name") },
448 { value: "email", label: __("Email") },
449 { value: "country", label: __("Country") },
450 { value: "total_bookings", label: __("Bookings") },
451 { value: "total_spent", label: __("Total Spent") },
452 { value: "status", label: __("Status") },
453 ];
454
455 // Helper functions};
456
457 const columnOptions = [
458 {
459 key: "customer",
460 label: __("Customer"),
461 visible: visibleColumns.customer,
462 },
463 {
464 key: "location",
465 label: __("Location"),
466 visible: visibleColumns.location,
467 },
468 {
469 key: "total_bookings",
470 label: __("Bookings"),
471 visible: visibleColumns.total_bookings,
472 },
473 {
474 key: "total_spent",
475 label: __("Total Spent"),
476 visible: visibleColumns.total_spent,
477 },
478 { key: "loyalty", label: __("Loyalty"), visible: visibleColumns.loyalty },
479 { key: "status", label: __("Status"), visible: visibleColumns.status },
480 {
481 key: "created_at",
482 label: __("Registered"),
483 visible: visibleColumns.created_at,
484 },
485 ];
486
487 const columns = [
488 {
489 key: "customer",
490 label: __("Customer"),
491 sortable: true,
492 visible: visibleColumns.customer,
493 render: (customer: Customer) => (
494 <div>
495 <div className="font-medium text-gray-900 dark:text-white">
496 <a
497 href={`${baseAdminUrl}?page=yatra&subpage=customers&action=view&id=${customer.id}`}
498 className="hover:underline underline-offset-2 text-blue-600 dark:text-blue-400"
499 >
500 {customer.name || `${customer.first_name} ${customer.last_name}`}
501 </a>
502 </div>
503 <div className="text-sm text-gray-500 dark:text-gray-400">
504 {customer.email}
505 </div>
506 {customer.phone && (
507 <div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
508 <Phone className="w-3 h-3" />
509 {customer.phone}
510 </div>
511 )}
512 </div>
513 ),
514 },
515 {
516 key: "location",
517 label: __("Location"),
518 sortable: true,
519 visible: visibleColumns.location,
520 render: (customer: Customer) =>
521 customer.country ? (
522 <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
523 <MapPin className="w-3.5 h-3.5" />
524 <span>
525 {customer.city ? `${customer.city}, ` : ""}
526 {customer.country}
527 </span>
528 </div>
529 ) : (
530 <span className="text-gray-400">-</span>
531 ),
532 },
533 isPro && {
534 key: "total_bookings",
535 label: __("Bookings"),
536 sortable: true,
537 visible: visibleColumns.total_bookings,
538 render: (customer: Customer) => (
539 <span className="text-gray-600 dark:text-gray-400">
540 {customer.total_bookings}
541 </span>
542 ),
543 },
544 isPro && {
545 key: "total_spent",
546 label: __("Total Spent"),
547 sortable: true,
548 visible: visibleColumns.total_spent,
549 render: (customer: Customer) => (
550 <span className="font-medium">{formatPrice(customer.total_spent)}</span>
551 ),
552 },
553 {
554 key: "loyalty",
555 label: __("Loyalty"),
556 sortable: false,
557 visible: visibleColumns.loyalty,
558 render: (customer: Customer) =>
559 getLoyaltyBadge(customer.loyalty_tier || "bronze"),
560 },
561 {
562 key: "status",
563 label: __("Status"),
564 sortable: true,
565 visible: visibleColumns.status,
566 render: (customer: Customer) => getStatusBadge(customer.status),
567 },
568 {
569 key: "created_at",
570 label: __("Registered"),
571 sortable: true,
572 visible: visibleColumns.created_at,
573 render: (customer: Customer) => (
574 <span className="text-gray-500 dark:text-gray-400 text-sm">
575 {formatDate(customer.created_at)}
576 </span>
577 ),
578 },
579 ].filter(Boolean);
580
581 const actions = [
582 {
583 key: "view",
584 label: __("View"),
585 icon: <Eye className="w-4 h-4" />,
586 onClick: (customer: Customer) => handleView(customer),
587 },
588 {
589 key: "edit",
590 label: __("Edit"),
591 icon: <Edit className="w-4 h-4" />,
592 onClick: (customer: Customer) => handleEdit(customer),
593 condition: () => can("yatra_edit_bookings"),
594 },
595 {
596 key: "mark_active",
597 label: __("Mark as Active"),
598 icon: <Check className="w-4 h-4" />,
599 onClick: async (customer: Customer) => {
600 await updateStatusForIds([customer.id], "active");
601 queryClient.invalidateQueries({ queryKey: ["customers"] });
602 },
603 condition: (customer: Customer) =>
604 can("yatra_edit_bookings") && customer.status !== "active",
605 },
606 {
607 key: "mark_inactive",
608 label: __("Mark as Inactive"),
609 icon: <PauseCircle className="w-4 h-4" />,
610 onClick: async (customer: Customer) => {
611 await updateStatusForIds([customer.id], "inactive");
612 queryClient.invalidateQueries({ queryKey: ["customers"] });
613 },
614 condition: (customer: Customer) =>
615 can("yatra_edit_bookings") && customer.status !== "inactive",
616 },
617 {
618 key: "mark_blocked",
619 label: __("Mark as Blocked"),
620 icon: <ShieldX className="w-4 h-4" />,
621 onClick: async (customer: Customer) => {
622 await updateStatusForIds([customer.id], "blocked");
623 queryClient.invalidateQueries({ queryKey: ["customers"] });
624 },
625 condition: (customer: Customer) =>
626 can("yatra_edit_bookings") && customer.status !== "blocked",
627 },
628 {
629 key: "delete",
630 label: __("Delete"),
631 icon: <Trash2 className="w-4 h-4" />,
632 onClick: (customer: Customer) => handleDelete(customer),
633 variant: "destructive" as const,
634 condition: () => can("yatra_delete_bookings"),
635 },
636 ];
637
638 // Skeleton loader
639 const renderSkeleton = () => (
640 <Table>
641 <TableHeader>
642 <TableRow>
643 <TableHead className="w-[250px]">{__("Customer")}</TableHead>
644 <TableHead>{__("Location")}</TableHead>
645 {isPro && <TableHead>{__("Bookings")}</TableHead>}
646 {isPro && <TableHead>{__("Total Spent")}</TableHead>}
647 <TableHead>{__("Loyalty")}</TableHead>
648 <TableHead>{__("Status")}</TableHead>
649 <TableHead>{__("Registered")}</TableHead>
650 <TableHead className="text-right w-[100px]">
651 {__("Actions")}
652 </TableHead>
653 </TableRow>
654 </TableHeader>
655 <TableBody>
656 {[...Array(5)].map((_, i) => (
657 <TableRow key={i}>
658 <TableCell>
659 <div className="space-y-2">
660 <Skeleton className="h-4 w-32" />
661 <Skeleton className="h-3 w-40" />
662 </div>
663 </TableCell>
664 <TableCell>
665 <Skeleton className="h-4 w-24" />
666 </TableCell>
667 {isPro && (
668 <TableCell>
669 <Skeleton className="h-4 w-8" />
670 </TableCell>
671 )}
672 {isPro && (
673 <TableCell>
674 <Skeleton className="h-4 w-16" />
675 </TableCell>
676 )}
677 <TableCell>
678 <Skeleton className="h-6 w-16 rounded" />
679 </TableCell>
680 <TableCell>
681 <Skeleton className="h-6 w-14 rounded" />
682 </TableCell>
683 <TableCell>
684 <Skeleton className="h-4 w-20" />
685 </TableCell>
686 <TableCell>
687 <div className="flex justify-end gap-1">
688 <Skeleton className="h-8 w-8 rounded" />
689 <Skeleton className="h-8 w-8 rounded" />
690 <Skeleton className="h-8 w-8 rounded" />
691 </div>
692 </TableCell>
693 </TableRow>
694 ))}
695 </TableBody>
696 </Table>
697 );
698
699 return (
700 <div className="space-y-3">
701 <PageHeader
702 title={__("Customers")}
703 description={__("Manage your customer database")}
704 actionCapability="yatra_edit_bookings"
705 actions={
706 <Button
707 onClick={handleCreateCustomer}
708 className="flex items-center gap-2"
709 >
710 <Plus className="w-4 h-4" />
711 {__("Add New Customer")}
712 </Button>
713 }
714 />
715
716 <div className="space-y-3">
717 {/* Search and Filters Card */}
718 <ConditionalRender capability="yatra_view_bookings">
719 <Card>
720 <CardContent className="p-4">
721 <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-center">
722 {/* Search & status/sort (handled by toolbar) */}
723 <div className={isPro ? "lg:col-span-8" : "lg:col-span-12"}>
724 <SearchFilterToolbar
725 searchTerm={searchTerm}
726 onSearchChange={setSearchTerm}
727 statusFilter={statusFilter}
728 onStatusChange={(value) => {
729 setStatusFilter(value);
730 setPage(1);
731 }}
732 statusOptions={statusOptions}
733 sortBy={sortBy}
734 onSortByChange={(field) => {
735 setSortBy(field);
736 setPage(1);
737 }}
738 sortOrder={sortOrder}
739 onSortOrderChange={(order) => {
740 setSortOrder(order);
741 setPage(1);
742 }}
743 sortOptions={sortOptions}
744 onResetFilters={handleResetFilters}
745 hasFilters={!!hasFilters}
746 placeholder={__("Search customers...")}
747 />
748 </div>
749
750 {/* Loyalty Filter */}
751 {isPro && (
752 <div className="lg:col-span-4">
753 <Select
754 value={loyaltyFilter}
755 onChange={(e) => {
756 setLoyaltyFilter(e.target.value);
757 setPage(1);
758 }}
759 className="w-full"
760 >
761 <option value="all">{__("All Tiers")}</option>
762 <option value="bronze">{__("Bronze")}</option>
763 <option value="silver">{__("Silver")}</option>
764 <option value="gold">{__("Gold")}</option>
765 <option value="platinum">{__("Platinum")}</option>
766 </Select>
767 </div>
768 )}
769 </div>
770 </CardContent>
771 </Card>
772 </ConditionalRender>
773
774 {/* Bulk Actions Toolbar */}
775 <ConditionalRender capability="yatra_view_bookings">
776 <BulkActionToolbar
777 selectedIds={selectedIds}
778 bulkAction={bulkAction}
779 setBulkAction={setBulkAction}
780 onApply={handleBulkApply}
781 onClearSelection={() => setSelectedIds([])}
782 statusFilter={statusFilter}
783 setStatusFilter={(value) => {
784 setStatusFilter(value);
785 setPage(1);
786 }}
787 statusOptions={[
788 { key: "all", label: __("All"), count: statsData?.all ?? 0 },
789 {
790 key: "active",
791 label: __("Active"),
792 count: statsData?.active ?? 0,
793 },
794 {
795 key: "inactive",
796 label: __("Inactive"),
797 count: statsData?.inactive ?? 0,
798 },
799 {
800 key: "blocked",
801 label: __("Blocked"),
802 count: statsData?.blocked ?? 0,
803 },
804 ]}
805 showColumnsDropdown={showColumnsDropdown}
806 setShowColumnsDropdown={setShowColumnsDropdown}
807 columnOptions={columnOptions}
808 onToggleColumn={toggleColumn}
809 bulkMutationPending={isBulkPending}
810 totalItems={total}
811 bulkActionOptions={bulkActionOptions}
812 />
813 </ConditionalRender>
814
815 {/* Customers Table Card */}
816 <ConditionalRender capability="yatra_view_bookings">
817 <Card>
818 <CardContent className="p-0">
819 {isLoading ? (
820 renderSkeleton()
821 ) : (
822 <SharedTable
823 data={customers}
824 columns={columns as any}
825 actions={actions as any}
826 isLoading={isLoading}
827 isError={!!error}
828 errorText={__("Error loading customers")}
829 errorDetails={errorContext.details}
830 errorRequestInfo={errorContext.requestInfo}
831 errorDescription={__(
832 "We couldn’t connect to the customers service. Please refresh or try again shortly.",
833 )}
834 onRetry={() =>
835 queryClient.invalidateQueries({ queryKey: ["customers"] })
836 }
837 emptyText={__("No customers found")}
838 emptyDescription={
839 hasFilters
840 ? __("Try adjusting your filters to see more results.")
841 : __("Customers will appear here when bookings are made")
842 }
843 onCreateClick={
844 can("yatra_edit_bookings")
845 ? handleCreateCustomer
846 : undefined
847 }
848 onSort={handleSort}
849 getSortIcon={getSortIcon}
850 selectedItemIds={selectedIds}
851 onSelectItem={handleSelectItem}
852 onSelectAll={handleSelectAll}
853 isAllSelected={isAllSelected}
854 getItemId={(customer: Customer) => customer.id}
855 skeletonRows={5}
856 capability="yatra_view_bookings"
857 />
858 )}
859 </CardContent>
860 </Card>
861
862 {/* Pagination */}
863 {total > 0 && (
864 <div className="mt-4">
865 <Pagination
866 currentPage={page}
867 totalPages={totalPages}
868 totalItems={total}
869 itemsPerPage={10}
870 onPageChange={(newPage) => setPage(newPage)}
871 itemName={__("customers")}
872 />
873 </div>
874 )}
875 </ConditionalRender>
876 </div>
877
878 {/* Delete Confirmation Dialog */}
879 <ConfirmationDialog
880 isOpen={deleteDialogOpen}
881 onClose={() => setDeleteDialogOpen(false)}
882 onConfirm={confirmDelete}
883 title={__("Delete Customer")}
884 description={
885 customerToDelete
886 ? __(
887 "Are you sure you want to delete this customer? This action cannot be undone.",
888 ) + ` (${customerToDelete.name || customerToDelete.email})`
889 : __(
890 "Are you sure you want to delete this customer? This action cannot be undone.",
891 )
892 }
893 confirmText={__("Delete")}
894 cancelText={__("Cancel")}
895 variant="danger"
896 isLoading={deleteMutation.isPending}
897 />
898 </div>
899 );
900 };
901
902 export default Customers;
903