PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.7
Yatra – Travel Booking & Tour Operator Software v3.0.2.7
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 / Trips.tsx

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

1,855 lines 59.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * All Trips Page
3 * Clean, minimal SaaS-style trips management page
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowDown,
10 ArrowUp,
11 Archive,
12 Copy,
13 ExternalLink,
14 Calendar,
15 Users,
16 Activity,
17 Route,
18 FolderTree,
19 TrendingUp,
20 Plus,
21 Search,
22 X,
23 ArrowUpDown,
24 RefreshCw,
25 } from "lucide-react";
26 import { __ } from "../lib/i18n";
27 import { usePermissions } from "../hooks/usePermissions";
28 import { Button } from "../components/ui/button";
29 import { Input } from "../components/ui/input";
30 import { Select } from "../components/ui/select";
31 import { PageHeader } from "../components/common/PageHeader";
32 import { Card, CardContent } from "../components/ui/card";
33 import { Badge } from "../components/ui/badge";
34 import { ConditionalRender } from "../components/ui/conditional-render";
35 import { Edit, Trash2 } from "lucide-react";
36 import { HelpText } from "../components/ui/help-text";
37 import { fetchSettings } from "../api/settings-api";
38 import { apiClient } from "../lib/api-client";
39 import { getErrorContext } from "../lib/errors";
40 import {
41 buildYatraSinglePublicUrls,
42 isWordPressPlainPermalink,
43 } from "../lib/frontend-permalink-urls";
44 import { useToast } from "../components/ui/toast";
45 import { generateSlug } from "../lib/slug";
46 import { getCurrencySymbol, getCurrency } from "../data/currencies";
47 import { Pagination } from "../components/shared/Pagination";
48 import { Table as SharedTable } from "../components/shared/Table";
49 import { BulkActionToolbar } from "../components/shared/BulkActionToolbar";
50 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
51 import { Modal } from "../components/ui/modal";
52 import { useNavigate } from "../hooks/useNavigate";
53
54 interface Trip {
55 id: number;
56 title: string;
57 slug: string;
58 original_price?: number;
59 discounted_price?: number;
60 sale_price?: number;
61 pricing_type?: "regular" | "traveler_based" | string;
62 status: string;
63 created_at: string;
64 updated_at?: string;
65 bookings_count?: number;
66 featured?: boolean;
67 trip_type?: "single_day" | "multi_day" | "flexible";
68 featured_priority?: string;
69 duration_days?: number;
70 duration_nights?: number;
71 min_travelers?: number;
72 max_travelers?: number;
73 countries?: string[];
74 regions?: string[];
75 featured_image_url?: string;
76 availability_dates?: Array<{
77 id: number;
78 departure_date: string;
79 seats_remaining?: number;
80 status?: string;
81 }>;
82 attributes?: Record<number, any>;
83 trip_category?: Array<{
84 id: number;
85 name: string;
86 slug?: string;
87 is_primary?: boolean;
88 order?: number;
89 }>;
90 activity_types?: Array<{
91 id: number;
92 name: string;
93 slug?: string;
94 is_primary?: boolean;
95 order?: number;
96 }>;
97 difficulty_level?: string;
98 destinations?: Array<{
99 id: number;
100 name: string;
101 slug?: string;
102 is_primary?: boolean;
103 }>;
104 price_types?: Array<{
105 id?: number;
106 category_id: number;
107 category_label?: string;
108 category_slug?: string;
109 original_price: number;
110 discounted_price?: number | null;
111 sale_price?: number | null;
112 is_default?: boolean;
113 min_quantity?: number;
114 max_quantity?: number | null;
115 }>;
116 traveler_min_price?: number | null;
117 traveler_max_price?: number | null;
118 }
119
120 const Trips: React.FC = () => {
121 const [searchTerm, setSearchTerm] = useState("");
122 const [statusFilter, setStatusFilter] = useState("all");
123 const [sortBy, setSortBy] = useState("id");
124 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
125 const [page, setPage] = useState(1);
126 const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
127 const [newTripTitle, setNewTripTitle] = useState("");
128 const [createTripError, setCreateTripError] = useState<string | null>(null);
129 const [newTripSlug, setNewTripSlug] = useState("");
130 const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
131 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
132 const [bulkAction, setBulkAction] = useState("");
133 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
134 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
135 const [tripToDelete, setTripToDelete] = useState<Trip | null>(null);
136 const queryClient = useQueryClient();
137 const { can, isPro } = usePermissions();
138 const { showToast } = useToast();
139 const { navigate } = useNavigate();
140
141 // Fetch global status counts (stable across filters)
142 const { data: statsData } = useQuery({
143 queryKey: ["trips-stats"],
144 queryFn: async () => {
145 try {
146 const response = await apiClient.get("/trips/stats");
147 return response;
148 } catch (error) {
149 return {
150 all: 0,
151 published: 0,
152 draft: 0,
153 review: 0,
154 approved: 0,
155 archived: 0,
156 trash: 0,
157 };
158 }
159 },
160 enabled: can("yatra_view_trips"),
161 });
162
163 // Build query params
164 const queryParams = useMemo(() => {
165 const params: Record<string, any> = {
166 page,
167 per_page: 10,
168 orderby: sortBy,
169 order: sortOrder,
170 };
171
172 if (searchTerm) {
173 params.search = searchTerm;
174 }
175
176 if (statusFilter !== "all") {
177 params.status = statusFilter;
178 }
179
180 return params;
181 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
182
183 // Fetch trips from API
184 const { data, isLoading, error, refetch } = useQuery({
185 queryKey: ["trips", queryParams],
186 queryFn: async () => {
187 const response = await apiClient.get("/trips", { params: queryParams });
188
189 return response;
190 },
191 enabled: can("yatra_view_trips"),
192 staleTime: 0, // Force fresh data
193 gcTime: 0, // Don't cache (new name for cacheTime)
194 });
195
196 // Delete mutation (permanent delete)
197 const deleteMutation = useMutation({
198 mutationFn: async (id: number) => {
199 return await apiClient.delete(`/trips/${id}/permanent-delete`);
200 },
201 onSuccess: () => {
202 queryClient.invalidateQueries({ queryKey: ["trips"] });
203 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
204 setIsDeleteDialogOpen(false);
205 setTripToDelete(null);
206 showToast(__("Trip deleted permanently.", "yatra"), "success");
207 },
208 onError: () => {
209 showToast(
210 __("Failed to delete trip. Please try again.", "yatra"),
211 "error",
212 );
213 },
214 });
215
216 const createTripMutation = useMutation({
217 mutationFn: async ({ title, slug }: { title: string; slug: string }) => {
218 const trimmedTitle = title.trim();
219 const trimmedSlug = slug.trim();
220 const payload = {
221 title: trimmedTitle,
222 slug: trimmedSlug,
223 status: "draft",
224 trip_type: "multi_day",
225 };
226 const response = await apiClient.post("/trips", payload);
227 return response?.data || response;
228 },
229 onSuccess: (data) => {
230 showToast(
231 __("Trip created as draft. Redirecting to builder...", "yatra"),
232 "success",
233 );
234 queryClient.invalidateQueries({ queryKey: ["trips"] });
235 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
236 setIsCreateModalOpen(false);
237 setNewTripTitle("");
238 setNewTripSlug("");
239 setIsSlugManuallyEdited(false);
240 setCreateTripError(null);
241 if (data?.id) {
242 setTimeout(() => {
243 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${data.id}`;
244 }, 600);
245 }
246 },
247 onError: (error: any) => {
248 const message =
249 error?.response?.data?.message ||
250 error?.message ||
251 __("Failed to create trip. Please try again.", "yatra");
252 setCreateTripError(message);
253 showToast(message, "error");
254 },
255 });
256
257 // List endpoint returns { data: Trip[], total, per_page }; meta.total / meta.per_page used by some collections.
258 const r = data as Record<string, any> | undefined;
259 const trips: Trip[] = Array.isArray(r?.data)
260 ? r.data
261 : Array.isArray(r?.data?.data)
262 ? r.data.data
263 : [];
264 const total = Number(r?.meta?.total ?? r?.total ?? 0);
265 const itemsPerPage = Number(r?.meta?.per_page ?? r?.per_page ?? 10) || 10;
266 const totalPages = Math.ceil(total / itemsPerPage);
267 const errorContext = getErrorContext(error);
268
269 // Get difficulty level data for lookup
270 const { data: difficultyLevelsData } = useQuery({
271 queryKey: ["difficulty-levels-lookup"],
272 queryFn: async () => {
273 try {
274 const response = await apiClient.get("/difficulty-levels", {
275 params: { per_page: 100 },
276 });
277 return response.data || [];
278 } catch (error) {
279 console.error("Failed to load difficulty levels", error);
280 return [];
281 }
282 },
283 staleTime: 5 * 60 * 1000, // 5 minutes
284 });
285
286 const difficultyLevels = useMemo(() => {
287 return (difficultyLevelsData || []).reduce(
288 (acc: Record<string, string>, level: any) => {
289 if (level && level.id && level.name) {
290 acc[level.id.toString()] = level.name;
291 }
292 return acc;
293 },
294 {},
295 );
296 }, [difficultyLevelsData]);
297
298 const formatLabel = (value?: string | null) => {
299 if (!value) return "";
300
301 // Only show difficulty if it's a numeric ID that exists in the difficulty levels data
302 if (value && /^\d+$/.test(value) && difficultyLevels[value]) {
303 return difficultyLevels[value];
304 }
305
306 // Don't show anything for non-numeric or invalid values
307 return "";
308 };
309
310 const summarizeDestinations = (trip: Trip) => {
311 const names = (trip.destinations || [])
312 .map((dest) => dest.name)
313 .filter(Boolean);
314 if (!names.length) return null;
315 const summary = names.slice(0, 2).join(", ");
316 const remaining = names.length - 2;
317 return remaining > 0 ? `${summary} +${remaining}` : summary;
318 };
319
320 const summarizeActivities = (trip: Trip) => {
321 const names = (trip.activity_types || [])
322 .map((act) => act.name)
323 .filter(Boolean);
324 if (!names.length) return null;
325 const summary = names.slice(0, 2).join(", ");
326 const remaining = names.length - 2;
327 return remaining > 0 ? `${summary} +${remaining}` : summary;
328 };
329
330 const summarizeCategories = (trip: Trip) => {
331 const names = (trip.trip_category || [])
332 .map((cat) => cat.name)
333 .filter(Boolean);
334 if (!names.length) return null;
335 const summary = names.slice(0, 2).join(", ");
336 const remaining = names.length - 2;
337 return remaining > 0 ? `${summary} +${remaining}` : summary;
338 };
339
340 const summarizeTravelers = (trip: Trip) => {
341 const { min_travelers, max_travelers } = trip;
342 if (min_travelers && max_travelers) {
343 return `${min_travelers}-${max_travelers} ${__("pax", "yatra")}`;
344 }
345 if (min_travelers) {
346 return `${__("Min", "yatra")} ${min_travelers} ${__("pax", "yatra")}`;
347 }
348 if (max_travelers) {
349 return `${__("Up to", "yatra")} ${max_travelers} ${__("pax", "yatra")}`;
350 }
351 return null;
352 };
353
354 const handleNewTripTitleChange = (value: string) => {
355 setNewTripTitle(value);
356 if (!isSlugManuallyEdited) {
357 setNewTripSlug(generateSlug(value));
358 }
359 };
360
361 const handleNewTripSlugChange = (value: string) => {
362 if (!isSlugManuallyEdited) {
363 setIsSlugManuallyEdited(true);
364 }
365 const sanitized = value
366 .toLowerCase()
367 .replace(/[^a-z0-9-]/g, "-")
368 .replace(/-+/g, "-")
369 .replace(/^-|-$/g, "");
370 setNewTripSlug(sanitized);
371 };
372
373 const enableSlugEditing = () => {
374 if (!isSlugManuallyEdited) {
375 if (!newTripSlug.trim()) {
376 setNewTripSlug(generateSlug(newTripTitle || "new-trip"));
377 }
378 setIsSlugManuallyEdited(true);
379 }
380 };
381
382 const formatDate = (dateString: string) => {
383 return new Date(dateString).toLocaleDateString("en-US", {
384 month: "short",
385 day: "numeric",
386 year: "numeric",
387 });
388 };
389
390 const formatPrice = (trip: Trip) => {
391 const currencyCode = defaultCurrency;
392 const symbol = getCurrencySymbol(currencyCode);
393 const currencyData = getCurrency(currencyCode);
394 const decimals = currencyData?.decimalDigits ?? 2;
395
396 const formatNumber = (value: number) =>
397 new Intl.NumberFormat(undefined, {
398 minimumFractionDigits: decimals,
399 maximumFractionDigits: decimals,
400 }).format(value);
401
402 // Traveler-based pricing: show min - max range
403 if (trip.pricing_type === "traveler_based") {
404 let minPrice: number | null = null;
405 let maxPrice: number | null = null;
406
407 if (Array.isArray(trip.price_types) && trip.price_types.length > 0) {
408 const pricesFromTypes = trip.price_types
409 .map(
410 (pt) =>
411 pt.sale_price || pt.discounted_price || pt.original_price || 0,
412 )
413 .filter((val) => typeof val === "number" && val > 0);
414
415 if (pricesFromTypes.length > 0) {
416 minPrice = Math.min(...pricesFromTypes);
417 maxPrice = Math.max(...pricesFromTypes);
418 }
419 } else {
420 // List endpoint does not hydrate price_types; use backend-computed fields
421 if (
422 typeof trip.traveler_min_price === "number" &&
423 trip.traveler_min_price > 0
424 ) {
425 minPrice = trip.traveler_min_price;
426 }
427 if (
428 typeof trip.traveler_max_price === "number" &&
429 trip.traveler_max_price > 0
430 ) {
431 maxPrice = trip.traveler_max_price;
432 }
433 }
434
435 if (minPrice !== null && maxPrice !== null) {
436 if (minPrice === maxPrice) {
437 return `${symbol}${formatNumber(minPrice)} \n`;
438 }
439 return `${symbol}${formatNumber(minPrice)} - ${symbol}${formatNumber(maxPrice)}`;
440 }
441 }
442
443 // Fallback / regular pricing: use sale_price, then discounted_price, then original_price
444 const price =
445 trip.sale_price || trip.discounted_price || trip.original_price || 0;
446
447 if (!price) {
448 return "-";
449 }
450
451 return `${symbol}${formatNumber(price)}`;
452 };
453
454 const getStatusBadge = (status: string) => {
455 const statusMap: Record<string, { className: string; label: string }> = {
456 publish: {
457 className:
458 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
459 label: __("Published", "yatra"),
460 },
461 draft: {
462 className:
463 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
464 label: __("Draft", "yatra"),
465 },
466 archived: {
467 className:
468 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
469 label: __("Archived", "yatra"),
470 },
471 review: {
472 className:
473 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
474 label: __("Review", "yatra"),
475 },
476 approved: {
477 className:
478 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
479 label: __("Approved", "yatra"),
480 },
481 trash: {
482 className:
483 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
484 label: __("Trash", "yatra"),
485 },
486 };
487
488 const statusInfo = statusMap[status] || {
489 className:
490 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
491 label: status,
492 };
493
494 return (
495 <span
496 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
497 >
498 {statusInfo.label}
499 </span>
500 );
501 };
502
503 const getTripTypeBadge = (
504 tripType?: "single_day" | "multi_day" | "flexible",
505 ) => {
506 if (!tripType) return null;
507
508 const typeMap: Record<string, { className: string; label: string }> = {
509 single_day: {
510 className:
511 "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400",
512 label: __("Single Day", "yatra"),
513 },
514 multi_day: {
515 className:
516 "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400",
517 label: __("Multi-Day", "yatra"),
518 },
519 flexible: {
520 className:
521 "bg-teal-100 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400",
522 label: __("Flexible", "yatra"),
523 },
524 };
525
526 const typeInfo = typeMap[tripType] || {
527 className:
528 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
529 label: tripType,
530 };
531
532 return (
533 <Badge className={`text-xs ${typeInfo.className}`}>
534 {typeInfo.label}
535 </Badge>
536 );
537 };
538
539 const handleEdit = (trip: Trip) => {
540 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${trip.id}`;
541 };
542
543 const handleDelete = (trip: Trip) => {
544 setTripToDelete(trip);
545 setIsDeleteDialogOpen(true);
546 };
547
548 // Fetch trip_base setting for permalink
549 const { data: settings } = useQuery({
550 queryKey: ["settings"],
551 queryFn: async () => {
552 try {
553 const response = await fetchSettings();
554 return response;
555 } catch (error) {
556 return null;
557 }
558 },
559 enabled: can("manage_yatra"),
560 staleTime: 5 * 60 * 1000, // Cache for 5 minutes
561 });
562
563 // Global/default currency (fallbacks: window.yatraAdmin, settings, then USD)
564 const defaultCurrency =
565 (window as any)?.yatraAdmin?.currency ||
566 (settings as any)?.default_currency ||
567 "USD";
568
569 const handleView = async (trip: Trip) => {
570 const tripSlug = trip.slug || "";
571 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
572 entity: "trip",
573 slug: tripSlug,
574 bases: settings as Record<string, unknown> | null,
575 });
576
577 if (isWordPressPlainPermalink()) {
578 if (!tripSlug) {
579 showToast(__("Trip slug is missing", "yatra"), "error");
580 return;
581 }
582 window.open(plainUrl, "_blank", "noopener,noreferrer");
583 return;
584 }
585
586 let apiPermalink = (trip as any)?.permalink || (trip as any)?.url;
587
588 if (!apiPermalink && trip.id) {
589 try {
590 const detail = await apiClient.get(`/trips/${trip.id}`);
591 apiPermalink =
592 (detail as any)?.permalink || (detail as any)?.url || apiPermalink;
593 } catch (e) {
594 // fall through to prettyUrl
595 }
596 }
597
598 if (apiPermalink) {
599 window.open(apiPermalink, "_blank", "noopener,noreferrer");
600 return;
601 }
602
603 if (!tripSlug) {
604 showToast(__("Trip slug is missing", "yatra"), "error");
605 return;
606 }
607
608 window.open(prettyUrl, "_blank", "noopener,noreferrer");
609 };
610
611 const handleCreateTrip = () => {
612 setIsCreateModalOpen(true);
613 setCreateTripError(null);
614 setNewTripTitle("");
615 setNewTripSlug("");
616 setIsSlugManuallyEdited(false);
617 };
618
619 const handleCreateTripConfirm = () => {
620 const title = newTripTitle.trim();
621 if (!title) {
622 setCreateTripError(__("Trip title is required", "yatra"));
623 return;
624 }
625 const slugBase = isSlugManuallyEdited ? newTripSlug : generateSlug(title);
626 const slug = slugBase.trim();
627 if (!slug) {
628 setCreateTripError(__("Trip slug is required", "yatra"));
629 return;
630 }
631 if (!/^[a-z0-9-]+$/.test(slug)) {
632 setCreateTripError(
633 __(
634 "Slug can only contain lowercase letters, numbers, and hyphens",
635 "yatra",
636 ),
637 );
638 return;
639 }
640 setNewTripSlug(slug);
641 createTripMutation.mutate({ title, slug });
642 };
643
644 const handleResetFilters = () => {
645 setSearchTerm("");
646 setStatusFilter("all");
647 setSortBy("id");
648 setSortOrder("desc");
649 setPage(1);
650 };
651
652 const handleSort = (field: string) => {
653 if (sortBy === field) {
654 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
655 } else {
656 setSortBy(field);
657 setSortOrder("asc");
658 }
659 };
660
661 const getSortIcon = (field: string) => {
662 if (sortBy !== field) {
663 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
664 }
665 return sortOrder === "asc" ? (
666 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
667 ) : (
668 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
669 );
670 };
671
672 const hasFilters =
673 searchTerm ||
674 statusFilter !== "all" ||
675 sortBy !== "id" ||
676 sortOrder !== "desc";
677
678 // Helper: update trip status, including required fields for backend validation
679 const updateTripStatus = async (trip: Trip, status: string) => {
680 await apiClient.put(`/trips/${trip.id}`, {
681 status,
682 title: trip.title,
683 slug: trip.slug,
684 });
685 // After a status change, refresh both list and stats
686 queryClient.invalidateQueries({ queryKey: ["trips"] });
687 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
688 };
689
690 // Column visibility state with localStorage persistence
691 const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
692 () => {
693 const defaults = {
694 trip: true,
695 price: true,
696 status: true,
697 trip_type: true,
698 duration: false,
699 availability: true, // Availability status (has dates, seats)
700 capacity: false, // Min/Max travelers
701 countries: false,
702 difficulty: false,
703 attributes: false, // Custom attributes count
704 // Control chips inside Trip column
705 destinations: true,
706 activities: true,
707 categories: true,
708 bookings: true,
709 created: true,
710 modified: false, // Last modified date
711 };
712
713 if (typeof window === "undefined") {
714 return defaults;
715 }
716
717 const stored = window.localStorage.getItem("yatra-trips-visible-columns");
718 if (stored) {
719 try {
720 const parsed = JSON.parse(stored) as Record<string, boolean>;
721 // Merge stored config with defaults so new columns (duration, countries, difficulty) default to true
722 return { ...defaults, ...parsed };
723 } catch {
724 return defaults;
725 }
726 }
727 return defaults;
728 },
729 );
730
731 const toggleColumn = (columnKey: string) => {
732 const updated = {
733 ...visibleColumns,
734 [columnKey]: !visibleColumns[columnKey],
735 };
736 setVisibleColumns(updated);
737 if (typeof window !== "undefined") {
738 window.localStorage.setItem(
739 "yatra-trips-visible-columns",
740 JSON.stringify(updated),
741 );
742 }
743 };
744
745 const columnOptions = [
746 { key: "trip", label: __("Trip", "yatra"), visible: visibleColumns.trip },
747 {
748 key: "price",
749 label: __("Price", "yatra"),
750 visible: visibleColumns.price,
751 },
752 {
753 key: "status",
754 label: __("Status", "yatra"),
755 visible: visibleColumns.status,
756 },
757 {
758 key: "trip_type",
759 label: __("Trip Type", "yatra"),
760 visible: visibleColumns.trip_type,
761 },
762 {
763 key: "duration",
764 label: __("Duration", "yatra"),
765 visible: visibleColumns.duration,
766 },
767 {
768 key: "countries",
769 label: __("Countries", "yatra"),
770 visible: visibleColumns.countries,
771 },
772 {
773 key: "difficulty",
774 label: __("Difficulty", "yatra"),
775 visible: visibleColumns.difficulty,
776 },
777 {
778 key: "availability",
779 label: __("Availability", "yatra"),
780 visible: visibleColumns.availability,
781 },
782 {
783 key: "capacity",
784 label: __("Capacity", "yatra"),
785 visible: visibleColumns.capacity,
786 },
787 {
788 key: "attributes",
789 label: __("Attributes", "yatra"),
790 visible: visibleColumns.attributes,
791 },
792 // These control which chips are shown inside the Trip column
793 {
794 key: "destinations",
795 label: __("Destinations (chips)", "yatra"),
796 visible: visibleColumns.destinations,
797 },
798 {
799 key: "activities",
800 label: __("Activities (chips)", "yatra"),
801 visible: visibleColumns.activities,
802 },
803 {
804 key: "categories",
805 label: __("Categories (chips)", "yatra"),
806 visible: visibleColumns.categories,
807 },
808 ...(isPro
809 ? [
810 {
811 key: "bookings",
812 label: __("Bookings", "yatra"),
813 visible: visibleColumns.bookings,
814 },
815 ]
816 : []),
817 {
818 key: "created",
819 label: __("Created", "yatra"),
820 visible: visibleColumns.created,
821 },
822 {
823 key: "modified",
824 label: __("Modified", "yatra"),
825 visible: visibleColumns.modified,
826 },
827 ];
828
829 // Selection helpers for shared table
830 const isAllSelected = trips.length > 0 && selectedIds.length === trips.length;
831
832 const handleSelectItem = (id: string | number, checked: boolean) => {
833 setSelectedIds((prev) =>
834 checked ? [...prev, id] : prev.filter((existingId) => existingId !== id),
835 );
836 };
837
838 const handleSelectAll = (checked: boolean) => {
839 if (checked) {
840 setSelectedIds(trips.map((trip: Trip) => trip.id));
841 } else {
842 setSelectedIds([]);
843 }
844 };
845
846 // Status counts for filter tabs (stable, from stats API)
847 const statusCounts = useMemo(() => {
848 if (statsData) {
849 return {
850 all: statsData.all ?? 0,
851 publish: statsData.published ?? 0,
852 draft: statsData.draft ?? 0,
853 review: statsData.review ?? 0,
854 approved: statsData.approved ?? 0,
855 archived: statsData.archived ?? 0,
856 trash: statsData.trash ?? 0,
857 };
858 }
859
860 return {
861 all: 0,
862 publish: 0,
863 draft: 0,
864 review: 0,
865 approved: 0,
866 archived: 0,
867 trash: 0,
868 };
869 }, [statsData]);
870
871 const statusOptions = [
872 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
873 {
874 key: "publish",
875 label: __("Published", "yatra"),
876 count: statusCounts.publish,
877 },
878 { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft },
879 { key: "review", label: __("Review", "yatra"), count: statusCounts.review },
880 {
881 key: "approved",
882 label: __("Approved", "yatra"),
883 count: statusCounts.approved,
884 },
885 {
886 key: "archived",
887 label: __("Archived", "yatra"),
888 count: statusCounts.archived,
889 },
890 { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash },
891 ];
892
893 // Bulk action options (status + delete)
894 const bulkActionOptions = useMemo(() => {
895 // In Trash view: allow restore to Draft and permanent delete only
896 if (statusFilter === "trash") {
897 return [
898 { value: "mark_draft", label: __("Restore to Draft", "yatra") },
899 { value: "delete", label: __("Delete Permanently", "yatra") },
900 ];
901 }
902
903 // Other views: normal status changes + move to trash + delete
904 return [
905 { value: "mark_publish", label: __("Mark as Published", "yatra") },
906 { value: "mark_draft", label: __("Mark as Draft", "yatra") },
907 { value: "mark_archived", label: __("Archive", "yatra") },
908 { value: "mark_trash", label: __("Move to Trash", "yatra") },
909 { value: "delete", label: __("Delete Permanently", "yatra") },
910 ];
911 }, [statusFilter]);
912
913 // Bulk apply handler (confirmation UI: BulkActionToolbar → ConfirmationDialog)
914 const handleBulkApply = async () => {
915 if (!bulkAction || selectedIds.length === 0) return;
916
917 try {
918 if (bulkAction === "delete") {
919 await Promise.all(
920 selectedIds.map((id) =>
921 apiClient.delete(`/trips/${id}/permanent-delete`),
922 ),
923 );
924 showToast(__("Trips deleted permanently", "yatra"), "success");
925 } else if (bulkAction.startsWith("mark_")) {
926 const status = bulkAction.replace("mark_", "");
927 // Only update trips that are currently loaded in this page
928 const selectedTrips = trips.filter((trip: Trip) =>
929 selectedIds.includes(trip.id),
930 );
931 await Promise.all(
932 selectedTrips.map((trip: Trip) => updateTripStatus(trip, status)),
933 );
934 showToast(__("Trip status updated successfully", "yatra"), "success");
935 }
936 queryClient.invalidateQueries({ queryKey: ["trips"] });
937 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
938 setSelectedIds([]);
939 setBulkAction("");
940 } catch (error) {
941 showToast(__("Bulk action failed", "yatra"), "error");
942 }
943 };
944
945 // Columns configuration for shared table
946 const columns = useMemo(() => {
947 const cols: {
948 key: string;
949 label: string;
950 sortable?: boolean;
951 visible?: boolean;
952 width?: string;
953 render?: (trip: Trip, index: number) => React.ReactNode;
954 }[] = [];
955
956 cols.push({
957 key: "title",
958 label: __("Trip", "yatra"),
959 sortable: true,
960 visible: visibleColumns.trip,
961 width: "w-[300px]",
962 render: (trip: Trip) => {
963 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${trip.id}`;
964 return (
965 <div className="flex items-start gap-3">
966 {/* Featured Image Thumbnail */}
967 {trip.featured_image_url ? (
968 <img
969 src={trip.featured_image_url}
970 alt={trip.title}
971 className="w-12 h-12 object-cover rounded flex-shrink-0"
972 />
973 ) : (
974 <img
975 src={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-content/plugins/yatra/assets/images/trip-placeholder.svg`}
976 alt={trip.title}
977 className="w-12 h-12 object-cover rounded flex-shrink-0"
978 />
979 )}
980 <div className="flex-1 min-w-0">
981 <div className="flex items-center gap-1">
982 <a
983 href={editUrl}
984 onClick={(e) => {
985 e.preventDefault();
986 e.stopPropagation();
987 handleEdit(trip);
988 }}
989 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"
990 >
991 {trip.title}
992 </a>
993 {can("yatra_view_trips") && trip.status !== "trash" && (
994 <button
995 type="button"
996 onClick={(e) => {
997 e.stopPropagation();
998 handleView(trip);
999 }}
1000 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"
1001 title={__("View trip in new tab", "yatra")}
1002 >
1003 <ExternalLink className="w-3.5 h-3.5" />
1004 </button>
1005 )}
1006 </div>
1007 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
1008 {trip.slug}
1009 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
1010 (ID: {trip.id})
1011 </span>
1012 </div>
1013 {(() => {
1014 const destinationLabel = summarizeDestinations(trip);
1015 const durationLabel = trip.duration_days
1016 ? `${trip.duration_days}${__("d", "yatra")}${trip.duration_nights ? ` / ${trip.duration_nights}${__("n", "yatra")}` : ""}`
1017 : null;
1018 const travelerLabel = summarizeTravelers(trip);
1019 const categoryLabel = summarizeCategories(trip);
1020 const activityLabel = summarizeActivities(trip);
1021 const difficultyLabel = formatLabel(trip.difficulty_level);
1022 const chips = [
1023 destinationLabel &&
1024 visibleColumns.destinations && {
1025 key: "dest",
1026 label: destinationLabel,
1027 icon: Route,
1028 },
1029 durationLabel && {
1030 key: "duration",
1031 label: durationLabel,
1032 icon: Calendar,
1033 },
1034 travelerLabel && {
1035 key: "traveler",
1036 label: travelerLabel,
1037 icon: Users,
1038 },
1039 activityLabel &&
1040 visibleColumns.activities && {
1041 key: "activity",
1042 label: activityLabel,
1043 icon: Activity,
1044 },
1045 categoryLabel &&
1046 visibleColumns.categories && {
1047 key: "category",
1048 label: categoryLabel,
1049 icon: FolderTree,
1050 },
1051 difficultyLabel && {
1052 key: "difficulty",
1053 label: difficultyLabel,
1054 icon: TrendingUp,
1055 },
1056 ].filter(Boolean) as Array<{
1057 key: string;
1058 label: string;
1059 icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
1060 }>;
1061 if (!chips.length) return null;
1062 return (
1063 <div className="flex flex-wrap gap-2 mt-1">
1064 {chips.map(({ key, label, icon: Icon }) => (
1065 <span
1066 key={`${trip.id}-${key}`}
1067 className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 px-2 py-0.5 text-[11px]"
1068 >
1069 <Icon className="w-3 h-3" />
1070 {label}
1071 </span>
1072 ))}
1073 </div>
1074 );
1075 })()}
1076 </div>
1077 {trip.featured_priority &&
1078 trip.featured_priority !== "none" &&
1079 isPro && (
1080 <Badge className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
1081 {trip.featured_priority === "featured"
1082 ? __("Featured", "yatra")
1083 : trip.featured_priority === "new"
1084 ? __("New", "yatra")
1085 : trip.featured_priority === "limited"
1086 ? __("Limited", "yatra")
1087 : trip.featured_priority === "bestseller"
1088 ? __("Bestseller", "yatra")
1089 : trip.featured_priority}
1090 </Badge>
1091 )}
1092 </div>
1093 );
1094 },
1095 });
1096
1097 cols.push({
1098 key: "price",
1099 label: __("Price", "yatra"),
1100 sortable: true,
1101 visible: visibleColumns.price,
1102 render: (trip: Trip) => (
1103 <span className="font-medium">{formatPrice(trip)}</span>
1104 ),
1105 });
1106
1107 cols.push({
1108 key: "status",
1109 label: __("Status", "yatra"),
1110 sortable: true,
1111 visible: visibleColumns.status,
1112 render: (trip: Trip) => getStatusBadge(trip.status),
1113 });
1114
1115 cols.push({
1116 key: "trip_type",
1117 label: __("Trip Type", "yatra"),
1118 sortable: true,
1119 visible: visibleColumns.trip_type,
1120 render: (trip: Trip) => getTripTypeBadge(trip.trip_type),
1121 });
1122
1123 // Duration column (days / nights)
1124 cols.push({
1125 key: "duration",
1126 label: __("Duration", "yatra"),
1127 sortable: false,
1128 visible: visibleColumns.duration,
1129 render: (trip: Trip) => {
1130 if (!trip.duration_days && !trip.duration_nights) return null;
1131 const days = trip.duration_days
1132 ? `${trip.duration_days} ${__("days", "yatra")}`
1133 : "";
1134 const nights = trip.duration_nights
1135 ? `${trip.duration_nights} ${__("nights", "yatra")}`
1136 : "";
1137 const label = [days, nights].filter(Boolean).join(" / ");
1138 return (
1139 <span className="text-sm text-gray-700 dark:text-gray-300">
1140 {label}
1141 </span>
1142 );
1143 },
1144 });
1145
1146 // Countries column
1147 cols.push({
1148 key: "countries",
1149 label: __("Countries", "yatra"),
1150 sortable: false,
1151 visible: visibleColumns.countries,
1152 render: (trip: Trip) => {
1153 const list = (trip.countries || []).filter(Boolean);
1154 if (!list.length) return null;
1155 return (
1156 <span className="text-sm text-gray-700 dark:text-gray-300">
1157 {list.join(", ")}
1158 </span>
1159 );
1160 },
1161 });
1162
1163 // Difficulty column (from difficulty_level)
1164 cols.push({
1165 key: "difficulty",
1166 label: __("Difficulty", "yatra"),
1167 sortable: false,
1168 visible: visibleColumns.difficulty,
1169 render: (trip: Trip) => {
1170 // Only show difficulty if it's set and is a valid positive number
1171 const difficultyValue = trip.difficulty_level;
1172 if (
1173 !difficultyValue ||
1174 difficultyValue === "0" ||
1175 Number(difficultyValue) === 0
1176 )
1177 return null;
1178 const difficultyText = formatLabel(String(difficultyValue));
1179 if (!difficultyText) return null;
1180 return (
1181 <span className="text-sm text-gray-700 dark:text-gray-300">
1182 {difficultyText}
1183 </span>
1184 );
1185 },
1186 });
1187
1188 // Availability column
1189 cols.push({
1190 key: "availability",
1191 label: __("Availability", "yatra"),
1192 sortable: false,
1193 visible: visibleColumns.availability,
1194 render: (trip: Trip) => {
1195 const dates = trip.availability_dates || [];
1196 if (!dates.length) {
1197 return (
1198 <span className="text-xs text-gray-400 dark:text-gray-500">
1199 {__("No dates", "yatra")}
1200 </span>
1201 );
1202 }
1203 const availableCount = dates.filter(
1204 (d) => d.status === "available",
1205 ).length;
1206 return (
1207 <div className="text-sm">
1208 <div className="text-gray-700 dark:text-gray-300">
1209 {dates.length} {__("dates", "yatra")}
1210 </div>
1211 {availableCount > 0 && (
1212 <div className="text-xs text-green-600 dark:text-green-400">
1213 {availableCount} {__("available", "yatra")}
1214 </div>
1215 )}
1216 </div>
1217 );
1218 },
1219 });
1220
1221 // Capacity column
1222 cols.push({
1223 key: "capacity",
1224 label: __("Capacity", "yatra"),
1225 sortable: false,
1226 visible: visibleColumns.capacity,
1227 render: (trip: Trip) => {
1228 if (!trip.min_travelers && !trip.max_travelers) return null;
1229 const min = trip.min_travelers || 0;
1230 const max = trip.max_travelers || 0;
1231 return (
1232 <span className="text-sm text-gray-700 dark:text-gray-300">
1233 {min}-{max} {__("travelers", "yatra")}
1234 </span>
1235 );
1236 },
1237 });
1238
1239 // Attributes column
1240 cols.push({
1241 key: "attributes",
1242 label: __("Attributes", "yatra"),
1243 sortable: false,
1244 visible: visibleColumns.attributes,
1245 render: (trip: Trip) => {
1246 const attrCount = trip.attributes
1247 ? Object.keys(trip.attributes).length
1248 : 0;
1249 if (!attrCount) {
1250 return (
1251 <span className="text-xs text-gray-400 dark:text-gray-500">
1252 {__("None", "yatra")}
1253 </span>
1254 );
1255 }
1256 return (
1257 <Badge className="text-xs bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400">
1258 {attrCount} {__("attributes", "yatra")}
1259 </Badge>
1260 );
1261 },
1262 });
1263
1264 if (isPro) {
1265 cols.push({
1266 key: "bookings",
1267 label: __("Bookings", "yatra"),
1268 sortable: false,
1269 visible: visibleColumns.bookings,
1270 render: (trip: Trip) => (
1271 <span className="text-gray-600 dark:text-gray-400">
1272 {trip.bookings_count || 0}
1273 </span>
1274 ),
1275 });
1276 }
1277
1278 cols.push({
1279 key: "date",
1280 label: __("Created", "yatra"),
1281 sortable: true,
1282 visible: visibleColumns.created,
1283 render: (trip: Trip) => (
1284 <span className="text-gray-500 dark:text-gray-400 text-sm">
1285 {formatDate(trip.created_at)}
1286 </span>
1287 ),
1288 });
1289
1290 // Modified column
1291 cols.push({
1292 key: "modified",
1293 label: __("Modified", "yatra"),
1294 sortable: true,
1295 visible: visibleColumns.modified,
1296 render: (trip: Trip) => {
1297 if (!trip.updated_at) return null;
1298 return (
1299 <span className="text-gray-500 dark:text-gray-400 text-sm">
1300 {formatDate(trip.updated_at)}
1301 </span>
1302 );
1303 },
1304 });
1305
1306 return cols;
1307 }, [
1308 isPro,
1309 visibleColumns,
1310 summarizeDestinations,
1311 summarizeTravelers,
1312 summarizeCategories,
1313 formatLabel,
1314 formatPrice,
1315 getStatusBadge,
1316 getTripTypeBadge,
1317 ]);
1318
1319 const navigateToAvailability = (trip: Trip) => {
1320 navigate({
1321 subpage: "trips",
1322 tab: "availability",
1323 trip_id: trip.id.toString(),
1324 });
1325 };
1326
1327 const navigateToRecurringRules = (trip: Trip) => {
1328 navigate({
1329 subpage: "trips",
1330 tab: "availability",
1331 trip_id: trip.id.toString(),
1332 tab_mode: "recurring",
1333 });
1334 };
1335
1336 // Row actions for shared table (3-dot menu)
1337 const tableActions = useMemo(() => {
1338 const actions: any[] = [];
1339
1340 // View is available except for trash trips
1341 if (can("yatra_view_trips")) {
1342 actions.push({
1343 key: "view",
1344 label: __("View (frontend)", "yatra"),
1345 icon: <ExternalLink className="w-4 h-4" />,
1346 onClick: (trip: Trip) => handleView(trip),
1347 condition: (trip: Trip) => trip.status !== "trash", // Hide for trash trips
1348 });
1349 }
1350
1351 // Availability / recurring quick links
1352 actions.push({
1353 key: "view-availability",
1354 label: __("View Availability", "yatra"),
1355 icon: <Calendar className="w-4 h-4" />,
1356 onClick: (trip: Trip) => navigateToAvailability(trip),
1357 });
1358
1359 actions.push({
1360 key: "view-recurring",
1361 label: __("View Recurring Rules", "yatra"),
1362 icon: <RefreshCw className="w-4 h-4" />,
1363 onClick: (trip: Trip) => navigateToRecurringRules(trip),
1364 });
1365
1366 // Edit available for all statuses
1367 if (can("yatra_edit_trips")) {
1368 actions.push({
1369 key: "edit",
1370 label: __("Edit", "yatra"),
1371 icon: <Edit className="w-4 h-4" />,
1372 onClick: (trip: Trip) => handleEdit(trip),
1373 });
1374
1375 // Duplicate trip as draft
1376 actions.push({
1377 key: "duplicate",
1378 label: __("Duplicate", "yatra"),
1379 icon: <Copy className="w-4 h-4" />,
1380 onClick: async (trip: Trip) => {
1381 try {
1382 await apiClient.post(`/trips/${trip.id}/duplicate`);
1383 showToast(__("Trip duplicated as draft.", "yatra"), "success");
1384 queryClient.invalidateQueries({ queryKey: ["trips"] });
1385 } catch (error) {
1386 console.error("Failed to duplicate trip", error);
1387 showToast(__("Failed to duplicate trip.", "yatra"), "error");
1388 }
1389 },
1390 });
1391
1392 // Status-based actions
1393 actions.push({
1394 key: "publish",
1395 label: __("Mark as Published", "yatra"),
1396 icon: <ArrowUp className="w-4 h-4" />,
1397 onClick: async (trip: Trip) => {
1398 await updateTripStatus(trip, "publish");
1399 showToast(__("Trip marked as published.", "yatra"), "success");
1400 queryClient.invalidateQueries({ queryKey: ["trips"] });
1401 },
1402 condition: (trip: Trip) =>
1403 trip.status !== "publish" && trip.status !== "trash",
1404 });
1405
1406 actions.push({
1407 key: "draft",
1408 label: __("Mark as Draft", "yatra"),
1409 icon: <ArrowDown className="w-4 h-4" />,
1410 onClick: async (trip: Trip) => {
1411 await updateTripStatus(trip, "draft");
1412 showToast(__("Trip marked as draft.", "yatra"), "success");
1413 queryClient.invalidateQueries({ queryKey: ["trips"] });
1414 },
1415 condition: (trip: Trip) =>
1416 trip.status !== "draft" && trip.status !== "trash",
1417 });
1418
1419 actions.push({
1420 key: "archive",
1421 label: __("Archive", "yatra"),
1422 icon: <Archive className="w-4 h-4" />,
1423 onClick: async (trip: Trip) => {
1424 await updateTripStatus(trip, "archived");
1425 showToast(__("Trip archived.", "yatra"), "success");
1426 queryClient.invalidateQueries({ queryKey: ["trips"] });
1427 },
1428 condition: (trip: Trip) =>
1429 trip.status !== "archived" && trip.status !== "trash",
1430 });
1431
1432 actions.push({
1433 key: "trash",
1434 label: __("Move to Trash", "yatra"),
1435 icon: <Trash2 className="w-4 h-4" />,
1436 onClick: async (trip: Trip) => {
1437 await updateTripStatus(trip, "trash");
1438 showToast(__("Trip moved to trash.", "yatra"), "success");
1439 queryClient.invalidateQueries({ queryKey: ["trips"] });
1440 },
1441 condition: (trip: Trip) => trip.status !== "trash",
1442 });
1443
1444 actions.push({
1445 key: "restore",
1446 label: __("Restore to Draft", "yatra"),
1447 icon: <ArrowUp className="w-4 h-4" />,
1448 onClick: async (trip: Trip) => {
1449 await updateTripStatus(trip, "draft");
1450 showToast(__("Trip restored to draft.", "yatra"), "success");
1451 queryClient.invalidateQueries({ queryKey: ["trips"] });
1452 },
1453 condition: (trip: Trip) => trip.status === "trash",
1454 });
1455 }
1456
1457 // Permanent delete for all trips (admin should be able to delete any trip)
1458 if (can("yatra_delete_trips")) {
1459 actions.push({
1460 key: "delete",
1461 label: __("Delete Permanently", "yatra"),
1462 icon: <Trash2 className="w-4 h-4" />,
1463 onClick: (trip: Trip) => handleDelete(trip),
1464 variant: "destructive" as const,
1465 condition: () => true, // Show for all trips
1466 });
1467 }
1468
1469 return actions;
1470 }, [can, queryClient, showToast]);
1471
1472 return (
1473 <>
1474 <div className="space-y-3">
1475 <PageHeader
1476 title={__("All Trips", "yatra")}
1477 description={__(
1478 "Manage your travel packages and tours. Create, edit, and organize all your trips in one place.",
1479 "yatra",
1480 )}
1481 actionCapability="yatra_edit_trips"
1482 actions={
1483 <div className="flex items-center gap-2">
1484 {/* DEBUG: Refresh button */}
1485 {(window as any).WP_DEBUG && (
1486 <Button
1487 onClick={() => {
1488 refetch();
1489 }}
1490 variant="outline"
1491 className="text-xs"
1492 >
1493 {__("Refresh Data", "yatra")}
1494 </Button>
1495 )}
1496 <Button
1497 onClick={handleCreateTrip}
1498 className="flex items-center gap-2"
1499 >
1500 <Plus className="w-4 h-4" />
1501 {__("Add New Trip", "yatra")}
1502 </Button>
1503 </div>
1504 }
1505 />
1506
1507 {/* Filters, Search, and Sorting - Always Visible */}
1508 <Card>
1509 <CardContent className="p-4">
1510 <div className="mb-3">
1511 <HelpText
1512 text={__(
1513 "Use the search box to find trips by name. Use filters to show only published, draft, or other status trips. Click column headers to sort.",
1514 "yatra",
1515 )}
1516 />
1517 </div>
1518 <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center w-full">
1519 {/* Search */}
1520 <div className="flex-1 min-w-0 relative">
1521 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
1522 <Input
1523 type="text"
1524 placeholder={__("Search by trip name...", "yatra")}
1525 value={searchTerm}
1526 onChange={(e) => setSearchTerm(e.target.value)}
1527 className="pl-10 w-full"
1528 title={__("Type to search for trips by name", "yatra")}
1529 />
1530 </div>
1531
1532 {/* Status Filter */}
1533 <div className="w-full lg:w-auto lg:min-w-[160px]">
1534 <Select
1535 value={statusFilter}
1536 onChange={(e) => setStatusFilter(e.target.value)}
1537 className="w-full"
1538 >
1539 <option value="all">{__("All Status", "yatra")}</option>
1540 <option value="publish">{__("Published", "yatra")}</option>
1541 <option value="draft">{__("Draft", "yatra")}</option>
1542 <option value="review">{__("Review", "yatra")}</option>
1543 <option value="approved">{__("Approved", "yatra")}</option>
1544 <option value="archived">{__("Archived", "yatra")}</option>
1545 <option value="trash">{__("Trash", "yatra")}</option>
1546 </Select>
1547 </div>
1548
1549 {/* Sort By */}
1550 <div className="w-full lg:w-auto lg:min-w-[140px]">
1551 <Select
1552 value={sortBy}
1553 onChange={(e) => setSortBy(e.target.value)}
1554 className="w-full"
1555 >
1556 <option value="title">{__("Title", "yatra")}</option>
1557 <option value="price">{__("Price", "yatra")}</option>
1558 <option value="date">{__("Date", "yatra")}</option>
1559 <option value="status">{__("Status", "yatra")}</option>
1560 </Select>
1561 </div>
1562
1563 {/* Sort Order */}
1564 <div className="w-full lg:w-auto lg:flex-shrink-0">
1565 <Button
1566 variant="outline"
1567 onClick={() =>
1568 setSortOrder(sortOrder === "asc" ? "desc" : "asc")
1569 }
1570 className="px-4 flex items-center gap-2 w-full lg:w-auto"
1571 title={
1572 sortOrder === "asc"
1573 ? __("Ascending", "yatra")
1574 : __("Descending", "yatra")
1575 }
1576 >
1577 {sortOrder === "asc" ? (
1578 <ArrowUp className="w-4 h-4" />
1579 ) : (
1580 <ArrowDown className="w-4 h-4" />
1581 )}
1582 <span className="text-sm whitespace-nowrap">
1583 {sortOrder === "asc"
1584 ? __("Asc", "yatra")
1585 : __("Desc", "yatra")}
1586 </span>
1587 </Button>
1588 </div>
1589
1590 {/* Reset Button */}
1591 {hasFilters && (
1592 <div className="w-full lg:w-auto lg:flex-shrink-0">
1593 <Button
1594 variant="outline"
1595 onClick={handleResetFilters}
1596 className="flex items-center gap-2 w-full lg:w-auto"
1597 >
1598 <X className="w-4 h-4" />
1599 <span className="text-sm">{__("Reset", "yatra")}</span>
1600 </Button>
1601 </div>
1602 )}
1603 </div>
1604 </CardContent>
1605 </Card>
1606
1607 <ConditionalRender capability="yatra_view_trips">
1608 {/* Bulk Actions & Column Visibility */}
1609 {!error && (
1610 <BulkActionToolbar
1611 selectedIds={selectedIds}
1612 bulkAction={bulkAction}
1613 setBulkAction={setBulkAction}
1614 onApply={handleBulkApply}
1615 onClearSelection={() => setSelectedIds([])}
1616 statusFilter={statusFilter}
1617 setStatusFilter={setStatusFilter}
1618 statusOptions={statusOptions}
1619 showColumnsDropdown={showColumnsDropdown}
1620 setShowColumnsDropdown={setShowColumnsDropdown}
1621 columnOptions={columnOptions}
1622 onToggleColumn={toggleColumn}
1623 bulkMutationPending={false}
1624 totalItems={total}
1625 bulkActionOptions={bulkActionOptions}
1626 />
1627 )}
1628
1629 {/* Table */}
1630 <Card>
1631 <CardContent className="p-0">
1632 <SharedTable
1633 data={trips}
1634 columns={columns}
1635 actions={tableActions}
1636 isLoading={isLoading}
1637 isError={!!error}
1638 errorText={__("Error Loading Trips", "yatra")}
1639 errorDescription={__(
1640 "We couldn’t connect to the trips service. Please refresh or try again shortly.",
1641 "We couldn’t connect to the trips service. Please refresh or try again shortly.",
1642 )}
1643 onRetry={() =>
1644 queryClient.invalidateQueries({ queryKey: ["trips"] })
1645 }
1646 errorDetails={errorContext.details}
1647 errorRequestInfo={errorContext.requestInfo}
1648 emptyText={
1649 searchTerm || statusFilter !== "all"
1650 ? __("No trips match your search", "yatra")
1651 : __("No trips yet", "yatra")
1652 }
1653 emptyDescription={
1654 searchTerm || statusFilter !== "all"
1655 ? __(
1656 "Try adjusting your search or filters to see more results.",
1657 "yatra",
1658 )
1659 : __(
1660 "Get started by creating your first travel package. Click the button above to add a new trip.",
1661 "yatra",
1662 )
1663 }
1664 onCreateClick={
1665 !searchTerm && statusFilter === "all"
1666 ? handleCreateTrip
1667 : undefined
1668 }
1669 onSort={handleSort}
1670 getSortIcon={getSortIcon}
1671 selectedItemIds={selectedIds}
1672 onSelectItem={handleSelectItem}
1673 onSelectAll={handleSelectAll}
1674 isAllSelected={isAllSelected}
1675 getItemId={(trip: Trip) => trip.id}
1676 />
1677 </CardContent>
1678 </Card>
1679
1680 {/* Pagination - Always Visible */}
1681 {total > 0 && (
1682 <div className="mt-4">
1683 <Pagination
1684 currentPage={page}
1685 totalPages={totalPages}
1686 totalItems={total}
1687 itemsPerPage={itemsPerPage}
1688 onPageChange={setPage}
1689 itemName={__("trips", "yatra")}
1690 />
1691 </div>
1692 )}
1693 </ConditionalRender>
1694 </div>
1695
1696 {/* Create Trip Modal - Using same Modal component as Discounts */}
1697 <Modal
1698 isOpen={isCreateModalOpen}
1699 onClose={() => {
1700 if (!createTripMutation.isPending) {
1701 setIsCreateModalOpen(false);
1702 setCreateTripError(null);
1703 }
1704 }}
1705 title={__("Create New Trip", "yatra")}
1706 description={__(
1707 "Give your trip a name and URL. We'll create a draft and take you to the trip builder.",
1708 "yatra",
1709 )}
1710 size="lg"
1711 footer={
1712 <div className="flex justify-end gap-2">
1713 <Button
1714 variant="outline"
1715 onClick={() => {
1716 if (!createTripMutation.isPending) {
1717 setIsCreateModalOpen(false);
1718 setCreateTripError(null);
1719 }
1720 }}
1721 disabled={createTripMutation.isPending}
1722 >
1723 {__("Cancel", "yatra")}
1724 </Button>
1725 <Button
1726 onClick={handleCreateTripConfirm}
1727 className="flex items-center gap-2"
1728 disabled={createTripMutation.isPending}
1729 >
1730 {createTripMutation.isPending && (
1731 <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
1732 )}
1733 {createTripMutation.isPending
1734 ? __("Creating…", "yatra")
1735 : __("Create & Continue", "yatra")}
1736 </Button>
1737 </div>
1738 }
1739 >
1740 <div className="space-y-6">
1741 <div className="space-y-2">
1742 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
1743 {__("Trip Title", "yatra")}
1744 <span className="text-red-500">*</span>
1745 </label>
1746 <Input
1747 value={newTripTitle}
1748 onChange={(e) => handleNewTripTitleChange(e.target.value)}
1749 placeholder={__("e.g., Bali Beach Retreat", "yatra")}
1750 disabled={createTripMutation.isPending}
1751 className="text-base"
1752 />
1753 <p className="text-xs text-gray-500 dark:text-gray-400">
1754 {__(
1755 "A catchy title that describes your trip. Recommended: 50-60 characters for best SEO results.",
1756 "yatra",
1757 )}
1758 </p>
1759 </div>
1760
1761 <div className="space-y-2">
1762 <div className="flex items-center justify-between">
1763 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
1764 {__("Trip URL", "yatra")}
1765 <span className="text-red-500">*</span>
1766 <span className="text-[10px] font-normal text-gray-400">
1767 {__("(URL friendly)", "yatra")}
1768 </span>
1769 </label>
1770 {!isSlugManuallyEdited && (
1771 <button
1772 type="button"
1773 onClick={enableSlugEditing}
1774 className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
1775 >
1776 {__("Customize URL", "yatra")}
1777 </button>
1778 )}
1779 </div>
1780 <Input
1781 value={newTripSlug}
1782 onChange={(e) => handleNewTripSlugChange(e.target.value)}
1783 placeholder={__("bali-beach-retreat", "yatra")}
1784 disabled={createTripMutation.isPending}
1785 readOnly={!isSlugManuallyEdited}
1786 className={`font-mono text-sm ${!isSlugManuallyEdited ? "bg-gray-100 dark:bg-gray-800 cursor-not-allowed" : ""}`}
1787 />
1788 <p className="text-xs text-gray-500 dark:text-gray-400">
1789 {isSlugManuallyEdited
1790 ? __(
1791 "Editing slug manually. Keep it short, lowercase, and hyphen-separated.",
1792 "yatra",
1793 )
1794 : __(
1795 'Auto-generated from the title. Click "Customize URL" if you need a custom slug.',
1796 "yatra",
1797 )}
1798 </p>
1799 {(newTripSlug || newTripTitle) && (
1800 <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
1801 <p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
1802 {__("Preview:", "yatra")}
1803 </p>
1804 <p className="text-xs text-gray-900 dark:text-white font-mono break-all">
1805 {(window as any).yatraAdmin?.siteUrl || "https://example.com"}
1806 /trips/{newTripSlug || generateSlug(newTripTitle)}
1807 </p>
1808 </div>
1809 )}
1810 </div>
1811
1812 {createTripError && (
1813 <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
1814 <p className="text-sm text-red-600 dark:text-red-400">
1815 {createTripError}
1816 </p>
1817 </div>
1818 )}
1819 </div>
1820 </Modal>
1821
1822 {/* Permanent Delete Confirmation (for Trash only) */}
1823 <ConfirmationDialog
1824 isOpen={isDeleteDialogOpen && !!tripToDelete}
1825 onClose={() => {
1826 if (!deleteMutation.isPending) {
1827 setIsDeleteDialogOpen(false);
1828 setTripToDelete(null);
1829 }
1830 }}
1831 onConfirm={() => {
1832 if (tripToDelete && !deleteMutation.isPending) {
1833 deleteMutation.mutate(tripToDelete.id);
1834 }
1835 }}
1836 title={__("Delete Trip Permanently", "yatra")}
1837 message={
1838 tripToDelete
1839 ? __(
1840 'Are you sure you want to permanently delete "{title}"? This cannot be undone and will remove all associated bookings.',
1841 "yatra",
1842 ).replace("{title}", tripToDelete.title)
1843 : ""
1844 }
1845 confirmText={__("Delete Permanently", "yatra")}
1846 cancelText={__("Cancel", "yatra")}
1847 variant="danger"
1848 isLoading={deleteMutation.isPending}
1849 />
1850 </>
1851 );
1852 };
1853
1854 export default Trips;
1855