PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at resources/js/pages/Trips.tsx

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