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 / Categories.tsx

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

1,233 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Categories Page
3 * Clean, minimal SaaS-style categories management page with subcategory support
4 */
5
6 import React, { useState, useMemo, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { Plus, Edit, Trash2, ExternalLink } from "lucide-react";
9 import {
10 SearchFilterToolbar,
11 Table as SharedTable,
12 BulkActionToolbar,
13 Pagination,
14 } from "../components/shared";
15 import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions";
16 import { __ } from "../lib/i18n";
17 import { usePermissions } from "../hooks/usePermissions";
18 import { useToast } from "../components/ui/toast";
19 import { fetchSettings } from "../api/settings-api";
20 import { apiClient } from "../lib/api-client";
21 import { getErrorContext } from "../lib/errors";
22 import {
23 buildYatraSinglePublicUrls,
24 isWordPressPlainPermalink,
25 } from "../lib/frontend-permalink-urls";
26 import { Button } from "../components/ui/button";
27 import { Select } from "../components/ui/select";
28 import { PageHeader } from "../components/common/PageHeader";
29 import { Card, CardContent } from "../components/ui/card";
30 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
31 import { IconSelector } from "../components/ui/icon-selector";
32 import type { IconPickerValue } from "../components/ui/icon-picker";
33
34 interface Category {
35 id: number;
36 name: string;
37 slug: string;
38 description?: string;
39 icon?: IconPickerValue | null;
40 trip_count?: number;
41 parent_id?: number | null;
42 parent_name?: string | null;
43 status: string;
44 metadata?: {
45 seo_title?: string;
46 seo_description?: string;
47 seo_keywords?: string;
48 [key: string]: any;
49 };
50 created_at: string;
51 updated_at: string;
52 created_by: number;
53 updated_by: number;
54 created_by_name?: string;
55 updated_by_name?: string;
56 subcategories?: Category[];
57 __subOnly?: boolean;
58 }
59
60 export const Categories: React.FC = () => {
61 const { can } = usePermissions();
62 const [searchTerm, setSearchTerm] = useState("");
63 const [statusFilter, setStatusFilter] = useState("all");
64 const [parentFilter, setParentFilter] = useState<
65 "all" | "top-level" | "subcategories"
66 >("all");
67 const [sortBy, setSortBy] = useState("name");
68 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
69 const [page, setPage] = useState(1);
70 const [expandedCategories, setExpandedCategories] = useState<Set<number>>(
71 new Set(),
72 );
73 const [deleteConfirm, setDeleteConfirm] = useState<{
74 isOpen: boolean;
75 category: Category | null;
76 }>({
77 isOpen: false,
78 category: null,
79 });
80 // Bulk action state
81 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
82 const [bulkAction, setBulkAction] = useState("");
83 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
84 const [visibleColumns, setVisibleColumns] = useState(() => {
85 const defaultColumns = {
86 name: true,
87 description: true,
88 trips: true,
89 status: true,
90 seo_title: false,
91 seo_description: false,
92 seo_keywords: false,
93 created_at: true,
94 };
95 const saved = localStorage.getItem("yatra_categories_columns_v2");
96 return saved ? { ...defaultColumns, ...JSON.parse(saved) } : defaultColumns;
97 });
98
99 const queryClient = useQueryClient();
100 const { showToast } = useToast();
101
102 // Build query params
103 const queryParams = useMemo(() => {
104 const params: Record<string, any> = {
105 page,
106 per_page: 50, // Get more items to handle hierarchical display
107 orderby: sortBy,
108 order: sortOrder,
109 hierarchical: true, // Request hierarchical data
110 };
111
112 if (searchTerm) {
113 params.search = searchTerm;
114 }
115
116 if (statusFilter !== "all") {
117 params.status = statusFilter;
118 }
119
120 if (parentFilter === "top-level") {
121 params.parent_id = null;
122 } else if (parentFilter === "subcategories") {
123 // We'll filter client-side for subcategories
124 }
125
126 return params;
127 }, [searchTerm, statusFilter, parentFilter, sortBy, sortOrder, page]);
128
129 // Fetch categories from API
130 const { data, isLoading, error } = useQuery({
131 queryKey: ["trip-categories", queryParams],
132 queryFn: async () => {
133 try {
134 const response = await apiClient.get("/trip-categories", {
135 params: queryParams,
136 });
137 return response;
138 } catch (error: any) {
139 showToast(
140 error?.message || __("Failed to load categories", "yatra"),
141 "error",
142 );
143 throw error;
144 }
145 },
146 enabled: can("yatra_view_trips"),
147 });
148
149 // Delete mutation
150 const deleteMutation = useMutation({
151 mutationFn: async (id: number) => {
152 return await apiClient.delete(`/trip-categories/${id}`);
153 },
154 onSuccess: () => {
155 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
156 showToast(__("Category deleted successfully", "yatra"), "success");
157 setDeleteConfirm({ isOpen: false, category: null });
158 },
159 onError: (error: any) => {
160 showToast(
161 error?.message || __("Failed to delete category", "yatra"),
162 "error",
163 );
164 },
165 });
166
167 // eslint-disable-next-line react-hooks/exhaustive-deps
168 const categories = data?.data || [];
169 const total = data?.total || 0;
170 const totalPages = Math.ceil(total / 50);
171 const errorContext = getErrorContext(error);
172
173 // Toggle column visibility
174 const toggleColumn = (columnKey: string) => {
175 const newVisibleColumns = {
176 ...visibleColumns,
177 [columnKey]: !visibleColumns[columnKey],
178 };
179
180 setVisibleColumns(newVisibleColumns);
181 localStorage.setItem(
182 "yatra_categories_columns_v2",
183 JSON.stringify(newVisibleColumns),
184 );
185 };
186
187 // Status counts for bulk toolbar - count both parents and children
188 const statusCounts = useMemo(() => {
189 const counts = {
190 all: 0,
191 publish: 0,
192 draft: 0,
193 trash: 0,
194 };
195
196 const countCategory = (cat: Category) => {
197 counts.all++;
198 if (cat.status === "publish") counts.publish++;
199 else if (cat.status === "draft") counts.draft++;
200 else if (cat.status === "trash") counts.trash++;
201 };
202
203 categories.forEach((cat: Category) => {
204 // Count parent category
205 countCategory(cat);
206
207 // Count subcategories
208 if (cat.subcategories && Array.isArray(cat.subcategories)) {
209 cat.subcategories.forEach((sub: Category) => {
210 countCategory(sub);
211 });
212 }
213 });
214
215 return counts;
216 }, [categories]);
217
218 // Bulk mutation for status changes and deletes
219 const bulkMutation = useMutation({
220 mutationFn: async ({
221 action,
222 ids,
223 }: {
224 action: string;
225 ids: (string | number)[];
226 }) => {
227 if (action === "delete") {
228 await Promise.all(
229 ids.map((id) => apiClient.delete(`/trip-categories/${id}`)),
230 );
231 } else {
232 // Status change - fetch each category and update with full payload
233 await Promise.all(
234 ids.map(async (id) => {
235 try {
236 const categoryResponse = await apiClient.get(
237 `/trip-categories/${id}`,
238 );
239 const category = categoryResponse;
240 if (!category || !category.name) {
241 return;
242 }
243
244 await apiClient.put(`/trip-categories/${id}`, {
245 name: category.name,
246 slug: category.slug,
247 description: category.description,
248 icon: category.icon,
249 parent_id: category.parent_id,
250 status: action,
251 });
252 } catch {
253 throw new Error("bulk_item_failed");
254 }
255 }),
256 );
257 }
258 },
259 onSuccess: (_data, variables) => {
260 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
261 const msgMap: Record<string, string> = {
262 delete: __("Selected categories deleted successfully", "yatra"),
263 trash: __("Selected categories moved to trash", "yatra"),
264 draft: __("Selected categories marked as draft", "yatra"),
265 publish: __("Selected categories published", "yatra"),
266 };
267 showToast(
268 msgMap[variables.action] || __("Bulk action completed", "yatra"),
269 "success",
270 );
271 setSelectedIds([]);
272 setBulkAction("");
273 },
274 onError: (error: any, variables) => {
275 const msgMapError: Record<string, string> = {
276 delete: __("Failed to delete selected categories", "yatra"),
277 trash: __("Failed to move categories to trash", "yatra"),
278 draft: __("Failed to mark categories as draft", "yatra"),
279 publish: __("Failed to publish categories", "yatra"),
280 };
281 showToast(
282 error?.message ||
283 msgMapError[variables.action] ||
284 __("Bulk action failed", "yatra"),
285 "error",
286 );
287 },
288 });
289
290 const handleBulkApply = () => {
291 if (!bulkAction) {
292 showToast(__("Select a bulk action first.", "yatra"), "warning");
293 return;
294 }
295
296 if (selectedIds.length === 0) {
297 showToast(__("Select at least one category.", "yatra"), "warning");
298 return;
299 }
300
301 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
302 };
303
304 // Process categories for hierarchical display
305 const processedCategories = useMemo(() => {
306 if (!Array.isArray(categories) || categories.length === 0) {
307 return [];
308 }
309
310 const normalized = categories.map((cat: Category) => ({
311 ...cat,
312 subcategories: Array.isArray(cat.subcategories) ? cat.subcategories : [],
313 }));
314
315 const term = searchTerm.trim().toLowerCase();
316
317 const matchesSearch = (cat: Category): boolean => {
318 if (!term) return true;
319 const name = cat.name?.toLowerCase() || "";
320 const slug = cat.slug?.toLowerCase() || "";
321 const desc = cat.description?.toLowerCase() || "";
322 return name.includes(term) || slug.includes(term) || desc.includes(term);
323 };
324
325 // Apply status + search filtering to both parents and children
326 const filterByStatusAndSearch = (items: Category[]): Category[] => {
327 return items
328 .map((cat: Category) => {
329 // Filter subcategories by status + search
330 const filteredSubcategories = cat.subcategories
331 ? cat.subcategories.filter((sub: Category) => {
332 const statusOk =
333 statusFilter === "all" || sub.status === statusFilter;
334 const searchOk = matchesSearch(sub);
335 return statusOk && searchOk;
336 })
337 : [];
338
339 return {
340 ...cat,
341 subcategories: filteredSubcategories,
342 };
343 })
344 .filter((cat: Category) => {
345 // When no status/search filters, keep all
346 if (statusFilter === "all" && !term) return true;
347
348 const statusOk =
349 statusFilter === "all" || cat.status === statusFilter;
350 const searchOk = matchesSearch(cat);
351 const hasMatchingChildren =
352 cat.subcategories && cat.subcategories.length > 0;
353
354 // Show parent if it itself matches filters OR has children that match
355 return (statusOk && searchOk) || hasMatchingChildren;
356 });
357 };
358
359 const filtered = filterByStatusAndSearch(normalized);
360
361 if (parentFilter === "subcategories") {
362 const onlySubs: Category[] = filtered.flatMap((cat: Category) =>
363 (cat.subcategories || []).map((sub) => ({
364 ...sub,
365 parent_name: cat.name,
366 __subOnly: true,
367 subcategories: [],
368 })),
369 );
370 return onlySubs;
371 }
372
373 if (parentFilter === "top-level") {
374 return filtered.map((cat) => ({
375 ...cat,
376 subcategories: [], // Don't show subcategories when filtering for top-level only
377 }));
378 }
379
380 return filtered;
381 }, [categories, parentFilter, statusFilter, searchTerm]);
382
383 // Fetch settings for permalink handling
384 const { data: settings } = useQuery({
385 queryKey: ["settings"],
386 queryFn: async () => {
387 try {
388 const response = await fetchSettings();
389 return response;
390 } catch (error) {
391 return null;
392 }
393 },
394 enabled: can("yatra_access_admin"),
395 staleTime: 5 * 60 * 1000, // Cache for 5 minutes
396 });
397
398 // Expand all parent categories that have subcategories by default
399 useEffect(() => {
400 if (expandedCategories.size > 0) return;
401
402 const initialExpanded = new Set<number>();
403 processedCategories.forEach((cat: Category) => {
404 if (!cat.__subOnly && cat.subcategories && cat.subcategories.length > 0) {
405 initialExpanded.add(cat.id);
406 }
407 });
408
409 if (initialExpanded.size > 0) {
410 setExpandedCategories(initialExpanded);
411 }
412 }, [processedCategories, expandedCategories.size]);
413
414 const formatDate = (dateString: string) => {
415 if (!dateString) return __("N/A", "yatra");
416 try {
417 const date = new Date(dateString);
418 return date.toLocaleDateString("en-US", {
419 month: "short",
420 day: "numeric",
421 year: "numeric",
422 hour: "2-digit",
423 minute: "2-digit",
424 });
425 } catch (e) {
426 return dateString;
427 }
428 };
429
430 const getStatusBadge = (status: string) => {
431 const statusMap: Record<string, { className: string; label: string }> = {
432 publish: {
433 className:
434 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
435 label: __("Publish", "yatra"),
436 },
437 draft: {
438 className:
439 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
440 label: __("Draft", "yatra"),
441 },
442 trash: {
443 className:
444 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
445 label: __("Trash", "yatra"),
446 },
447 };
448
449 const statusInfo = statusMap[status] || {
450 className:
451 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
452 label: status,
453 };
454
455 return (
456 <span
457 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
458 >
459 {statusInfo.label}
460 </span>
461 );
462 };
463
464 const renderIcon = (icon: IconPickerValue | null | undefined) => {
465 if (!icon) {
466 return (
467 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
468 <span className="text-xs text-gray-400 dark:text-gray-500">—</span>
469 </div>
470 );
471 }
472
473 if (icon.type === "image") {
474 return (
475 <img
476 src={icon.value}
477 alt=""
478 className="w-8 h-8 rounded object-cover"
479 onError={(e) => {
480 const target = e.target as HTMLImageElement;
481 target.style.display = "none";
482 if (target.parentElement) {
483 target.parentElement.innerHTML =
484 '<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>';
485 }
486 }}
487 />
488 );
489 }
490
491 return (
492 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
493 <IconSelector
494 iconName={icon.value}
495 provider={icon.provider ?? "yatra"}
496 className="w-5 h-5 text-gray-600 dark:text-gray-400"
497 />
498 </div>
499 );
500 };
501
502 const toggleExpand = (categoryId: string | number) => {
503 const id =
504 typeof categoryId === "string" ? parseInt(categoryId, 10) : categoryId;
505 setExpandedCategories((prev) => {
506 const newSet = new Set(prev);
507 if (newSet.has(id)) {
508 newSet.delete(id);
509 } else {
510 newSet.add(id);
511 }
512 return newSet;
513 });
514 };
515
516 const handleEdit = (category: Category) => {
517 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
518 };
519
520 const handleView = async (category: Category) => {
521 const categorySlug = category.slug || "";
522 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
523 entity: "category",
524 slug: categorySlug,
525 bases: settings as Record<string, unknown> | null,
526 });
527
528 if (isWordPressPlainPermalink()) {
529 if (!categorySlug) {
530 showToast(__("Category slug is missing", "yatra"), "error");
531 return;
532 }
533 window.open(plainUrl, "_blank", "noopener,noreferrer");
534 return;
535 }
536
537 let apiPermalink = (category as any)?.permalink || (category as any)?.url;
538
539 if (!apiPermalink && category.id) {
540 try {
541 const detail = await apiClient.get(`/trip-categories/${category.id}`);
542 apiPermalink =
543 (detail as any)?.permalink || (detail as any)?.url || apiPermalink;
544 } catch (e) {
545 // fall through to prettyUrl
546 }
547 }
548
549 if (apiPermalink) {
550 window.open(apiPermalink, "_blank", "noopener,noreferrer");
551 return;
552 }
553
554 if (!categorySlug) {
555 showToast(__("Category slug is missing", "yatra"), "error");
556 return;
557 }
558
559 window.open(prettyUrl, "_blank", "noopener,noreferrer");
560 };
561
562 const confirmDelete = () => {
563 if (deleteConfirm.category) {
564 deleteMutation.mutate(deleteConfirm.category.id);
565 }
566 };
567
568 const handleCreateCategory = () => {
569 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=create`;
570 };
571
572 const handleResetFilters = () => {
573 setSearchTerm("");
574 setStatusFilter("all");
575 setParentFilter("all");
576 setSortBy("name");
577 setSortOrder("asc");
578 setPage(1);
579 };
580
581 const hasFilters =
582 searchTerm ||
583 statusFilter !== "all" ||
584 parentFilter !== "all" ||
585 sortBy !== "name" ||
586 sortOrder !== "asc";
587
588 // Define columns for the shared table
589 const columns = useMemo(
590 () => [
591 {
592 key: "name",
593 label: __("Name", "yatra"),
594 sortable: true,
595 visible: visibleColumns.name,
596 render: (category: Category) => {
597 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
598 return (
599 <div className="flex items-start gap-2">
600 <div className="flex-1 min-w-0">
601 <div className="flex items-center gap-1">
602 <a
603 href={editUrl}
604 onClick={(e) => {
605 e.preventDefault();
606 e.stopPropagation();
607 handleEdit(category);
608 }}
609 className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline-offset-2 hover:underline truncate"
610 >
611 {category.name}
612 </a>
613 {can("yatra_view_trips") && category.status !== "trash" && (
614 <button
615 type="button"
616 onClick={(e) => {
617 e.stopPropagation();
618 handleView(category);
619 }}
620 className="ml-1 inline-flex items-center justify-center rounded-full p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800"
621 title={__("View category in new tab", "yatra")}
622 >
623 <ExternalLink className="w-3.5 h-3.5" />
624 </button>
625 )}
626 </div>
627 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
628 {category.slug}
629 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
630 (ID: {category.id})
631 </span>
632 </div>
633 </div>
634 </div>
635 );
636 },
637 },
638 {
639 key: "trips",
640 label: __("Trips", "yatra"),
641 visible: visibleColumns.trips,
642 render: (category: Category) => (
643 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
644 {typeof category.trip_count === "number" ? category.trip_count : 0}
645 </span>
646 ),
647 },
648 {
649 key: "description",
650 label: __("Description", "yatra"),
651 visible: visibleColumns.description,
652 render: (category: Category) => (
653 <span
654 className={`block max-w-[300px] truncate ${
655 category.status === "trash" || statusFilter === "trash"
656 ? "text-gray-400 dark:text-gray-600"
657 : "text-gray-600 dark:text-gray-400"
658 }`}
659 title={category.description || ""}
660 >
661 {category.description || __("No description", "yatra")}
662 </span>
663 ),
664 },
665 {
666 key: "seo_title",
667 label: __("SEO Title", "yatra"),
668 visible: visibleColumns.seo_title,
669 render: (category: Category) => {
670 return (
671 <span
672 className={
673 category.status === "trash"
674 ? "text-gray-400 dark:text-gray-600"
675 : "text-gray-600 dark:text-gray-400"
676 }
677 >
678 {category.metadata?.seo_title || __("Not set", "yatra")}
679 </span>
680 );
681 },
682 },
683 {
684 key: "seo_description",
685 label: __("SEO Description", "yatra"),
686 visible: visibleColumns.seo_description,
687 render: (category: Category) => {
688 return (
689 <span
690 className={
691 category.status === "trash"
692 ? "text-gray-400 dark:text-gray-600"
693 : "text-gray-600 dark:text-gray-400"
694 }
695 title={category.metadata?.seo_description || ""}
696 >
697 {category.metadata?.seo_description
698 ? category.metadata.seo_description.length > 50
699 ? category.metadata.seo_description.substring(0, 50) + "..."
700 : category.metadata.seo_description
701 : __("Not set", "yatra")}
702 </span>
703 );
704 },
705 },
706 {
707 key: "seo_keywords",
708 label: __("SEO Keywords", "yatra"),
709 visible: visibleColumns.seo_keywords,
710 render: (category: Category) => {
711 return (
712 <span
713 className={
714 category.status === "trash"
715 ? "text-gray-400 dark:text-gray-600"
716 : "text-gray-600 dark:text-gray-400"
717 }
718 title={category.metadata?.seo_keywords || ""}
719 >
720 {category.metadata?.seo_keywords
721 ? category.metadata.seo_keywords.length > 30
722 ? category.metadata.seo_keywords.substring(0, 30) + "..."
723 : category.metadata.seo_keywords
724 : __("Not set", "yatra")}
725 </span>
726 );
727 },
728 },
729 {
730 key: "status",
731 label: __("Status", "yatra"),
732 sortable: true,
733 visible: visibleColumns.status,
734 render: (category: Category) => {
735 return (
736 <span
737 className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
738 category.status === "publish"
739 ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
740 : category.status === "draft"
741 ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
742 : "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300"
743 }`}
744 >
745 {category.status === "publish"
746 ? __("Published", "yatra")
747 : category.status === "draft"
748 ? __("Draft", "yatra")
749 : category.status === "trash"
750 ? __("Trash", "yatra")
751 : category.status}
752 </span>
753 );
754 },
755 },
756 {
757 key: "created_at",
758 label: __("Created", "yatra"),
759 sortable: true,
760 visible: visibleColumns.created_at,
761 },
762 ],
763 // eslint-disable-next-line react-hooks/exhaustive-deps
764 [visibleColumns],
765 );
766
767 // Status mutation for individual actions
768 const statusMutation = useMutation({
769 mutationFn: async ({ id, status }: { id: number; status: string }) => {
770 const categoryResponse = await apiClient.get(`/trip-categories/${id}`);
771 const category = categoryResponse as Category;
772
773 return apiClient.put(`/trip-categories/${id}`, {
774 name: category.name,
775 slug: category.slug,
776 description: category.description,
777 icon: category.icon,
778 parent_id: category.parent_id,
779 status,
780 });
781 },
782 onSuccess: (_data, variables) => {
783 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
784 const msgMap: Record<string, string> = {
785 trash: __("Category moved to trash", "yatra"),
786 draft: __("Category marked as draft", "yatra"),
787 publish: __("Category published", "yatra"),
788 };
789 const msg =
790 msgMap[variables.status] ||
791 __("Category updated successfully", "yatra");
792 showToast(msg, "success");
793 },
794 onError: (error: any, variables) => {
795 const msgMapError: Record<string, string> = {
796 trash: __("Failed to move category to trash", "yatra"),
797 draft: __("Failed to mark category as draft", "yatra"),
798 publish: __("Failed to publish category", "yatra"),
799 };
800 const fallback =
801 msgMapError[variables.status] ||
802 __("Failed to update category", "yatra");
803 showToast(error?.message || fallback, "error");
804 },
805 });
806
807 // Define actions for the shared table - dynamic based on item status
808 const actions = [
809 {
810 key: "view",
811 label: __("View (frontend)", "yatra"),
812 icon: <ExternalLink className="w-4 h-4" />,
813 onClick: (category: Category) => {
814 // Open category in new tab
815 const categoryUrl = `${window.yatraAdmin?.siteUrl || ""}/trip-category/${category.slug}`;
816 window.open(categoryUrl, "_blank");
817 },
818 condition: (category: Category) => category.status !== "trash", // Hide for trash categories
819 },
820 {
821 key: "edit",
822 label: __("Edit", "yatra"),
823 icon: <Edit className="w-4 h-4" />,
824 onClick: (category: Category) => handleEdit(category),
825 condition: () => can("yatra_edit_trips"),
826 },
827 {
828 key: "publish",
829 label: __("Make Published", "yatra"),
830 icon: <Edit className="w-4 h-4" />,
831 onClick: (category: Category) =>
832 statusMutation.mutate({ id: category.id, status: "publish" }),
833 condition: (category: Category) =>
834 can("yatra_edit_trips") && category.status !== "publish",
835 },
836 {
837 key: "draft",
838 label: __("Make Draft", "yatra"),
839 icon: <Edit className="w-4 h-4" />,
840 onClick: (category: Category) =>
841 statusMutation.mutate({ id: category.id, status: "draft" }),
842 condition: (category: Category) =>
843 can("yatra_edit_trips") && category.status !== "draft",
844 },
845 {
846 key: "trash",
847 label: __("Move to Trash", "yatra"),
848 icon: <Trash2 className="w-4 h-4" />,
849 onClick: (category: Category) =>
850 statusMutation.mutate({ id: category.id, status: "trash" }),
851 variant: "destructive" as const,
852 condition: (category: Category) =>
853 can("yatra_edit_trips") && category.status !== "trash",
854 },
855 {
856 key: "delete",
857 label: __("Delete Permanently", "yatra"),
858 icon: <Trash2 className="w-4 h-4" />,
859 onClick: (category: Category) =>
860 setDeleteConfirm({ isOpen: true, category }),
861 variant: "destructive" as const,
862 condition: (category: Category) =>
863 can("yatra_edit_trips") && category.status === "trash",
864 },
865 ];
866
867 // Custom row content renderer for hierarchical display
868 const renderRowContent = (
869 category: Category,
870 _index: number,
871 isChild = false,
872 ): React.ReactNode[] => {
873 // Get visible columns and render their content
874 const visibleColumns = columns.filter((col) => col.visible !== false);
875
876 return visibleColumns.map((column) => {
877 // Handle each column based on its key
878 switch (column.key) {
879 case "name":
880 return (
881 <div className="flex items-center gap-2">
882 {renderIcon(category.icon)}
883 <div>
884 <div className="font-medium text-gray-900 dark:text-white flex items-center gap-1">
885 {isChild && <span className="text-gray-400 mr-1">└─</span>}
886 <a
887 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`}
888 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer"
889 >
890 {category.name}
891 </a>
892 {can("yatra_view_trips") && category.status !== "trash" && (
893 <button
894 type="button"
895 onClick={(e) => {
896 e.stopPropagation();
897 handleView(category);
898 }}
899 className="ml-1 inline-flex items-center justify-center rounded-full p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800"
900 title={__("View category in new tab", "yatra")}
901 >
902 <ExternalLink className="w-3.5 h-3.5" />
903 </button>
904 )}
905 </div>
906 <div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
907 <span>{category.slug}</span>
908 <span className="text-[11px] text-gray-400 dark:text-gray-500">
909 ({__("ID:", "yatra")} {category.id})
910 </span>
911 </div>
912 {category.parent_name && (
913 <div className="text-xs text-gray-500 dark:text-gray-400">
914 {__("Parent:", "yatra")} {category.parent_name}
915 </div>
916 )}
917 </div>
918 </div>
919 );
920
921 case "trips":
922 return (
923 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
924 {typeof category.trip_count === "number"
925 ? category.trip_count
926 : 0}
927 </span>
928 );
929
930 case "description":
931 return (
932 <div className="max-w-xs truncate text-sm text-gray-600 dark:text-gray-400">
933 {category.description || "—"}
934 </div>
935 );
936
937 case "status":
938 return getStatusBadge(category.status);
939
940 case "created_at":
941 return (
942 <span className="text-sm text-gray-600 dark:text-gray-400">
943 {formatDate(category.created_at)}
944 </span>
945 );
946
947 // SEO columns - use their render functions
948 case "seo_title":
949 case "seo_description":
950 case "seo_keywords":
951 return column.render ? column.render(category) : null;
952
953 default:
954 // Fallback for any other columns
955 return column.render
956 ? column.render(category)
957 : (category as any)[column.key];
958 }
959 });
960 };
961
962 return (
963 <div className="space-y-3">
964 <ConfirmationDialog
965 isOpen={deleteConfirm.isOpen}
966 onClose={() => setDeleteConfirm({ isOpen: false, category: null })}
967 onConfirm={confirmDelete}
968 title={__("Delete Category", "yatra")}
969 message={
970 deleteConfirm.category
971 ? __(
972 'Are you sure you want to delete "{name}"? This action cannot be undone.',
973 "yatra",
974 ).replace("{name}", deleteConfirm.category.name)
975 : __(
976 "Are you sure you want to delete this category? This action cannot be undone.",
977 "yatra",
978 )
979 }
980 confirmText={__("Delete Permanently", "yatra")}
981 cancelText={__("Cancel", "yatra")}
982 variant="danger"
983 isLoading={deleteMutation.isPending}
984 />
985
986 <PageHeader
987 title={__("Categories", "yatra")}
988 description={__("Manage trip categories and subcategories", "yatra")}
989 actionCapability="yatra_edit_trips"
990 actions={
991 <Button
992 onClick={handleCreateCategory}
993 className="flex items-center gap-2"
994 >
995 <Plus className="w-4 h-4" />
996 {__("Add New Category", "yatra")}
997 </Button>
998 }
999 />
1000
1001 {/* Filters */}
1002 <Card>
1003 <CardContent className="p-3">
1004 <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
1005 {/* Shared search + status + sort toolbar - give it more space on large screens */}
1006 <div className="min-w-0 w-full lg:flex-[4]">
1007 <SearchFilterToolbar
1008 searchTerm={searchTerm}
1009 onSearchChange={setSearchTerm}
1010 statusFilter={statusFilter}
1011 onStatusChange={(value) => {
1012 setStatusFilter(value);
1013 setPage(1);
1014 }}
1015 statusOptions={[
1016 { value: "all", label: __("All Status", "yatra") },
1017 { value: "publish", label: __("Publish", "yatra") },
1018 { value: "draft", label: __("Draft", "yatra") },
1019 { value: "trash", label: __("Trash", "yatra") },
1020 ]}
1021 sortBy={sortBy}
1022 onSortByChange={(value) => {
1023 setSortBy(value);
1024 setSortOrder("asc");
1025 setPage(1);
1026 }}
1027 sortOrder={sortOrder}
1028 onSortOrderChange={(order) => {
1029 setSortOrder(order);
1030 setPage(1);
1031 }}
1032 sortOptions={[
1033 { value: "name", label: __("Name", "yatra") },
1034 { value: "status", label: __("Status", "yatra") },
1035 { value: "created_at", label: __("Created", "yatra") },
1036 ]}
1037 onResetFilters={handleResetFilters}
1038 hasFilters={!!hasFilters}
1039 placeholder={__("Search categories", "yatra")}
1040 />
1041 </div>
1042
1043 {/* Parent filter - constrained width on large screens */}
1044 <div className="w-full lg:w-48 xl:w-56 lg:flex-none">
1045 <Select
1046 value={parentFilter}
1047 onChange={(e) =>
1048 setParentFilter(
1049 e.target.value as "all" | "top-level" | "subcategories",
1050 )
1051 }
1052 className="w-full text-sm truncate"
1053 >
1054 <option value="all">{__("All Categories", "yatra")}</option>
1055 <option value="top-level">
1056 {__("Top Level Only", "yatra")}
1057 </option>
1058 <option value="subcategories">
1059 {__("Subcategories Only", "yatra")}
1060 </option>
1061 </Select>
1062 </div>
1063 </div>
1064 </CardContent>
1065 </Card>
1066
1067 {/* Bulk Actions */}
1068 <BulkActionToolbar
1069 selectedIds={selectedIds}
1070 bulkAction={bulkAction}
1071 setBulkAction={setBulkAction}
1072 onApply={handleBulkApply}
1073 onClearSelection={() => setSelectedIds([])}
1074 statusFilter={statusFilter}
1075 setStatusFilter={(value) => {
1076 setStatusFilter(value);
1077 setPage(1);
1078 setSelectedIds([]);
1079 setBulkAction("");
1080 }}
1081 statusOptions={[
1082 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
1083 {
1084 key: "publish",
1085 label: __("Published", "yatra"),
1086 count: statusCounts.publish,
1087 },
1088 {
1089 key: "draft",
1090 label: __("Draft", "yatra"),
1091 count: statusCounts.draft,
1092 },
1093 {
1094 key: "trash",
1095 label: __("Trash", "yatra"),
1096 count: statusCounts.trash,
1097 },
1098 ]}
1099 showColumnsDropdown={showColumnsDropdown}
1100 setShowColumnsDropdown={setShowColumnsDropdown}
1101 columnOptions={[
1102 {
1103 key: "name",
1104 label: __("Category", "yatra"),
1105 visible: visibleColumns.name,
1106 },
1107 {
1108 key: "description",
1109 label: __("Description", "yatra"),
1110 visible: visibleColumns.description,
1111 },
1112 {
1113 key: "trips",
1114 label: __("Trips", "yatra"),
1115 visible: visibleColumns.trips,
1116 },
1117 {
1118 key: "seo_title",
1119 label: __("SEO Title", "yatra"),
1120 visible: visibleColumns.seo_title,
1121 },
1122 {
1123 key: "seo_description",
1124 label: __("SEO Description", "yatra"),
1125 visible: visibleColumns.seo_description,
1126 },
1127 {
1128 key: "seo_keywords",
1129 label: __("SEO Keywords", "yatra"),
1130 visible: visibleColumns.seo_keywords,
1131 },
1132 {
1133 key: "status",
1134 label: __("Status", "yatra"),
1135 visible: visibleColumns.status,
1136 },
1137 {
1138 key: "created_at",
1139 label: __("Created Date", "yatra"),
1140 visible: visibleColumns.created_at,
1141 },
1142 ]}
1143 onToggleColumn={toggleColumn}
1144 bulkMutationPending={bulkMutation.isPending}
1145 totalItems={processedCategories.length}
1146 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
1147 />
1148
1149 {/* Table */}
1150 <Card>
1151 <CardContent className="p-0">
1152 {null}
1153
1154 <SharedTable
1155 key={`categories-table-${JSON.stringify(visibleColumns)}`}
1156 data={processedCategories}
1157 columns={columns}
1158 actions={actions}
1159 isLoading={isLoading}
1160 isError={!!error}
1161 errorText={__("Error loading categories", "yatra")}
1162 errorDescription={__(
1163 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1164 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1165 )}
1166 onRetry={() =>
1167 queryClient.invalidateQueries({ queryKey: ["trip-categories"] })
1168 }
1169 errorDetails={errorContext.details}
1170 errorRequestInfo={errorContext.requestInfo}
1171 emptyText={__("No categories found", "yatra")}
1172 emptyDescription={__(
1173 "Organize trips into categories to help travelers find the perfect fit.",
1174 "Organize trips into categories to help travelers find the perfect fit.",
1175 )}
1176 onCreateClick={handleCreateCategory}
1177 selectedItemIds={selectedIds}
1178 onSelectItem={(id: string | number, checked: boolean) => {
1179 if (checked) {
1180 setSelectedIds([...selectedIds, id]);
1181 } else {
1182 setSelectedIds(
1183 selectedIds.filter((selectedId) => selectedId !== id),
1184 );
1185 }
1186 }}
1187 onSelectAll={(checked: boolean) => {
1188 if (checked) {
1189 setSelectedIds(
1190 processedCategories.map((cat: Category) => cat.id),
1191 );
1192 } else {
1193 setSelectedIds([]);
1194 }
1195 }}
1196 isAllSelected={
1197 processedCategories.length > 0 &&
1198 selectedIds.length === processedCategories.length
1199 }
1200 getItemId={(category: Category) => category.id}
1201 getItemStatus={(category: Category) => category.status}
1202 statusFilter={statusFilter}
1203 skeletonRows={5}
1204 capability="yatra_view_trips"
1205 // Hierarchical props
1206 isHierarchical={true}
1207 expandedIds={expandedCategories}
1208 onToggleExpand={toggleExpand}
1209 getChildren={(category: Category) => category.subcategories || []}
1210 renderRowContent={renderRowContent}
1211 />
1212 </CardContent>
1213 </Card>
1214
1215 {/* Pagination */}
1216 {total > 0 && (
1217 <div className="mt-4">
1218 <Pagination
1219 currentPage={page}
1220 totalPages={totalPages}
1221 totalItems={total}
1222 itemsPerPage={10}
1223 onPageChange={(newPage) => setPage(newPage)}
1224 itemName={__("categories", "yatra")}
1225 />
1226 </div>
1227 )}
1228 </div>
1229 );
1230 };
1231
1232 export default Categories;
1233