PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / Customers.tsx

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

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