PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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.9, at resources/js/pages/TravelerCategories.tsx

882 lines 27.8 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 provider={icon.provider ?? "yatra"}
286 className="w-5 h-5 text-gray-600 dark:text-gray-400"
287 />
288 </div>
289 );
290 };
291
292 const handleEdit = (category: TravelerCategory) => {
293 window.location.href = `${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=edit&id=${category.id}`;
294 };
295
296 const handleDelete = (category: TravelerCategory) => {
297 setDeleteConfirm({ isOpen: true, category });
298 };
299
300 const confirmDelete = () => {
301 if (deleteConfirm.category) {
302 deleteMutation.mutate(deleteConfirm.category.id);
303 }
304 };
305
306 const handleCreateCategory = () => {
307 window.location.href = `${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=create`;
308 };
309
310 const handleResetFilters = () => {
311 setSearchTerm("");
312 setStatusFilter("all");
313 setSortBy("label");
314 setSortOrder("asc");
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("asc");
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 sortBy !== "id" ||
342 sortOrder !== "desc";
343
344 // Bulk selection & column visibility state
345 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
346 const [bulkAction, setBulkAction] = useState("");
347 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
348 const [visibleColumns, setVisibleColumns] = useState({
349 category: true,
350 description: true,
351 age_range: true,
352 pricing: true,
353 status: true,
354 dates: true,
355 author: true,
356 });
357
358 const toggleColumnVisibility = (key: keyof typeof visibleColumns) => {
359 setVisibleColumns((prev) => ({
360 ...prev,
361 [key]: !prev[key],
362 }));
363 };
364
365 // Status counts from stats API (stable across filters)
366 const statusCounts = useMemo(() => {
367 if (statsData) {
368 return {
369 all: statsData.all ?? 0,
370 publish: statsData.publish ?? 0,
371 draft: statsData.draft ?? 0,
372 trash: statsData.trash ?? 0,
373 };
374 }
375
376 return {
377 all: 0,
378 publish: 0,
379 draft: 0,
380 trash: 0,
381 };
382 }, [statsData]);
383
384 // Bulk mutation for status changes and deletes
385 const bulkMutation = useMutation({
386 mutationFn: async ({
387 action,
388 ids,
389 }: {
390 action: string;
391 ids: (string | number)[];
392 }) => {
393 if (action === "delete") {
394 await Promise.all(
395 ids.map((id) => apiClient.delete(`/traveler-categories/${id}`)),
396 );
397 } else {
398 // Status change - fetch each category and update with full payload
399 await Promise.all(
400 ids.map(async (id) => {
401 try {
402 const response = await apiClient.get(
403 `/traveler-categories/${id}`,
404 );
405 const category = response as TravelerCategory;
406 if (!category || !category.label) {
407 return;
408 }
409
410 await apiClient.put(`/traveler-categories/${id}`, {
411 label: category.label,
412 slug: category.slug,
413 description: category.description,
414 age_min: category.age_min,
415 age_max: category.age_max,
416 icon: category.icon,
417 pricing_mode: category.pricing_mode || "per_person",
418 min_pax: category.min_pax ?? null,
419 max_pax: category.max_pax ?? null,
420 status: action,
421 });
422 } catch {
423 throw new Error("bulk_item_failed");
424 }
425 }),
426 );
427 }
428 },
429 onSuccess: (_data, variables) => {
430 queryClient.invalidateQueries({ queryKey: ["traveler-categories"] });
431 queryClient.invalidateQueries({
432 queryKey: ["traveler-categories-stats"],
433 });
434 const msgMap: Record<string, string> = {
435 delete: __(
436 "Selected traveler categories deleted successfully",
437 "yatra",
438 ),
439 trash: __("Selected traveler categories moved to trash", "yatra"),
440 draft: __("Selected traveler categories marked as draft", "yatra"),
441 publish: __("Selected traveler categories published", "yatra"),
442 };
443 showToast(
444 msgMap[variables.action] || __("Bulk action completed", "yatra"),
445 "success",
446 );
447 setSelectedIds([]);
448 setBulkAction("");
449 },
450 onError: (error: any, variables) => {
451 const msgMapError: Record<string, string> = {
452 delete: __("Failed to delete selected traveler categories", "yatra"),
453 trash: __("Failed to move traveler categories to trash", "yatra"),
454 draft: __("Failed to mark traveler categories as draft", "yatra"),
455 publish: __("Failed to publish traveler categories", "yatra"),
456 };
457 showToast(
458 error?.message ||
459 msgMapError[variables.action] ||
460 __("Bulk action failed", "yatra"),
461 "error",
462 );
463 },
464 });
465
466 const handleBulkApply = () => {
467 if (!bulkAction) {
468 showToast(__("Select a bulk action first.", "yatra"), "warning");
469 return;
470 }
471
472 if (selectedIds.length === 0) {
473 showToast(
474 __("Select at least one traveler category.", "yatra"),
475 "warning",
476 );
477 return;
478 }
479
480 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
481 };
482
483 // Columns config for shared Table
484 const columns = [
485 {
486 key: "category",
487 label: __("Category", "yatra"),
488 sortable: true,
489 visible: visibleColumns.category,
490 render: (category: TravelerCategory) => (
491 <div className="flex items-center gap-3">
492 {renderIcon(category.icon)}
493 <div>
494 <a
495 href={`${baseAdminUrl}?page=yatra&subpage=traveler-categories&action=edit&id=${category.id}`}
496 className="font-medium text-gray-900 dark:text-white text-blue-600 dark:text-blue-400 hover:underline underline-offset-2"
497 >
498 {category.label}
499 </a>
500 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 flex items-center gap-2">
501 <span>{category.slug}</span>
502 <span className="text-gray-400 dark:text-gray-500">
503 (ID: {category.id})
504 </span>
505 </div>
506 </div>
507 </div>
508 ),
509 },
510 {
511 key: "description",
512 label: __("Description", "yatra"),
513 visible: visibleColumns.description,
514 render: (category: TravelerCategory) => (
515 <div className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2">
516 {category.description || __("No description", "yatra")}
517 </div>
518 ),
519 },
520 {
521 key: "age_range",
522 label: __("Age Range", "yatra"),
523 visible: visibleColumns.age_range,
524 render: (category: TravelerCategory) => (
525 <span className="text-gray-600 dark:text-gray-400 text-sm">
526 {formatAgeRange(category.age_min, category.age_max)}
527 </span>
528 ),
529 },
530 {
531 key: "pricing",
532 label: __("Pricing", "yatra"),
533 visible: visibleColumns.pricing,
534 render: (category: TravelerCategory) => (
535 <span className="text-gray-600 dark:text-gray-400 text-sm">
536 {formatPricing(category)}
537 </span>
538 ),
539 },
540 {
541 key: "status",
542 label: __("Status", "yatra"),
543 sortable: true,
544 visible: visibleColumns.status,
545 render: (category: TravelerCategory) => getStatusBadge(category.status),
546 },
547 {
548 key: "dates",
549 label: __("Date", "yatra"),
550 sortable: true,
551 visible: visibleColumns.dates,
552 render: (category: TravelerCategory) => (
553 <div className="text-gray-500 dark:text-gray-400 text-sm">
554 <div>{formatDate(category.created_at)}</div>
555 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
556 {__("Updated", "yatra")}: {formatDate(category.updated_at)}
557 </div>
558 </div>
559 ),
560 },
561 {
562 key: "author",
563 label: __("Author", "yatra"),
564 visible: visibleColumns.author,
565 render: (category: TravelerCategory) => (
566 <div className="text-gray-600 dark:text-gray-400 text-sm">
567 <div>{formatUser(category.created_by, category.created_by_name)}</div>
568 <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
569 {__("Updated by", "yatra")}:{" "}
570 {formatUser(category.updated_by, category.updated_by_name)}
571 </div>
572 </div>
573 ),
574 },
575 ];
576
577 const actions = [
578 {
579 key: "edit",
580 label: __("Edit", "yatra"),
581 icon: <Edit className="w-4 h-4" />,
582 onClick: (category: TravelerCategory) => handleEdit(category),
583 condition: () => can("yatra_edit_trips"),
584 },
585 {
586 key: "publish",
587 label: __("Make Published", "yatra"),
588 icon: <Edit className="w-4 h-4" />,
589 onClick: (category: TravelerCategory) =>
590 bulkMutation.mutate({ action: "publish", ids: [category.id] }),
591 condition: (category: TravelerCategory) =>
592 can("yatra_edit_trips") && category.status !== "publish",
593 },
594 {
595 key: "draft",
596 label: __("Make Draft", "yatra"),
597 icon: <Edit className="w-4 h-4" />,
598 onClick: (category: TravelerCategory) =>
599 bulkMutation.mutate({ action: "draft", ids: [category.id] }),
600 condition: (category: TravelerCategory) =>
601 can("yatra_edit_trips") && category.status !== "draft",
602 },
603 {
604 key: "trash",
605 label: __("Move to Trash", "yatra"),
606 icon: <Trash2 className="w-4 h-4" />,
607 onClick: (category: TravelerCategory) =>
608 bulkMutation.mutate({ action: "trash", ids: [category.id] }),
609 variant: "destructive" as const,
610 condition: (category: TravelerCategory) =>
611 can("yatra_edit_trips") && category.status !== "trash",
612 },
613 {
614 key: "delete",
615 label: __("Delete Permanently", "yatra"),
616 icon: <Trash2 className="w-4 h-4" />,
617 onClick: (category: TravelerCategory) => handleDelete(category),
618 variant: "destructive" as const,
619 condition: (category: TravelerCategory) =>
620 can("yatra_delete_trips") && category.status === "trash",
621 },
622 ];
623
624 return (
625 <div className="space-y-3">
626 {/* Confirmation Dialog */}
627 <ConfirmationDialog
628 isOpen={deleteConfirm.isOpen}
629 onClose={() => setDeleteConfirm({ isOpen: false, category: null })}
630 onConfirm={confirmDelete}
631 title={__("Delete Traveler Category", "yatra")}
632 message={
633 deleteConfirm.category
634 ? __(
635 'Are you sure you want to delete "{label}"? This action cannot be undone.',
636 "yatra",
637 ).replace("{label}", deleteConfirm.category.label)
638 : __(
639 "Are you sure you want to delete this traveler category? This action cannot be undone.",
640 "yatra",
641 )
642 }
643 confirmText={__("Delete", "yatra")}
644 cancelText={__("Cancel", "yatra")}
645 variant="danger"
646 isLoading={deleteMutation.isPending}
647 />
648
649 <PageHeader
650 title={__("Traveler Categories", "yatra")}
651 description={__(
652 "Manage pricing categories for different types of travelers",
653 "yatra",
654 )}
655 actionCapability="yatra_edit_trips"
656 actions={
657 <Button
658 onClick={handleCreateCategory}
659 className="flex items-center gap-2"
660 >
661 <Plus className="w-4 h-4" />
662 {__("Add New Category", "yatra")}
663 </Button>
664 }
665 />
666
667 {/* Filters */}
668 <Card>
669 <CardContent className="p-3">
670 <SearchFilterToolbar
671 searchTerm={searchTerm}
672 onSearchChange={setSearchTerm}
673 statusFilter={statusFilter}
674 onStatusChange={(value) => {
675 setStatusFilter(value);
676 setPage(1);
677 }}
678 statusOptions={[
679 { value: "all", label: __("All Status", "yatra") },
680 { value: "publish", label: __("Publish", "yatra") },
681 { value: "draft", label: __("Draft", "yatra") },
682 { value: "trash", label: __("Trash", "yatra") },
683 ]}
684 sortBy={sortBy}
685 onSortByChange={(value) => {
686 setSortBy(value);
687 setSortOrder("asc");
688 setPage(1);
689 }}
690 sortOrder={sortOrder}
691 onSortOrderChange={(order) => {
692 setSortOrder(order);
693 setPage(1);
694 }}
695 sortOptions={[
696 { value: "id", label: __("ID", "yatra") },
697 { value: "label", label: __("Label", "yatra") },
698 { value: "status", label: __("Status", "yatra") },
699 { value: "created_at", label: __("Created At", "yatra") },
700 { value: "updated_at", label: __("Updated At", "yatra") },
701 ]}
702 onResetFilters={handleResetFilters}
703 hasFilters={!!hasFilters}
704 placeholder={__("Search categories...", "yatra")}
705 />
706 </CardContent>
707 </Card>
708
709 <ConditionalRender capability="yatra_view_trips">
710 {!isCategoriesError && (
711 <BulkActionToolbar
712 selectedIds={selectedIds}
713 bulkAction={bulkAction}
714 setBulkAction={setBulkAction}
715 onApply={handleBulkApply}
716 onClearSelection={() => setSelectedIds([])}
717 statusFilter={statusFilter}
718 setStatusFilter={(value) => {
719 setStatusFilter(value);
720 setPage(1);
721 setSelectedIds([]);
722 setBulkAction("");
723 }}
724 statusOptions={[
725 {
726 key: "all",
727 label: __("All", "yatra"),
728 count: statusCounts.all,
729 },
730 {
731 key: "publish",
732 label: __("Published", "yatra"),
733 count: statusCounts.publish,
734 },
735 {
736 key: "draft",
737 label: __("Draft", "yatra"),
738 count: statusCounts.draft,
739 },
740 {
741 key: "trash",
742 label: __("Trash", "yatra"),
743 count: statusCounts.trash,
744 },
745 ]}
746 showColumnsDropdown={showColumnsDropdown}
747 setShowColumnsDropdown={setShowColumnsDropdown}
748 columnOptions={[
749 {
750 key: "category",
751 label: __("Category", "yatra"),
752 visible: visibleColumns.category,
753 },
754 {
755 key: "description",
756 label: __("Description", "yatra"),
757 visible: visibleColumns.description,
758 },
759 {
760 key: "age_range",
761 label: __("Age Range", "yatra"),
762 visible: visibleColumns.age_range,
763 },
764 {
765 key: "pricing",
766 label: __("Pricing", "yatra"),
767 visible: visibleColumns.pricing,
768 },
769 {
770 key: "status",
771 label: __("Status", "yatra"),
772 visible: visibleColumns.status,
773 },
774 {
775 key: "dates",
776 label: __("Date", "yatra"),
777 visible: visibleColumns.dates,
778 },
779 {
780 key: "author",
781 label: __("Author", "yatra"),
782 visible: visibleColumns.author,
783 },
784 ]}
785 onToggleColumn={(key) =>
786 toggleColumnVisibility(key as keyof typeof visibleColumns)
787 }
788 bulkMutationPending={bulkMutation.isPending}
789 totalItems={categories.length}
790 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter).filter(
791 (opt) => opt.value !== "delete" || can("yatra_delete_trips"),
792 )}
793 />
794 )}
795
796 <Card>
797 <CardContent className="p-0">
798 <SharedTable
799 data={categories}
800 columns={columns}
801 actions={actions}
802 isLoading={isLoading}
803 isError={isCategoriesError}
804 errorText={__("Error loading traveler categories", "yatra")}
805 errorDescription={__(
806 "We couldn’t connect to the traveler categories service. Please refresh or try again shortly.",
807 "We couldn’t connect to the traveler categories service. Please refresh or try again shortly.",
808 )}
809 errorDetails={errorContext.details || apiErrorMessage}
810 errorRequestInfo={errorContext.requestInfo}
811 onRetry={() =>
812 queryClient.invalidateQueries({
813 queryKey: ["traveler-categories"],
814 })
815 }
816 emptyText={__("No traveler categories found", "yatra")}
817 emptyDescription={
818 hasFilters
819 ? __(
820 "Try adjusting your filters to see more results.",
821 "yatra",
822 )
823 : __(
824 "Get started by creating your first traveler category.",
825 "yatra",
826 )
827 }
828 onCreateClick={
829 can("yatra_edit_trips") ? handleCreateCategory : undefined
830 }
831 onSort={handleSort}
832 getSortIcon={getSortIcon}
833 capability="yatra_view_trips"
834 selectedItemIds={selectedIds}
835 onSelectItem={(id, checked) => {
836 if (checked) {
837 setSelectedIds((prev) => Array.from(new Set([...prev, id])));
838 } else {
839 setSelectedIds((prev) =>
840 prev.filter((existingId) => existingId !== id),
841 );
842 }
843 }}
844 onSelectAll={(checked) => {
845 if (checked) {
846 setSelectedIds(
847 categories.map((cat: TravelerCategory) => cat.id),
848 );
849 } else {
850 setSelectedIds([]);
851 }
852 }}
853 isAllSelected={
854 categories.length > 0 &&
855 selectedIds.length === categories.length
856 }
857 getItemId={(cat: TravelerCategory) => cat.id}
858 getItemStatus={(cat: TravelerCategory) => cat.status}
859 statusFilter={statusFilter}
860 />
861 </CardContent>
862 </Card>
863
864 {total > 0 && (
865 <div className="mt-4">
866 <Pagination
867 currentPage={page}
868 totalPages={totalPages}
869 totalItems={total}
870 itemsPerPage={10}
871 onPageChange={(newPage) => setPage(newPage)}
872 itemName={__("categories", "yatra")}
873 />
874 </div>
875 )}
876 </ConditionalRender>
877 </div>
878 );
879 };
880
881 export default TravelerCategories;
882