PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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 / TravelerCategories.tsx

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

879 lines 27.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Traveler Categories Page
3 * Clean, minimal SaaS-style traveler categories management page
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { Plus, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { usePermissions } from "../hooks/usePermissions";
11 import { useToast } from "../components/ui/toast";
12 import { apiClient } from "../lib/api-client";
13 import { Button } from "../components/ui/button";
14 import { PageHeader } from "../components/common/PageHeader";
15 import { Card, CardContent } from "../components/ui/card";
16 import { ConditionalRender } from "../components/ui/conditional-render";
17 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
18 import { Edit, Trash2 } from "lucide-react";
19 import { IconSelector } from "../components/ui/icon-selector";
20 import type { IconPickerValue } from "../components/ui/icon-picker";
21 import {
22 SearchFilterToolbar,
23 BulkActionToolbar,
24 Pagination,
25 Table as SharedTable,
26 } from "../components/shared";
27 import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions";
28 import { getErrorContext } from "../lib/errors";
29
30 interface TravelerCategory {
31 id: number;
32 label: string;
33 slug: string;
34 description: string;
35 age_min?: number | null;
36 age_max?: number | null;
37 icon?: IconPickerValue | null;
38 status: "draft" | "publish" | "trash";
39 pricing_mode?: "per_person" | "per_group";
40 min_pax?: number | null;
41 max_pax?: number | null;
42 created_at: string;
43 updated_at: string;
44 created_by: number; // user_id
45 updated_by: number; // user_id
46 created_by_name?: string; // Optional: user name from API
47 updated_by_name?: string; // Optional: user name from API
48 }
49
50 const TravelerCategories: React.FC = () => {
51 const [searchTerm, setSearchTerm] = useState("");
52 const [statusFilter, setStatusFilter] = useState("all");
53 const [sortBy, setSortBy] = useState("id");
54 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
55 const [page, setPage] = useState(1);
56 const [deleteConfirm, setDeleteConfirm] = useState<{
57 isOpen: boolean;
58 category: TravelerCategory | null;
59 }>({
60 isOpen: false,
61 category: null,
62 });
63 const queryClient = useQueryClient();
64 const { can } = usePermissions();
65 const { showToast } = useToast();
66
67 const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || "";
68
69 // Build query params
70 const queryParams = useMemo(() => {
71 const params: Record<string, any> = {
72 page,
73 per_page: 10,
74 orderby: sortBy,
75 order: sortOrder,
76 };
77
78 if (searchTerm) {
79 params.search = searchTerm;
80 }
81
82 if (statusFilter !== "all") {
83 params.status = statusFilter;
84 }
85
86 return params;
87 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
88
89 // Fetch stable status counts from API (independent of filters)
90 const { data: statsData } = useQuery({
91 queryKey: ["traveler-categories-stats"],
92 queryFn: async () => {
93 try {
94 const response = await apiClient.get("/traveler-categories/stats");
95 return response;
96 } catch (error: any) {
97 return { all: 0, publish: 0, draft: 0, trash: 0 };
98 }
99 },
100 enabled: can("yatra_view_trips"),
101 });
102
103 // Fetch categories from API
104 const { data, isLoading, error } = useQuery<any>({
105 queryKey: ["traveler-categories", queryParams],
106 queryFn: async () => {
107 try {
108 const response = await apiClient.get("/traveler-categories", {
109 params: queryParams,
110 });
111 return response;
112 } catch (error: any) {
113 showToast(
114 error?.message || __("Failed to load traveler categories", "yatra"),
115 "error",
116 );
117 throw error;
118 }
119 },
120 enabled: can("yatra_view_trips"),
121 });
122
123 // Delete mutation
124 const deleteMutation = useMutation({
125 mutationFn: async (id: number) => {
126 return await apiClient.delete(`/traveler-categories/${id}`);
127 },
128 onSuccess: () => {
129 queryClient.invalidateQueries({ queryKey: ["traveler-categories"] });
130 queryClient.invalidateQueries({
131 queryKey: ["traveler-categories-stats"],
132 });
133 showToast(
134 __("Traveler category deleted successfully", "yatra"),
135 "success",
136 );
137 setDeleteConfirm({ isOpen: false, category: null });
138 },
139 onError: (error: any) => {
140 showToast(
141 error?.message || __("Failed to delete traveler category", "yatra"),
142 "error",
143 );
144 },
145 });
146
147 const categories = data?.data || [];
148 const total = data?.total || 0;
149 const totalPages = Math.ceil(total / 10);
150 const errorContext = getErrorContext(error);
151 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
152 const isCategoriesError = !!error || !!apiErrorMessage;
153
154 const formatDate = (dateString: string) => {
155 if (!dateString) return __("N/A", "yatra");
156 try {
157 const date = new Date(dateString);
158 return date.toLocaleDateString("en-US", {
159 month: "short",
160 day: "numeric",
161 year: "numeric",
162 hour: "2-digit",
163 minute: "2-digit",
164 });
165 } catch (e) {
166 return dateString;
167 }
168 };
169
170 const getStatusBadge = (status: string) => {
171 const statusMap: Record<string, { className: string; label: string }> = {
172 publish: {
173 className:
174 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
175 label: __("Publish", "yatra"),
176 },
177 draft: {
178 className:
179 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
180 label: __("Draft", "yatra"),
181 },
182 trash: {
183 className:
184 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
185 label: __("Trash", "yatra"),
186 },
187 };
188
189 const statusInfo = statusMap[status] || {
190 className:
191 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
192 label: status,
193 };
194
195 return (
196 <span
197 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
198 >
199 {statusInfo.label}
200 </span>
201 );
202 };
203
204 const formatUser = (userId: number, userName?: string) => {
205 if (userName) {
206 return userName;
207 }
208 return `User #${userId}`;
209 };
210
211 const formatAgeRange = (ageMin?: number | null, ageMax?: number | null) => {
212 if (
213 ageMin !== null &&
214 ageMin !== undefined &&
215 ageMax !== null &&
216 ageMax !== undefined
217 ) {
218 return `${ageMin}-${ageMax} ${__("years old", "yatra")}`;
219 } else if (ageMin !== null && ageMin !== undefined) {
220 return `${ageMin}+ ${__("years old", "yatra")}`;
221 } else if (ageMax !== null && ageMax !== undefined) {
222 return `${__("Under", "yatra")} ${ageMax} ${__("years old", "yatra")}`;
223 }
224 return __("No age restriction", "yatra");
225 };
226
227 const formatPricing = (category: TravelerCategory) => {
228 const mode = category.pricing_mode || "per_person";
229
230 if (mode === "per_group") {
231 const hasMin =
232 category.min_pax !== null && category.min_pax !== undefined;
233 const hasMax =
234 category.max_pax !== null && category.max_pax !== undefined;
235
236 if (hasMin && hasMax) {
237 return `${__("Per group", "yatra")} (${category.min_pax}-${category.max_pax})`;
238 }
239
240 if (hasMin) {
241 return `${__("Per group", "yatra")} (${__("From", "yatra")} ${category.min_pax})`;
242 }
243
244 if (hasMax) {
245 return `${__("Per group", "yatra")} (${__("Up to", "yatra")} ${category.max_pax})`;
246 }
247
248 return __("Per group", "yatra");
249 }
250
251 return __("Per person", "yatra");
252 };
253
254 const renderIcon = (icon: IconPickerValue | null | undefined) => {
255 if (!icon) {
256 return (
257 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
258 <span className="text-xs text-gray-400 dark:text-gray-500">—</span>
259 </div>
260 );
261 }
262
263 if (icon.type === "image") {
264 return (
265 <img
266 src={icon.value}
267 alt=""
268 className="w-8 h-8 rounded object-cover"
269 onError={(e) => {
270 const target = e.target as HTMLImageElement;
271 target.style.display = "none";
272 if (target.parentElement) {
273 target.parentElement.innerHTML =
274 '<div class="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center"><span class="text-xs text-gray-400 dark:text-gray-500">—</span></div>';
275 }
276 }}
277 />
278 );
279 }
280
281 return (
282 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
283 <IconSelector
284 iconName={icon.value}
285 className="w-5 h-5 text-gray-600 dark:text-gray-400"
286 />
287 </div>
288 );
289 };
290
291 const handleEdit = (category: TravelerCategory) => {
292 window.location.href = `${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=edit&id=${category.id}`;
293 };
294
295 const handleDelete = (category: TravelerCategory) => {
296 setDeleteConfirm({ isOpen: true, category });
297 };
298
299 const confirmDelete = () => {
300 if (deleteConfirm.category) {
301 deleteMutation.mutate(deleteConfirm.category.id);
302 }
303 };
304
305 const handleCreateCategory = () => {
306 window.location.href = `${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=create`;
307 };
308
309 const handleResetFilters = () => {
310 setSearchTerm("");
311 setStatusFilter("all");
312 setSortBy("label");
313 setSortOrder("asc");
314 setPage(1);
315 };
316
317 const handleSort = (field: string) => {
318 if (sortBy === field) {
319 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
320 } else {
321 setSortBy(field);
322 setSortOrder("asc");
323 }
324 };
325
326 const getSortIcon = (field: string) => {
327 if (sortBy !== field) {
328 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
329 }
330 return sortOrder === "asc" ? (
331 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
332 ) : (
333 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
334 );
335 };
336
337 const hasFilters =
338 searchTerm ||
339 statusFilter !== "all" ||
340 sortBy !== "id" ||
341 sortOrder !== "desc";
342
343 // Bulk selection & column visibility state
344 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
345 const [bulkAction, setBulkAction] = useState("");
346 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
347 const [visibleColumns, setVisibleColumns] = useState({
348 category: true,
349 description: true,
350 age_range: true,
351 pricing: true,
352 status: true,
353 dates: true,
354 author: true,
355 });
356
357 const toggleColumnVisibility = (key: keyof typeof visibleColumns) => {
358 setVisibleColumns((prev) => ({
359 ...prev,
360 [key]: !prev[key],
361 }));
362 };
363
364 // Status counts from stats API (stable across filters)
365 const statusCounts = useMemo(() => {
366 if (statsData) {
367 return {
368 all: statsData.all ?? 0,
369 publish: statsData.publish ?? 0,
370 draft: statsData.draft ?? 0,
371 trash: statsData.trash ?? 0,
372 };
373 }
374
375 return {
376 all: 0,
377 publish: 0,
378 draft: 0,
379 trash: 0,
380 };
381 }, [statsData]);
382
383 // Bulk mutation for status changes and deletes
384 const bulkMutation = useMutation({
385 mutationFn: async ({
386 action,
387 ids,
388 }: {
389 action: string;
390 ids: (string | number)[];
391 }) => {
392 if (action === "delete") {
393 await Promise.all(
394 ids.map((id) => apiClient.delete(`/traveler-categories/${id}`)),
395 );
396 } else {
397 // Status change - fetch each category and update with full payload
398 await Promise.all(
399 ids.map(async (id) => {
400 try {
401 const response = await apiClient.get(
402 `/traveler-categories/${id}`,
403 );
404 const category = response as TravelerCategory;
405 if (!category || !category.label) {
406 return;
407 }
408
409 await apiClient.put(`/traveler-categories/${id}`, {
410 label: category.label,
411 slug: category.slug,
412 description: category.description,
413 age_min: category.age_min,
414 age_max: category.age_max,
415 icon: category.icon,
416 pricing_mode: category.pricing_mode || "per_person",
417 min_pax: category.min_pax ?? null,
418 max_pax: category.max_pax ?? null,
419 status: action,
420 });
421 } catch {
422 throw new Error("bulk_item_failed");
423 }
424 }),
425 );
426 }
427 },
428 onSuccess: (_data, variables) => {
429 queryClient.invalidateQueries({ queryKey: ["traveler-categories"] });
430 queryClient.invalidateQueries({
431 queryKey: ["traveler-categories-stats"],
432 });
433 const msgMap: Record<string, string> = {
434 delete: __(
435 "Selected traveler categories deleted successfully",
436 "yatra",
437 ),
438 trash: __("Selected traveler categories moved to trash", "yatra"),
439 draft: __("Selected traveler categories marked as draft", "yatra"),
440 publish: __("Selected traveler categories published", "yatra"),
441 };
442 showToast(
443 msgMap[variables.action] || __("Bulk action completed", "yatra"),
444 "success",
445 );
446 setSelectedIds([]);
447 setBulkAction("");
448 },
449 onError: (error: any, variables) => {
450 const msgMapError: Record<string, string> = {
451 delete: __("Failed to delete selected traveler categories", "yatra"),
452 trash: __("Failed to move traveler categories to trash", "yatra"),
453 draft: __("Failed to mark traveler categories as draft", "yatra"),
454 publish: __("Failed to publish traveler categories", "yatra"),
455 };
456 showToast(
457 error?.message ||
458 msgMapError[variables.action] ||
459 __("Bulk action failed", "yatra"),
460 "error",
461 );
462 },
463 });
464
465 const handleBulkApply = () => {
466 if (!bulkAction) {
467 showToast(__("Select a bulk action first.", "yatra"), "warning");
468 return;
469 }
470
471 if (selectedIds.length === 0) {
472 showToast(
473 __("Select at least one traveler category.", "yatra"),
474 "warning",
475 );
476 return;
477 }
478
479 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
480 };
481
482 // Columns config for shared Table
483 const columns = [
484 {
485 key: "category",
486 label: __("Category", "yatra"),
487 sortable: true,
488 visible: visibleColumns.category,
489 render: (category: TravelerCategory) => (
490 <div className="flex items-center gap-3">
491 {renderIcon(category.icon)}
492 <div>
493 <a
494 href={`${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=edit&id=${category.id}`}
495 className="font-medium text-gray-900 dark:text-white text-blue-600 dark:text-blue-400 hover:underline underline-offset-2"
496 >
497 {category.label}
498 </a>
499 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-2">
500 <span>{category.slug}</span>
501 <span className="text-gray-400 dark:text-gray-500">
502 (ID: {category.id})
503 </span>
504 </div>
505 </div>
506 </div>
507 ),
508 },
509 {
510 key: "description",
511 label: __("Description", "yatra"),
512 visible: visibleColumns.description,
513 render: (category: TravelerCategory) => (
514 <div className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2">
515 {category.description || __("No description", "yatra")}
516 </div>
517 ),
518 },
519 {
520 key: "age_range",
521 label: __("Age Range", "yatra"),
522 visible: visibleColumns.age_range,
523 render: (category: TravelerCategory) => (
524 <span className="text-gray-600 dark:text-gray-400 text-sm">
525 {formatAgeRange(category.age_min, category.age_max)}
526 </span>
527 ),
528 },
529 {
530 key: "pricing",
531 label: __("Pricing", "yatra"),
532 visible: visibleColumns.pricing,
533 render: (category: TravelerCategory) => (
534 <span className="text-gray-600 dark:text-gray-400 text-sm">
535 {formatPricing(category)}
536 </span>
537 ),
538 },
539 {
540 key: "status",
541 label: __("Status", "yatra"),
542 sortable: true,
543 visible: visibleColumns.status,
544 render: (category: TravelerCategory) => getStatusBadge(category.status),
545 },
546 {
547 key: "dates",
548 label: __("Date", "yatra"),
549 sortable: true,
550 visible: visibleColumns.dates,
551 render: (category: TravelerCategory) => (
552 <div className="text-gray-500 dark:text-gray-400 text-sm">
553 <div>{formatDate(category.created_at)}</div>
554 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
555 {__("Updated", "yatra")}: {formatDate(category.updated_at)}
556 </div>
557 </div>
558 ),
559 },
560 {
561 key: "author",
562 label: __("Author", "yatra"),
563 visible: visibleColumns.author,
564 render: (category: TravelerCategory) => (
565 <div className="text-gray-600 dark:text-gray-400 text-sm">
566 <div>{formatUser(category.created_by, category.created_by_name)}</div>
567 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
568 {__("Updated by", "yatra")}:{" "}
569 {formatUser(category.updated_by, category.updated_by_name)}
570 </div>
571 </div>
572 ),
573 },
574 ];
575
576 const actions = [
577 {
578 key: "edit",
579 label: __("Edit", "yatra"),
580 icon: <Edit className="w-4 h-4" />,
581 onClick: (category: TravelerCategory) => handleEdit(category),
582 condition: () => can("yatra_edit_trips"),
583 },
584 {
585 key: "publish",
586 label: __("Make Published", "yatra"),
587 icon: <Edit className="w-4 h-4" />,
588 onClick: (category: TravelerCategory) =>
589 bulkMutation.mutate({ action: "publish", ids: [category.id] }),
590 condition: (category: TravelerCategory) =>
591 can("yatra_edit_trips") && category.status !== "publish",
592 },
593 {
594 key: "draft",
595 label: __("Make Draft", "yatra"),
596 icon: <Edit className="w-4 h-4" />,
597 onClick: (category: TravelerCategory) =>
598 bulkMutation.mutate({ action: "draft", ids: [category.id] }),
599 condition: (category: TravelerCategory) =>
600 can("yatra_edit_trips") && category.status !== "draft",
601 },
602 {
603 key: "trash",
604 label: __("Move to Trash", "yatra"),
605 icon: <Trash2 className="w-4 h-4" />,
606 onClick: (category: TravelerCategory) =>
607 bulkMutation.mutate({ action: "trash", ids: [category.id] }),
608 variant: "destructive" as const,
609 condition: (category: TravelerCategory) =>
610 can("yatra_edit_trips") && category.status !== "trash",
611 },
612 {
613 key: "delete",
614 label: __("Delete Permanently", "yatra"),
615 icon: <Trash2 className="w-4 h-4" />,
616 onClick: (category: TravelerCategory) => handleDelete(category),
617 variant: "destructive" as const,
618 condition: (category: TravelerCategory) =>
619 can("yatra_delete_trips") && category.status === "trash",
620 },
621 ];
622
623 return (
624 <div className="space-y-3">
625 {/* Confirmation Dialog */}
626 <ConfirmationDialog
627 isOpen={deleteConfirm.isOpen}
628 onClose={() => setDeleteConfirm({ isOpen: false, category: null })}
629 onConfirm={confirmDelete}
630 title={__("Delete Traveler Category", "yatra")}
631 message={
632 deleteConfirm.category
633 ? __(
634 'Are you sure you want to delete "{label}"? This action cannot be undone.',
635 "yatra",
636 ).replace("{label}", deleteConfirm.category.label)
637 : __(
638 "Are you sure you want to delete this traveler category? This action cannot be undone.",
639 "yatra",
640 )
641 }
642 confirmText={__("Delete", "yatra")}
643 cancelText={__("Cancel", "yatra")}
644 variant="danger"
645 isLoading={deleteMutation.isPending}
646 />
647
648 <PageHeader
649 title={__("Traveler Categories", "yatra")}
650 description={__(
651 "Manage pricing categories for different types of travelers",
652 "yatra",
653 )}
654 actionCapability="yatra_edit_trips"
655 actions={
656 <Button
657 onClick={handleCreateCategory}
658 className="flex items-center gap-2"
659 >
660 <Plus className="w-4 h-4" />
661 {__("Add New Category", "yatra")}
662 </Button>
663 }
664 />
665
666 {/* Filters */}
667 <Card>
668 <CardContent className="p-3">
669 <SearchFilterToolbar
670 searchTerm={searchTerm}
671 onSearchChange={setSearchTerm}
672 statusFilter={statusFilter}
673 onStatusChange={(value) => {
674 setStatusFilter(value);
675 setPage(1);
676 }}
677 statusOptions={[
678 { value: "all", label: __("All Status", "yatra") },
679 { value: "publish", label: __("Publish", "yatra") },
680 { value: "draft", label: __("Draft", "yatra") },
681 { value: "trash", label: __("Trash", "yatra") },
682 ]}
683 sortBy={sortBy}
684 onSortByChange={(value) => {
685 setSortBy(value);
686 setSortOrder("asc");
687 setPage(1);
688 }}
689 sortOrder={sortOrder}
690 onSortOrderChange={(order) => {
691 setSortOrder(order);
692 setPage(1);
693 }}
694 sortOptions={[
695 { value: "id", label: __("ID", "yatra") },
696 { value: "label", label: __("Label", "yatra") },
697 { value: "status", label: __("Status", "yatra") },
698 { value: "created_at", label: __("Created At", "yatra") },
699 { value: "updated_at", label: __("Updated At", "yatra") },
700 ]}
701 onResetFilters={handleResetFilters}
702 hasFilters={!!hasFilters}
703 placeholder={__("Search categories...", "yatra")}
704 />
705 </CardContent>
706 </Card>
707
708 <ConditionalRender capability="yatra_view_trips">
709 {!isCategoriesError && (
710 <BulkActionToolbar
711 selectedIds={selectedIds}
712 bulkAction={bulkAction}
713 setBulkAction={setBulkAction}
714 onApply={handleBulkApply}
715 onClearSelection={() => setSelectedIds([])}
716 statusFilter={statusFilter}
717 setStatusFilter={(value) => {
718 setStatusFilter(value);
719 setPage(1);
720 setSelectedIds([]);
721 setBulkAction("");
722 }}
723 statusOptions={[
724 {
725 key: "all",
726 label: __("All", "yatra"),
727 count: statusCounts.all,
728 },
729 {
730 key: "publish",
731 label: __("Published", "yatra"),
732 count: statusCounts.publish,
733 },
734 {
735 key: "draft",
736 label: __("Draft", "yatra"),
737 count: statusCounts.draft,
738 },
739 {
740 key: "trash",
741 label: __("Trash", "yatra"),
742 count: statusCounts.trash,
743 },
744 ]}
745 showColumnsDropdown={showColumnsDropdown}
746 setShowColumnsDropdown={setShowColumnsDropdown}
747 columnOptions={[
748 {
749 key: "category",
750 label: __("Category", "yatra"),
751 visible: visibleColumns.category,
752 },
753 {
754 key: "description",
755 label: __("Description", "yatra"),
756 visible: visibleColumns.description,
757 },
758 {
759 key: "age_range",
760 label: __("Age Range", "yatra"),
761 visible: visibleColumns.age_range,
762 },
763 {
764 key: "pricing",
765 label: __("Pricing", "yatra"),
766 visible: visibleColumns.pricing,
767 },
768 {
769 key: "status",
770 label: __("Status", "yatra"),
771 visible: visibleColumns.status,
772 },
773 {
774 key: "dates",
775 label: __("Date", "yatra"),
776 visible: visibleColumns.dates,
777 },
778 {
779 key: "author",
780 label: __("Author", "yatra"),
781 visible: visibleColumns.author,
782 },
783 ]}
784 onToggleColumn={(key) =>
785 toggleColumnVisibility(key as keyof typeof visibleColumns)
786 }
787 bulkMutationPending={bulkMutation.isPending}
788 totalItems={categories.length}
789 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
790 />
791 )}
792
793 <Card>
794 <CardContent className="p-0">
795 <SharedTable
796 data={categories}
797 columns={columns}
798 actions={actions}
799 isLoading={isLoading}
800 isError={isCategoriesError}
801 errorText={__("Error loading traveler categories", "yatra")}
802 errorDescription={__(
803 "We couldn’t connect to the traveler categories service. Please refresh or try again shortly.",
804 "We couldn’t connect to the traveler categories service. Please refresh or try again shortly.",
805 )}
806 errorDetails={errorContext.details || apiErrorMessage}
807 errorRequestInfo={errorContext.requestInfo}
808 onRetry={() =>
809 queryClient.invalidateQueries({
810 queryKey: ["traveler-categories"],
811 })
812 }
813 emptyText={__("No traveler categories found", "yatra")}
814 emptyDescription={
815 hasFilters
816 ? __(
817 "Try adjusting your filters to see more results.",
818 "yatra",
819 )
820 : __(
821 "Get started by creating your first traveler category.",
822 "yatra",
823 )
824 }
825 onCreateClick={
826 can("yatra_edit_trips") ? handleCreateCategory : undefined
827 }
828 onSort={handleSort}
829 getSortIcon={getSortIcon}
830 capability="yatra_view_trips"
831 selectedItemIds={selectedIds}
832 onSelectItem={(id, checked) => {
833 if (checked) {
834 setSelectedIds((prev) => Array.from(new Set([...prev, id])));
835 } else {
836 setSelectedIds((prev) =>
837 prev.filter((existingId) => existingId !== id),
838 );
839 }
840 }}
841 onSelectAll={(checked) => {
842 if (checked) {
843 setSelectedIds(
844 categories.map((cat: TravelerCategory) => cat.id),
845 );
846 } else {
847 setSelectedIds([]);
848 }
849 }}
850 isAllSelected={
851 categories.length > 0 &&
852 selectedIds.length === categories.length
853 }
854 getItemId={(cat: TravelerCategory) => cat.id}
855 getItemStatus={(cat: TravelerCategory) => cat.status}
856 statusFilter={statusFilter}
857 />
858 </CardContent>
859 </Card>
860
861 {total > 0 && (
862 <div className="mt-4">
863 <Pagination
864 currentPage={page}
865 totalPages={totalPages}
866 totalItems={total}
867 itemsPerPage={10}
868 onPageChange={(newPage) => setPage(newPage)}
869 itemName={__("categories", "yatra")}
870 />
871 </div>
872 )}
873 </ConditionalRender>
874 </div>
875 );
876 };
877
878 export default TravelerCategories;
879