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

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

2,568 lines 105.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Itinerary Page - Day-by-Day Schedule Builder
3 * Manage itinerary entries organized by days with expandable cards
4 */
5
6 import React, { useState, useEffect, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 ChevronDown,
11 ChevronRight,
12 MoreVertical,
13 Trash2,
14 MapPin,
15 Clock,
16 UtensilsCrossed,
17 Hotel,
18 Calendar,
19 CalendarDays,
20 AlertCircle,
21 Pencil,
22 } from "lucide-react";
23 import { getCurrencySymbol } from "../data/currencies";
24 import { __ } from "../lib/i18n";
25 import { usePermissions } from "../hooks/usePermissions";
26 import { apiClient } from "../lib/api-client";
27 import { useToast } from "../components/ui/toast";
28 import { Button } from "../components/ui/button";
29 import { SearchableSelect } from "../components/ui/searchable-select";
30 import { Card, CardContent } from "../components/ui/card";
31 import { Badge } from "../components/ui/badge";
32 import { ConditionalRender } from "../components/ui/conditional-render";
33 import {
34 IconSelector,
35 type IconSelectorProvider,
36 } from "../components/ui/icon-selector";
37 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
38 import { BulkActionToolbar } from "../components/shared/BulkActionToolbar";
39 import { getErrorContext } from "../lib/errors";
40
41 function parseItemTypeIcon(
42 icon: unknown,
43 defaultName = "footprints",
44 ): {
45 name: string;
46 provider: IconSelectorProvider;
47 isImage: boolean;
48 imageSrc?: string;
49 } {
50 if (!icon) {
51 return { name: defaultName, provider: "yatra", isImage: false };
52 }
53 if (typeof icon === "string") {
54 return { name: icon, provider: "yatra", isImage: false };
55 }
56 if (typeof icon === "object" && icon !== null && "type" in icon) {
57 const o = icon as { type?: string; value?: string; provider?: string };
58 if (o.type === "image" && o.value) {
59 return {
60 name: "image",
61 provider: "yatra",
62 isImage: true,
63 imageSrc: String(o.value),
64 };
65 }
66 const prov: IconSelectorProvider =
67 o.provider === "fa-solid" || o.provider === "fa-regular"
68 ? o.provider
69 : "yatra";
70 return {
71 name: (o.value && String(o.value)) || defaultName,
72 provider: prov,
73 isImage: false,
74 };
75 }
76 return { name: defaultName, provider: "yatra", isImage: false };
77 }
78
79 interface ItineraryEntry {
80 id: number;
81 trip_id: number;
82 trip_title: string;
83 day: number;
84 day_title?: string;
85 item_type: string;
86 item_name: string;
87 item_icon: string;
88 item_icon_provider?: IconSelectorProvider;
89 item_icon_is_image?: boolean;
90 item_icon_image_src?: string;
91 item_color?: string;
92 item_type_id: number | null;
93 item_id: number | null;
94 title: string;
95 description: string;
96 location?: string;
97 duration?: string;
98 start_time: string;
99 end_time: string;
100 cost?: string;
101 cost_per_person?: boolean;
102 included_items?: string[] | string | null;
103 excluded_items?: string[] | string | null;
104 status: string;
105 created_at: string;
106 }
107
108 interface DayGroup {
109 trip_id: number;
110 trip_title: string;
111 day: number;
112 day_title?: string;
113 day_id?: number;
114 day_status?: string;
115 entries: ItineraryEntry[];
116 }
117
118 const Itinerary: React.FC = () => {
119 // Global currency from settings (same as other admin pages)
120 const globalCurrency = (window as any).yatraAdmin?.currency || "USD";
121 const currencySymbol = getCurrencySymbol(globalCurrency) || "";
122 const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || "";
123 // Get trip_id and day from URL params
124 const urlParams = new URLSearchParams(window.location.search);
125 const tripIdParam = urlParams.get("trip_id");
126 const dayParam = urlParams.get("day");
127
128 // Restore last selected trip from localStorage if no URL param
129 const storedTripId = !tripIdParam
130 ? window.localStorage.getItem("yatra_itinerary_selected_trip") || ""
131 : "";
132
133 const [tripFilter, setTripFilter] = useState(tripIdParam || storedTripId);
134 const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
135 const [selectedEmptyDays, setSelectedEmptyDays] = useState<Set<string>>(
136 new Set(),
137 );
138 const [selectedDay, setSelectedDay] = useState<{
139 tripId: number;
140 day: number;
141 } | null>(null);
142 const [deleteConfirm, setDeleteConfirm] = useState<{
143 isOpen: boolean;
144 entry: ItineraryEntry | null;
145 }>({
146 isOpen: false,
147 entry: null,
148 });
149 const [dayMenuOpen, setDayMenuOpen] = useState<{
150 tripId: number;
151 day: number;
152 } | null>(null);
153 const [selectedEntries, setSelectedEntries] = useState<Set<number>>(
154 new Set(),
155 );
156 const [bulkAction, setBulkAction] = useState("");
157 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
158 const [statusFilter, setStatusFilter] = useState("all");
159 const queryClient = useQueryClient();
160 const { can } = usePermissions();
161 const { showToast } = useToast();
162
163 // Update trip filter when URL param changes
164 useEffect(() => {
165 if (tripIdParam) {
166 setTripFilter(tripIdParam);
167 }
168 }, [tripIdParam]);
169
170 // Persist selected trip to localStorage so it is restored on next visit
171 useEffect(() => {
172 if (tripFilter) {
173 window.localStorage.setItem("yatra_itinerary_selected_trip", tripFilter);
174 }
175 }, [tripFilter]);
176
177 // State for item types and items (populated from trips API meta)
178 const [itemTypesData, setItemTypesData] = useState<
179 Array<{ id: number; name: string; icon: string; color: string }>
180 >([]);
181 const [itemsData, setItemsData] = useState<
182 Array<{ id: number; name: string; type_id: number }>
183 >([]);
184
185 // Fetch trips for filter (with itinerary meta for item types and items)
186 const { data: tripsData, isLoading: isLoadingTrips } = useQuery({
187 queryKey: ["trips-with-itinerary-meta"],
188 queryFn: async () => {
189 try {
190 const response = await apiClient.get("/trips", {
191 params: {
192 per_page: 100,
193 status: "all", // Get all trips for filter
194 include_itinerary_meta: 1, // Include item types and items in meta
195 },
196 });
197
198 // Extract trips data
199 const trips = response?.data?.data || response?.data || response || [];
200
201 // Extract meta data for item types and items
202 const meta = response?.data?.meta || response?.meta || {};
203 if (meta.available_item_types) {
204 setItemTypesData(meta.available_item_types);
205 }
206 if (meta.available_items) {
207 setItemsData(meta.available_items);
208 }
209
210 return Array.isArray(trips) ? trips : [];
211 } catch (error: any) {
212 showToast(
213 error?.message || __("Failed to load trips", "yatra"),
214 "error",
215 );
216 return [];
217 }
218 },
219 enabled: can("yatra_view_trips"),
220 staleTime: 2 * 60 * 1000, // Consider data fresh for 2 minutes
221 gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
222 refetchOnWindowFocus: false, // Don't refetch when switching tabs
223 });
224
225 // Helper function to map item_type_id and item_id to item_type/item_name/item_icon/item_color
226 const mapItemIds = (
227 itemTypeId: number | null | undefined,
228 itemId: number | null | undefined,
229 ) => {
230 // Find item type
231 const itemType = itemTypeId
232 ? itemTypesData.find(
233 (type) =>
234 type.id === itemTypeId ||
235 type.id?.toString() === itemTypeId?.toString(),
236 )
237 : null;
238
239 // Find item
240 const item = itemId
241 ? itemsData.find(
242 (it) => it.id === itemId || it.id?.toString() === itemId?.toString(),
243 )
244 : null;
245
246 const parsed = parseItemTypeIcon(itemType?.icon, "footprints");
247 const itemColor = itemType?.color || "gray";
248
249 if (itemType && item) {
250 return {
251 item_type: itemType.name || "Activity",
252 item_name: item.name || "Activity",
253 item_icon: parsed.name,
254 item_icon_provider: parsed.provider,
255 item_icon_is_image: parsed.isImage,
256 item_icon_image_src: parsed.imageSrc,
257 item_color: itemColor,
258 };
259 }
260
261 if (itemType) {
262 // Only item type found
263 return {
264 item_type: itemType.name || "Activity",
265 item_name: itemType.name || "Activity",
266 item_icon: parsed.name,
267 item_icon_provider: parsed.provider,
268 item_icon_is_image: parsed.isImage,
269 item_icon_image_src: parsed.imageSrc,
270 item_color: itemColor,
271 };
272 }
273
274 // Fallback
275 return {
276 item_type: "Activity",
277 item_name: "Activity",
278 item_icon: "footprints",
279 item_icon_provider: "yatra" as const,
280 item_icon_is_image: false,
281 item_color: "gray",
282 };
283 };
284
285 // Helper to parse time field to start_time and end_time
286 const parseTime = (
287 time: string | null | undefined,
288 ): { start_time: string; end_time: string } => {
289 if (!time) return { start_time: "08:00", end_time: "17:00" };
290
291 // Try to parse formats like "08:00-17:00" or "08:00 to 17:00"
292 const timeMatch = time.match(
293 /(\d{1,2}):(\d{2})\s*(?:-|to|–)\s*(\d{1,2}):(\d{2})/i,
294 );
295 if (timeMatch) {
296 return {
297 start_time: `${timeMatch[1].padStart(2, "0")}:${timeMatch[2]}`,
298 end_time: `${timeMatch[3].padStart(2, "0")}:${timeMatch[4]}`,
299 };
300 }
301
302 // Try single time format
303 const singleTime = time.match(/(\d{1,2}):(\d{2})/);
304 if (singleTime) {
305 const hour = parseInt(singleTime[1]);
306 return {
307 start_time: `${singleTime[1].padStart(2, "0")}:${singleTime[2]}`,
308 end_time: `${((hour + 1) % 24).toString().padStart(2, "0")}:${singleTime[2]}`,
309 };
310 }
311
312 return { start_time: "08:00", end_time: "17:00" };
313 };
314
315 const { data, isLoading, error, refetch } = useQuery<DayGroup[]>({
316 queryKey: ["itinerary", tripFilter, tripsData],
317 queryFn: async (): Promise<DayGroup[]> => {
318 const trips = Array.isArray(tripsData) ? tripsData : [];
319 const allEntries: ItineraryEntry[] = [];
320 const allDays = new Map<
321 string,
322 {
323 trip_id: number;
324 trip_title: string;
325 day: number;
326 day_title: string;
327 day_id?: number;
328 day_status?: string;
329 }
330 >();
331 const dayStatuses = new Map<string, string>();
332
333 // Fetch itinerary data for the selected trip
334 if (!tripFilter) {
335 return [];
336 }
337 const tripsToFetch = trips.filter(
338 (t: any) => t.id === parseInt(tripFilter),
339 );
340
341 for (const trip of tripsToFetch) {
342 // Check URL parameter for error simulation (for testing)
343 const urlParams = new URLSearchParams(window.location.search);
344 if (urlParams.get("simulate_error") === "true") {
345 throw new Error(
346 "Simulated API error for testing error UI functionality",
347 );
348 }
349
350 // Fetch itinerary data from the new itinerary endpoint
351 const itineraryResponse = await apiClient.get("/itinerary", {
352 params: {
353 trip_id: trip.id,
354 _t: Date.now(), // Cache buster
355 },
356 });
357
358 // Debug logging
359
360 // Extract data - WordPress REST API returns data directly, not nested under 'data'
361 const itineraryEntries = Array.isArray(itineraryResponse)
362 ? itineraryResponse
363 : Array.isArray(itineraryResponse?.data)
364 ? itineraryResponse.data
365 : [];
366
367 // Group entries by day number
368 const entriesByDay = new Map<number, any[]>();
369 const dayInfo = new Map<
370 number,
371 {
372 title?: string;
373 description?: string;
374 status?: string;
375 day_id?: number;
376 }
377 >();
378
379 // First, group entries by day and collect day info
380 for (const entry of itineraryEntries) {
381 const dayNumber = entry.day || 1;
382
383 // Store day info from day entries (entries where item_type_id and item_id are 0)
384 if (
385 (!entry.item_type_id || entry.item_type_id === 0) &&
386 (!entry.item_id || entry.item_id === 0)
387 ) {
388 dayInfo.set(dayNumber, {
389 title: entry.day_title || entry.title || "",
390 description: entry.day_description || entry.description || "",
391 status: entry.status,
392 day_id: entry.id, // Store the day ID from the day entry
393 });
394 } else {
395 // This is an activity entry, add to the day's entries
396 if (!entriesByDay.has(dayNumber)) {
397 entriesByDay.set(dayNumber, []);
398 }
399 entriesByDay.get(dayNumber)!.push(entry);
400 }
401 }
402
403 // Create day structures similar to the old format
404 const itineraryDays = [];
405
406 // First, add all days that have day info (even if no activities)
407 for (const [dayNumber, info] of dayInfo) {
408 const entries = entriesByDay.get(dayNumber) || [];
409 itineraryDays.push({
410 id: info.day_id, // Day ID from the days table
411 day_id: info.day_id, // Day ID for Edit/Delete operations
412 day: dayNumber,
413 day_number: dayNumber,
414 day_title: info.title || `Day ${dayNumber}`,
415 title: info.title || `Day ${dayNumber}`,
416 description: info.description || "",
417 entries: entries,
418 status: info.status,
419 });
420 }
421
422 // Then, add any days that only have activities but no day info
423 for (const [dayNumber, entries] of entriesByDay) {
424 if (!dayInfo.has(dayNumber)) {
425 itineraryDays.push({
426 id: undefined,
427 day_id: undefined,
428 day: dayNumber,
429 day_number: dayNumber,
430 day_title: `Day ${dayNumber}`,
431 title: `Day ${dayNumber}`,
432 description: "",
433 entries: entries,
434 status: "publish",
435 });
436 }
437 }
438
439 // Sort days by day_number
440 itineraryDays.sort((a, b) => a.day_number - b.day_number);
441
442 // Debug logging
443
444 // Process each day
445 for (const day of itineraryDays) {
446 const dayNumber = day.day_number || day.day || 1;
447 const dayTitle = day.title || day.day_title || "";
448 const entries = day.entries || [];
449 const dayKey = `${trip.id}-${dayNumber}`;
450
451 // Process each entry (only activities, not day entries)
452 for (const entry of entries) {
453 // Ensure entry has required fields from database
454 if (!entry || !entry.id) {
455 continue;
456 }
457
458 // Skip day entries (entries where item_type_id and item_id are null)
459 // Day entries should not be displayed as activities
460 if (
461 (entry.item_type_id === null ||
462 entry.item_type_id === undefined ||
463 entry.item_type_id === 0) &&
464 (entry.item_id === null ||
465 entry.item_id === undefined ||
466 entry.item_id === 0)
467 ) {
468 // Capture day-level status from the day entry so day badges respond to edits
469 if (entry.status) {
470 dayStatuses.set(dayKey, String(entry.status));
471 }
472 continue;
473 }
474
475 const mapped = mapItemIds(entry.item_type_id, entry.item_id);
476 const times = parseTime(entry.time);
477
478 const processedEntry: ItineraryEntry = {
479 id: entry.id || 0,
480 trip_id: trip.id,
481 trip_title: trip.title || trip.name || "",
482 day: dayNumber,
483 day_title: dayTitle,
484 item_type: mapped.item_type,
485 item_name: mapped.item_name,
486 item_icon: mapped.item_icon,
487 item_icon_provider: mapped.item_icon_provider,
488 item_icon_is_image: mapped.item_icon_is_image,
489 item_icon_image_src: mapped.item_icon_image_src,
490 item_color: mapped.item_color || "gray",
491 item_type_id: entry.item_type_id || null,
492 item_id: entry.item_id || null,
493 title: entry.title || "",
494 description: entry.description || "",
495 location: entry.location || "",
496 duration: entry.duration || "",
497 start_time: times.start_time,
498 end_time: times.end_time,
499 cost:
500 typeof entry.cost === "number" || typeof entry.cost === "string"
501 ? String(entry.cost)
502 : undefined,
503 cost_per_person:
504 entry.cost_per_person !== undefined
505 ? !!entry.cost_per_person
506 : undefined,
507 included_items: entry.included_items ?? [],
508 excluded_items: entry.excluded_items ?? [],
509 status: entry.status || "active",
510 created_at: entry.created_at || entry.createdAt || "",
511 };
512
513 allEntries.push(processedEntry);
514 }
515 }
516
517 // Also track all days (even if they have no activities) to ensure they appear in the listing
518 for (const day of itineraryDays) {
519 const dayNumber = day.day_number || day.day || 1;
520 const dayTitle = day.title || day.day_title || "";
521 const dayId = day.id || day.day_id || undefined;
522 const dayKey = `${trip.id}-${dayNumber}`;
523
524 // Store day info for grouping later
525 if (!allDays.has(dayKey)) {
526 allDays.set(dayKey, {
527 trip_id: trip.id,
528 trip_title: trip.title || trip.name || "",
529 day: dayNumber,
530 day_title: dayTitle,
531 day_id: dayId,
532 day_status: dayStatuses.get(dayKey),
533 });
534 }
535 }
536 }
537
538 // Group by trip and day
539 // First, create groups from all days (even if they have no activities)
540 const grouped: DayGroup[] = [];
541 const seen = new Set<string>();
542
543 // Add all days first (from allDays map)
544 allDays.forEach(
545 (
546 dayInfo: {
547 trip_id: number;
548 trip_title: string;
549 day: number;
550 day_title: string;
551 day_id?: number;
552 day_status?: string;
553 },
554 key: string,
555 ) => {
556 if (!seen.has(key)) {
557 seen.add(key);
558 grouped.push({
559 trip_id: dayInfo.trip_id,
560 trip_title: dayInfo.trip_title,
561 day: dayInfo.day,
562 day_title: dayInfo.day_title,
563 day_id: dayInfo.day_id,
564 day_status: dayInfo.day_status,
565 entries: allEntries.filter(
566 (e) => e.trip_id === dayInfo.trip_id && e.day === dayInfo.day,
567 ),
568 });
569 }
570 },
571 );
572
573 // Also add any days that have entries but weren't in allDays (fallback)
574 allEntries.forEach((entry) => {
575 const key = `${entry.trip_id}-${entry.day}`;
576 if (!seen.has(key)) {
577 seen.add(key);
578 grouped.push({
579 trip_id: entry.trip_id,
580 trip_title: entry.trip_title,
581 day: entry.day,
582 day_title: entry.day_title,
583 entries: allEntries.filter(
584 (e) => e.trip_id === entry.trip_id && e.day === entry.day,
585 ),
586 });
587 }
588 });
589
590 // Sort by trip_id and day
591 grouped.sort((a, b) => {
592 if (a.trip_id !== b.trip_id) return a.trip_id - b.trip_id;
593 return a.day - b.day;
594 });
595
596 return grouped;
597 },
598 enabled: can("yatra_view_trips") && !!tripsData && !!tripFilter,
599 staleTime: 0, // Always refetch to ensure fresh data
600 gcTime: 0, // Don't cache to ensure fresh data (formerly cacheTime)
601 refetchOnMount: true, // Always refetch when component mounts
602 refetchOnWindowFocus: true, // Refetch when window regains focus
603 refetchOnReconnect: true, // Refetch when network reconnects
604 });
605
606 // Auto-expand day from URL or first day when data loads (only when data/URL change)
607 useEffect(() => {
608 if (data && data.length > 0 && expandedDays.size === 0) {
609 // If day parameter is in URL, expand that specific day
610 if (dayParam && tripIdParam) {
611 const dayNumber = parseInt(dayParam);
612 const tripId = parseInt(tripIdParam);
613 const targetDay = data.find(
614 (dg: DayGroup) => dg.trip_id === tripId && dg.day === dayNumber,
615 );
616
617 if (targetDay) {
618 const dayKey = `${tripId}-${dayNumber}`;
619 setExpandedDays(new Set([dayKey]));
620 setSelectedDay({ tripId, day: dayNumber });
621 // Scroll to the day after a short delay to ensure it's rendered
622 setTimeout(() => {
623 const dayElement = document.querySelector(
624 `[data-day-key="${dayKey}"]`,
625 );
626 if (dayElement) {
627 dayElement.scrollIntoView({
628 behavior: "smooth",
629 block: "center",
630 });
631 }
632 }, 300);
633 } else {
634 // If target day not found, expand first day as fallback
635 const firstDay = data[0];
636 setExpandedDays(new Set([`${firstDay.trip_id}-${firstDay.day}`]));
637 }
638 } else {
639 // No day parameter, expand first day
640 const firstDay = data[0];
641 setExpandedDays(new Set([`${firstDay.trip_id}-${firstDay.day}`]));
642 }
643 }
644 }, [data, dayParam, tripIdParam]);
645
646 // Bulk delete mutation
647 const bulkDeleteMutation = useMutation({
648 mutationFn: async (ids: number[]) => {
649 const response = await apiClient.delete("/itinerary", {
650 data: { ids },
651 });
652 return response?.data || response;
653 },
654 onSuccess: (data) => {
655 const deleted = data?.deleted || 0;
656 const failed = data?.failed || 0;
657
658 if (deleted > 0) {
659 showToast(
660 failed > 0
661 ? `${deleted} item(s) deleted successfully. ${failed} item(s) failed to delete.`
662 : `${deleted} item(s) deleted successfully.`,
663 "success",
664 );
665 } else {
666 showToast("Failed to delete items. Please try again.", "error");
667 }
668
669 setSelectedEntries(new Set());
670 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
671 queryClient.invalidateQueries({ queryKey: ["trips"] });
672 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
673 refetch();
674 },
675 onError: (error: any) => {
676 showToast(
677 error?.message || "Failed to delete items. Please try again.",
678 "error",
679 );
680 },
681 });
682
683 const deleteMutation = useMutation({
684 mutationFn: async (entry: ItineraryEntry) => {
685 try {
686 // Determine mode based on whether this is a day entry or activity entry
687 const isDayEntry = !entry.item_type_id || entry.item_type_id === 0;
688 const mode = isDayEntry ? "day" : "activity";
689 const response = await apiClient.delete(
690 `/itinerary/${entry.id}?mode=${mode}`,
691 );
692 return { response: response?.data || response, entry };
693 } catch (error: any) {
694 throw error;
695 }
696 },
697 onSuccess: (data) => {
698 const entry = data.entry;
699 const isDayDeletion = (entry as any)?._isDayDeletion;
700 showToast(
701 isDayDeletion
702 ? __("Day and all activities deleted successfully", "yatra")
703 : __("Itinerary entry deleted successfully", "yatra"),
704 "success",
705 );
706 // Close confirmation dialog
707 setDeleteConfirm({ isOpen: false, entry: null });
708 // Refresh itinerary data
709 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
710 queryClient.invalidateQueries({ queryKey: ["trips"] });
711 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
712 // Force refetch
713 refetch();
714 },
715 onError: (error: any) => {
716 showToast(
717 error?.message || __("Failed to delete itinerary entry", "yatra"),
718 "error",
719 );
720 },
721 });
722
723 // Mutation to update itinerary entry item
724 const updateItemMutation = useMutation({
725 mutationFn: async ({
726 entryId,
727 itemId,
728 itemTypeId,
729 }: {
730 entryId: number;
731 itemId: number;
732 itemTypeId: number;
733 }) => {
734 // Item assignment is always for activities, so use mode=activity
735 const currentEntry = await apiClient.get(
736 `/itinerary/${entryId}?mode=activity`,
737 );
738 const entryData =
739 currentEntry?.data?.data || currentEntry?.data || currentEntry;
740
741 // Update with new item_id and item_type_id
742 return await apiClient.put(`/itinerary/${entryId}?mode=activity`, {
743 ...entryData,
744 item_id: itemId,
745 item_type_id: itemTypeId,
746 });
747 },
748 onSuccess: () => {
749 showToast(__("Item updated successfully", "yatra"), "success");
750 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
751 queryClient.invalidateQueries({ queryKey: ["trips"] });
752 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
753 refetch();
754 },
755 onError: (error: any) => {
756 showToast(
757 error?.message || __("Failed to update item", "yatra"),
758 "error",
759 );
760 },
761 });
762
763 const handleAddDay = () => {
764 // Navigate to add day form - this creates a new day entry
765 // If a trip is selected, pass trip_id; otherwise let the form show the trip selector
766 if (tripFilter) {
767 // Get the max day number for this trip to suggest the next day
768 const tripEntries = dayGroups.filter(
769 (dg: DayGroup) => dg.trip_id === parseInt(tripFilter),
770 );
771 const maxDay =
772 tripEntries.length > 0
773 ? Math.max(...tripEntries.map((dg: DayGroup) => dg.day))
774 : 0;
775 const nextDay = maxDay + 1;
776
777 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&trip_id=${tripFilter}&day=${nextDay}&mode=day`;
778 } else {
779 // No trip selected - navigate without trip_id so form shows trip selector
780 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&mode=day`;
781 }
782 };
783
784 // Get quick add options from item types and items
785 const quickAddOptions = useMemo(() => {
786 const itemTypes = Array.isArray(itemTypesData) ? itemTypesData : [];
787
788 return itemTypes
789 .map((itemType: any) => {
790 const parsed = parseItemTypeIcon(itemType.icon, "footprints");
791 const color = itemType.color || "blue";
792
793 return {
794 typeId: itemType.id,
795 typeName: itemType.name || "",
796 typeIcon: parsed.name,
797 typeIconProvider: parsed.provider,
798 typeIconImageSrc: parsed.isImage ? parsed.imageSrc : undefined,
799 typeIconData: itemType.icon,
800 typeColor: color,
801 };
802 })
803 .filter((option: any) => option.typeName); // Filter out invalid options
804 }, [itemTypesData]);
805
806 const handleQuickAdd = (typeId: number | string) => {
807 if (!selectedDay) {
808 // Select first day if none selected
809 const firstDay = data?.[0];
810 if (firstDay) {
811 setSelectedDay({ tripId: firstDay.trip_id, day: firstDay.day });
812 const key = `${firstDay.trip_id}-${firstDay.day}`;
813 setExpandedDays(new Set([key]));
814 }
815 }
816
817 const tripId =
818 selectedDay?.tripId || (tripFilter ? parseInt(tripFilter) : 1);
819 const day = selectedDay?.day || 1;
820
821 // Use item type ID for navigation
822 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&trip_id=${tripId}&day=${day}&type_id=${typeId}`;
823 };
824
825 const handleEdit = (entry: ItineraryEntry) => {
826 // Determine if this is a day entry or activity entry
827 // Day entries have item_type_id: 0 or null, activity entries have item_type_id > 0
828 const isDayEntry = !entry.item_type_id || entry.item_type_id === 0;
829 const mode = isDayEntry ? "day" : "activity";
830 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=edit&id=${entry.id}&mode=${mode}`;
831 };
832
833 const handleEditDay = async (tripId: number, day: number) => {
834 const redirectToCreate = () => {
835 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&trip_id=${tripId}&day=${day}&mode=day`;
836 };
837
838 const redirectToEdit = (entryId: number) => {
839 const params = new URLSearchParams({
840 action: "edit",
841 id: entryId.toString(),
842 mode: "day",
843 trip_id: tripId.toString(),
844 day: day.toString(),
845 });
846 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&${params.toString()}`;
847 };
848
849 const dayGroup = dayGroups.find(
850 (dg: DayGroup) => dg.trip_id === tripId && dg.day === day,
851 );
852
853 if (!dayGroup) {
854 redirectToCreate();
855 setDayMenuOpen(null);
856 return;
857 }
858
859 if (dayGroup.day_id) {
860 try {
861 const response = await apiClient.get(
862 `/itinerary/day-entry-by-day-id/${dayGroup.day_id}`,
863 );
864
865 const dayEntryId =
866 response?.data?.day_entry_id ||
867 response?.day_entry_id ||
868 response?.data?.id ||
869 response?.id ||
870 null;
871
872 if (dayEntryId) {
873 redirectToEdit(dayEntryId);
874 setDayMenuOpen(null);
875 return;
876 }
877 } catch (error) {
878 console.error(
879 "[YATRA DEBUG] Failed to fetch day entry for editing:",
880 error,
881 );
882 }
883 }
884
885 if (dayGroup.entries.length > 0) {
886 // Look for day entry (item_type_id and item_id are 0, null, or undefined)
887 const dayEntry = dayGroup.entries.find((entry: any) => {
888 const isDayEntry =
889 (entry.item_type_id === null ||
890 entry.item_type_id === undefined ||
891 entry.item_type_id === 0) &&
892 (entry.item_id === null ||
893 entry.item_id === undefined ||
894 entry.item_id === 0);
895
896 return isDayEntry;
897 });
898
899 if (dayEntry && dayEntry.id) {
900 redirectToEdit(dayEntry.id);
901 setDayMenuOpen(null);
902 return;
903 }
904
905 const firstEntry = dayGroup.entries[0];
906
907 if (firstEntry && firstEntry.id) {
908 redirectToEdit(firstEntry.id);
909 setDayMenuOpen(null);
910 return;
911 }
912 }
913
914 redirectToCreate();
915 setDayMenuOpen(null);
916 };
917
918 const handleDeleteDay = async (tripId: number, day: number) => {
919 // Find the day group
920 const dayGroup = dayGroups.find(
921 (dg: DayGroup) => dg.trip_id === tripId && dg.day === day,
922 );
923
924 if (!dayGroup) {
925 showToast(__("Day not found", "yatra"), "error");
926 setDayMenuOpen(null);
927 return;
928 }
929
930 // If we have day_id, fetch the day entry ID from backend
931 if (dayGroup.day_id) {
932 try {
933 const response = await apiClient.get(
934 `/itinerary/day-entry-by-day-id/${dayGroup.day_id}`,
935 );
936 const dayEntryId =
937 response?.data?.day_entry_id ||
938 response?.day_entry_id ||
939 response?.data?.id ||
940 response?.id ||
941 null;
942
943 if (dayEntryId) {
944 const dayEntryForDeletion = {
945 id: dayEntryId,
946 trip_id: tripId,
947 day: day,
948 _isDayDeletion: true,
949 _dayNumber: day,
950 } as ItineraryEntry & {
951 _isDayDeletion?: boolean;
952 _dayNumber?: number;
953 };
954
955 setDeleteConfirm({ isOpen: true, entry: dayEntryForDeletion });
956 } else {
957 showToast(__("Day entry not found", "yatra"), "error");
958 }
959 } catch (error: any) {
960 showToast(__("Failed to fetch day entry", "yatra"), "error");
961 }
962 } else {
963 showToast(__("Day ID not available", "yatra"), "error");
964 }
965 setDayMenuOpen(null);
966 };
967
968 const handleDelete = (entry: ItineraryEntry) => {
969 setDeleteConfirm({ isOpen: true, entry });
970 };
971
972 const handleDeleteConfirm = () => {
973 if (deleteConfirm.entry) {
974 deleteMutation.mutate(deleteConfirm.entry);
975 }
976 };
977
978 const handleDeleteCancel = () => {
979 setDeleteConfirm({ isOpen: false, entry: null });
980 };
981
982 // Bulk selection handlers
983 const handleToggleSelect = (entryId: number) => {
984 setSelectedEntries((prev) => {
985 const newSet = new Set(prev);
986 if (newSet.has(entryId)) {
987 newSet.delete(entryId);
988 } else {
989 newSet.add(entryId);
990 }
991 return newSet;
992 });
993 };
994
995 const handleSelectAll = (dayGroup: DayGroup) => {
996 const allEntryIds = dayGroup.entries.map((e) => e.id);
997 setSelectedEntries((prev) => {
998 const newSet = new Set(prev);
999 const allSelected = allEntryIds.every((id) => newSet.has(id));
1000 if (allSelected) {
1001 allEntryIds.forEach((id) => newSet.delete(id));
1002 } else {
1003 allEntryIds.forEach((id) => newSet.add(id));
1004 }
1005 return newSet;
1006 });
1007 };
1008
1009 const handleSelectAllDays = () => {
1010 // Only operate on entries that are currently visible under the active status tab
1011 const allEntryIds = filteredDayGroups.flatMap((dg) =>
1012 dg.entries.map((e) => e.id),
1013 );
1014 const emptyDayKeys = filteredDayGroups
1015 .filter((dg) => dg.entries.length === 0)
1016 .map((dg) => `${dg.trip_id}-${dg.day}`);
1017
1018 setSelectedEntries((prev) => {
1019 const newSet = new Set(prev);
1020 const allSelected =
1021 allEntryIds.length > 0 && allEntryIds.every((id) => newSet.has(id));
1022 if (allSelected) {
1023 allEntryIds.forEach((id) => newSet.delete(id));
1024 } else {
1025 allEntryIds.forEach((id) => newSet.add(id));
1026 }
1027 return newSet;
1028 });
1029
1030 // Keep empty-day selection in sync with global Select All
1031 setSelectedEmptyDays((prev) => {
1032 const allSelected =
1033 emptyDayKeys.length > 0 && emptyDayKeys.every((key) => prev.has(key));
1034 const next = new Set(prev);
1035 if (allSelected) {
1036 emptyDayKeys.forEach((key) => next.delete(key));
1037 } else {
1038 emptyDayKeys.forEach((key) => next.add(key));
1039 }
1040 return next;
1041 });
1042 };
1043
1044 const handleBulkApply = async () => {
1045 const ids = Array.from(selectedEntries);
1046 const emptyDayKeys = Array.from(selectedEmptyDays);
1047
1048 // Also treat days that have at least one selected entry as selected days
1049 const selectedDayKeysFromEntries = new Set<string>();
1050 filteredDayGroups.forEach((dg) => {
1051 const hasSelectedEntry = dg.entries.some((e) =>
1052 selectedEntries.has(e.id),
1053 );
1054 if (hasSelectedEntry) {
1055 selectedDayKeysFromEntries.add(`${dg.trip_id}-${dg.day}`);
1056 }
1057 });
1058
1059 const combinedDayKeys = new Set<string>([
1060 ...emptyDayKeys,
1061 ...selectedDayKeysFromEntries,
1062 ]);
1063
1064 if (!bulkAction || (ids.length === 0 && combinedDayKeys.size === 0)) {
1065 showToast(
1066 __("Please select entries or days and a bulk action first.", "yatra"),
1067 "error",
1068 );
1069 return;
1070 }
1071
1072 // Resolve day_entry IDs for selected empty days
1073 const resolveDayEntryIds = async (): Promise<number[]> => {
1074 const dayEntryIds: number[] = [];
1075
1076 for (const key of combinedDayKeys) {
1077 const [tripIdStr, dayStr] = key.split("-");
1078 const tripIdNum = parseInt(tripIdStr, 10);
1079 const dayNum = parseInt(dayStr, 10);
1080
1081 if (Number.isNaN(tripIdNum) || Number.isNaN(dayNum)) {
1082 continue;
1083 }
1084
1085 const dg = dayGroups.find(
1086 (g) => g.trip_id === tripIdNum && g.day === dayNum,
1087 );
1088
1089 if (!dg || !dg.day_id) {
1090 continue;
1091 }
1092
1093 try {
1094 const response = await apiClient.get(
1095 `/itinerary/day-entry-by-day-id/${dg.day_id}`,
1096 );
1097 const dayEntryId =
1098 response?.data?.day_entry_id ||
1099 response?.day_entry_id ||
1100 response?.data?.id ||
1101 response?.id ||
1102 null;
1103
1104 if (dayEntryId) {
1105 dayEntryIds.push(Number(dayEntryId));
1106 }
1107 } catch (error) {
1108 // Ignore failures for individual days; bulk handler will still proceed for others
1109 console.error("Failed to resolve day entry ID for day", key, error);
1110 }
1111 }
1112
1113 return dayEntryIds;
1114 };
1115
1116 const dayEntryIds = await resolveDayEntryIds();
1117 const allIdsForAction = [...ids, ...dayEntryIds];
1118
1119 if (allIdsForAction.length === 0) {
1120 showToast(
1121 __("No valid entries or days found to apply this action.", "yatra"),
1122 "error",
1123 );
1124 return;
1125 }
1126
1127 // Delete permanently (uses dedicated bulk delete endpoint)
1128 if (bulkAction === "delete") {
1129 bulkDeleteMutation.mutate(allIdsForAction);
1130 return;
1131 }
1132
1133 // Status changes are applied one-by-one using the REST API
1134 const targetStatus = (() => {
1135 if (bulkAction === "publish") return "published";
1136 if (bulkAction === "draft") return "draft";
1137 if (bulkAction === "trash") return "trash";
1138 if (bulkAction === "restore") return "draft"; // restore from Trash -> Draft
1139 return "";
1140 })();
1141
1142 if (!targetStatus) {
1143 return;
1144 }
1145
1146 try {
1147 await Promise.all(
1148 allIdsForAction.map(async (id) => {
1149 // First fetch with activity mode (most common case)
1150 // If it fails, the backend will handle it
1151 let current;
1152 let mode = "activity";
1153
1154 try {
1155 current = await apiClient.get(`/itinerary/${id}?mode=activity`);
1156 } catch (error) {
1157 // If activity mode fails, try day mode
1158 mode = "day";
1159 current = await apiClient.get(`/itinerary/${id}?mode=day`);
1160 }
1161
1162 const entryData = current?.data?.data || current?.data || current;
1163
1164 // Apply status change while preserving other fields
1165 await apiClient.put(`/itinerary/${id}?mode=${mode}`, {
1166 ...entryData,
1167 status: targetStatus,
1168 });
1169 }),
1170 );
1171
1172 showToast(__("Bulk action applied successfully.", "yatra"), "success");
1173 setSelectedEntries(new Set());
1174 setSelectedEmptyDays(new Set());
1175 setBulkAction("");
1176 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
1177 queryClient.invalidateQueries({ queryKey: ["trips"] });
1178 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
1179 refetch();
1180 } catch (error: any) {
1181 showToast(
1182 error?.message ||
1183 __("Failed to apply bulk action. Please try again.", "yatra"),
1184 "error",
1185 );
1186 }
1187 };
1188
1189 const toggleDay = (tripId: number, day: number) => {
1190 const key = `${tripId}-${day}`;
1191 setExpandedDays((prev) => {
1192 const next = new Set(prev);
1193 if (next.has(key)) {
1194 next.delete(key);
1195 } else {
1196 next.add(key);
1197 }
1198 return next;
1199 });
1200 setSelectedDay({ tripId, day });
1201 };
1202
1203 const getItemCounts = (entries: ItineraryEntry[]) => {
1204 const meals = entries.filter((e) => e.item_type === "Meal").length;
1205 const activities = entries.filter((e) => e.item_type === "Activity").length;
1206 const accommodations = entries.filter(
1207 (e) => e.item_type === "Accommodation",
1208 ).length;
1209 return { meals, activities, accommodations, total: entries.length };
1210 };
1211
1212 const getDayStatusBadge = (dayGroup: DayGroup) => {
1213 const normalize = (value?: string | null) => (value || "").toLowerCase();
1214
1215 // Prefer explicit day_status from the day entry
1216 const explicitStatus = normalize(dayGroup.day_status);
1217
1218 let label = "";
1219 let className = "";
1220
1221 if (explicitStatus === "publish") {
1222 label = __("Published", "yatra");
1223 className =
1224 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400";
1225 } else if (explicitStatus === "draft") {
1226 label = __("Draft", "yatra");
1227 className =
1228 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1229 } else if (explicitStatus === "trash") {
1230 label = __("Trash", "yatra");
1231 className =
1232 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
1233 } else {
1234 // Fallback: infer from activity entries when day_status is not set
1235 const statuses = dayGroup.entries.map((e) => normalize(e.status));
1236
1237 const hasPublished = statuses.some((s) => s === "publish");
1238 const hasDraft = statuses.some((s) => s === "draft");
1239 const hasTrash = statuses.some((s) => s === "trash");
1240
1241 if (hasPublished) {
1242 label = __("Published", "yatra");
1243 className =
1244 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400";
1245 } else if (hasDraft) {
1246 label = __("Draft", "yatra");
1247 className =
1248 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1249 } else if (hasTrash) {
1250 label = __("Trash", "yatra");
1251 className =
1252 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
1253 } else {
1254 label = __("No Status", "yatra");
1255 className =
1256 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1257 }
1258 }
1259
1260 return (
1261 <Badge className={`ml-2 text-[11px] px-2 py-0.5 ${className}`}>
1262 {label}
1263 </Badge>
1264 );
1265 };
1266
1267 const formatTime = (time: string) => {
1268 // Convert 24h to 12h format
1269 const [hours, minutes] = time.split(":");
1270 const hour = parseInt(hours);
1271 const ampm = hour >= 12 ? "PM" : "AM";
1272 const hour12 = hour % 12 || 12;
1273 return `${hour12}:${minutes} ${ampm}`;
1274 };
1275
1276 const getEntryStatusBadge = (status: string) => {
1277 const normalized = (status || "").toLowerCase();
1278
1279 if (normalized === "publish" || normalized === "published") {
1280 return (
1281 <Badge className="ml-2 text-[11px] px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400 border border-green-200 dark:border-green-800">
1282 {__("Published", "yatra")}
1283 </Badge>
1284 );
1285 }
1286
1287 if (normalized === "draft") {
1288 return (
1289 <Badge className="ml-2 text-[11px] px-2 py-0.5 bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600">
1290 {__("Draft", "yatra")}
1291 </Badge>
1292 );
1293 }
1294
1295 if (normalized === "trash") {
1296 return (
1297 <Badge className="ml-2 text-[11px] px-2 py-0.5 bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border border-gray-400 dark:border-gray-700">
1298 {__("Trash", "yatra")}
1299 </Badge>
1300 );
1301 }
1302
1303 return null;
1304 };
1305
1306 const dayGroups: DayGroup[] = (data || []) as DayGroup[];
1307 const errorContext = getErrorContext(error);
1308 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
1309 const derivedErrorDetails =
1310 errorContext.details ||
1311 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
1312 (error ? String(error?.message || error) : undefined);
1313
1314 // More precise error detection - only true if query ran and failed
1315 const queryEnabled = can("yatra_view_trips") && !!tripsData && !!tripFilter;
1316 const isItineraryError = queryEnabled && (!!error || !!apiErrorMessage);
1317
1318 // Debug logging to understand the state
1319
1320 const matchesStatusValue = (
1321 statusValue: string | undefined | null,
1322 filter: string,
1323 ) => {
1324 if (!filter || filter === "all") {
1325 return true;
1326 }
1327
1328 const status = (statusValue || "").toLowerCase();
1329
1330 if (filter === "publish") {
1331 return status === "publish";
1332 }
1333
1334 if (filter === "draft") {
1335 return status === "draft";
1336 }
1337
1338 if (filter === "trash") {
1339 return status === "trash";
1340 }
1341
1342 // Fallback: treat unknown filters as "match all"
1343 return true;
1344 };
1345
1346 const matchesStatusFilter = (entry: ItineraryEntry, filter: string) => {
1347 return matchesStatusValue(entry.status, filter);
1348 };
1349
1350 const matchesStatusFilterForDay = (dayGroup: DayGroup, filter: string) => {
1351 if (!filter || filter === "all") {
1352 return true;
1353 }
1354
1355 // Prefer explicit day_status when available
1356 if (matchesStatusValue(dayGroup.day_status, filter)) {
1357 return true;
1358 }
1359
1360 // Fallback: infer from any of the day's entries
1361 return dayGroup.entries.some((entry) => matchesStatusFilter(entry, filter));
1362 };
1363
1364 const filteredDayGroups: DayGroup[] = useMemo(() => {
1365 const groupsWithFilteredEntries = dayGroups.map((dg) => ({
1366 ...dg,
1367 entries: dg.entries.filter((entry) =>
1368 matchesStatusFilter(entry, statusFilter),
1369 ),
1370 }));
1371
1372 if (!statusFilter || statusFilter === "all") {
1373 return groupsWithFilteredEntries;
1374 }
1375
1376 // For specific status tabs, only show days whose own status OR any entry matches the filter
1377 return groupsWithFilteredEntries.filter((dg) =>
1378 matchesStatusFilterForDay(dg, statusFilter),
1379 );
1380 }, [dayGroups, statusFilter]);
1381
1382 const totalEntries = filteredDayGroups.reduce(
1383 (acc, dg) => acc + dg.entries.length,
1384 0,
1385 );
1386 const totalEmptyDays = filteredDayGroups.filter(
1387 (dg) => dg.entries.length === 0,
1388 ).length;
1389 const totalSelectableItems = totalEntries + totalEmptyDays;
1390
1391 // Precompute all entry IDs for header "Select All" checkbox (respecting current status filter)
1392 const allEntryIds = filteredDayGroups.flatMap((dg) =>
1393 dg.entries.map((e) => e.id),
1394 );
1395
1396 const statusCounts = dayGroups.reduce(
1397 (acc, dg) => {
1398 if (matchesStatusFilterForDay(dg, "publish")) {
1399 acc.publish += 1;
1400 }
1401 if (matchesStatusFilterForDay(dg, "draft")) {
1402 acc.draft += 1;
1403 }
1404 if (matchesStatusFilterForDay(dg, "trash")) {
1405 acc.trash += 1;
1406 }
1407
1408 return acc;
1409 },
1410 { publish: 0, draft: 0, trash: 0 },
1411 );
1412
1413 const statusOptions = [
1414 { key: "all", label: __("All", "yatra"), count: dayGroups.length },
1415 {
1416 key: "publish",
1417 label: __("Published", "yatra"),
1418 count: statusCounts.publish,
1419 },
1420 { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft },
1421 { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash },
1422 ];
1423
1424 const bulkActionOptions = useMemo(() => {
1425 // In Trash view: allow restore to Draft and permanent delete only
1426 if (statusFilter === "trash") {
1427 return [
1428 { value: "restore", label: __("Restore to Draft", "yatra") },
1429 { value: "delete", label: __("Delete Permanently", "yatra") },
1430 ];
1431 }
1432
1433 // Other views: normal status changes + move to trash + delete
1434 return [
1435 { value: "publish", label: __("Mark as Published", "yatra") },
1436 { value: "draft", label: __("Mark as Draft", "yatra") },
1437 { value: "trash", label: __("Move to Trash", "yatra") },
1438 { value: "delete", label: __("Delete Permanently", "yatra") },
1439 ];
1440 }, [statusFilter]);
1441
1442 // Close day menu when clicking outside
1443 useEffect(() => {
1444 const handleClickOutside = () => {
1445 setDayMenuOpen(null);
1446 };
1447 if (dayMenuOpen) {
1448 document.addEventListener("click", handleClickOutside);
1449 return () => document.removeEventListener("click", handleClickOutside);
1450 }
1451 }, [dayMenuOpen]);
1452
1453 return (
1454 <div className="space-y-4 pb-6">
1455 <div className="flex items-center justify-between mb-8">
1456 <div>
1457 <p className="text-sm text-gray-500 dark:text-gray-400">
1458 {__(
1459 "Build your complete itinerary with activities, meals, and accommodations",
1460 "yatra",
1461 )}
1462 </p>
1463 </div>
1464 </div>
1465
1466 {/* Trip Selector - Clean Design */}
1467 <div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 rounded-lg border border-blue-200 dark:border-gray-700 p-6 mb-6">
1468 <div className="flex items-start justify-between gap-4 mb-4">
1469 <div className="flex-1 min-w-0">
1470 <div className="flex items-center gap-2 mb-1">
1471 <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
1472 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1473 {__("Select Trip", "yatra")}
1474 </h3>
1475 </div>
1476 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1477 {__(
1478 "Choose a trip to manage its itinerary entries and day-by-day schedule",
1479 "yatra",
1480 )}
1481 </p>
1482 </div>
1483 </div>
1484
1485 <div className="space-y-4 overflow-visible">
1486 <div>
1487 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1488 {__("Trip", "yatra")} <span className="text-red-500">*</span>
1489 </label>
1490 <SearchableSelect
1491 value={tripFilter}
1492 onChange={(value) => setTripFilter(value)}
1493 options={
1494 (Array.isArray(tripsData) ? tripsData : []).map(
1495 (trip: any) => ({
1496 value: trip.id.toString(),
1497 label: `${trip.title || trip.name || ""}${trip.trip_type === "single_day" ? __(" (Single Day)", "yatra") : trip.trip_type === "multi_day" ? __(" (Multi-Day)", "yatra") : ""}`,
1498 }),
1499 ) || []
1500 }
1501 placeholder={__("Search or select a trip...", "yatra")}
1502 searchPlaceholder={__("Search by trip name or ID...", "yatra")}
1503 className="w-full"
1504 required
1505 />
1506 </div>
1507
1508 {tripFilter &&
1509 (() => {
1510 const tripsList = Array.isArray(tripsData) ? tripsData : [];
1511 const selectedTrip = tripsList.find(
1512 (t: any) => t.id.toString() === tripFilter,
1513 );
1514 return selectedTrip ? (
1515 <div className="flex items-center gap-4 pt-2 border-t border-blue-200 dark:border-gray-700">
1516 <div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
1517 <MapPin className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1518 <span className="font-medium">
1519 {selectedTrip.starting_location ||
1520 selectedTrip.start_location ||
1521 "N/A"}
1522 </span>
1523 <span className="text-gray-400">→</span>
1524 <span className="font-medium">
1525 {selectedTrip.ending_location ||
1526 selectedTrip.end_location ||
1527 "N/A"}
1528 </span>
1529 </div>
1530 {selectedTrip.trip_type && (
1531 <Badge
1532 className={
1533 selectedTrip.trip_type === "single_day"
1534 ? "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
1535 : "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400"
1536 }
1537 >
1538 {selectedTrip.trip_type === "single_day"
1539 ? __("Single Day", "yatra")
1540 : __("Multi-Day", "yatra")}
1541 </Badge>
1542 )}
1543 </div>
1544 ) : null;
1545 })()}
1546
1547 {!tripFilter && (
1548 <div className="mt-4 p-4 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-blue-200/50 dark:border-gray-700/50 overflow-visible">
1549 <div className="flex items-start gap-3">
1550 <div className="flex-shrink-0 mt-0.5">
1551 <div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
1552 <AlertCircle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1553 </div>
1554 </div>
1555 <div className="flex-1 min-w-0">
1556 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1557 {__("Get Started", "yatra")}
1558 </p>
1559 <p className="text-sm text-gray-600 dark:text-gray-400">
1560 {__(
1561 "Select a trip from the dropdown above to view and manage its itinerary entries and day-by-day schedule.",
1562 "yatra",
1563 )}
1564 </p>
1565 </div>
1566 </div>
1567 </div>
1568 )}
1569 </div>
1570 </div>
1571
1572 {/* Add Day / Add Entry button below trip selector */}
1573 {tripFilter &&
1574 (() => {
1575 const selectedTrip = tripsData?.find(
1576 (t: any) => t.id.toString() === tripFilter,
1577 );
1578 const isSingleDay = selectedTrip?.trip_type === "single_day";
1579 const buttonLabel = isSingleDay
1580 ? __("Add Entry", "yatra")
1581 : __("Add Day", "yatra");
1582
1583 return (
1584 <div className="flex justify-end mt-4">
1585 <ConditionalRender capability="yatra_edit_trips">
1586 <Button
1587 onClick={handleAddDay}
1588 className="flex items-center gap-2 bg-black text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
1589 >
1590 <Plus className="w-4 h-4" />
1591 {buttonLabel}
1592 </Button>
1593 </ConditionalRender>
1594 </div>
1595 );
1596 })()}
1597
1598 <ConditionalRender capability="yatra_view_trips">
1599 <>
1600 <BulkActionToolbar
1601 selectedIds={[
1602 ...Array.from(selectedEntries),
1603 ...Array.from(selectedEmptyDays),
1604 ]}
1605 bulkAction={bulkAction}
1606 setBulkAction={setBulkAction}
1607 onApply={handleBulkApply}
1608 onClearSelection={() => {
1609 setSelectedEntries(new Set());
1610 setSelectedEmptyDays(new Set());
1611 }}
1612 statusFilter={statusFilter}
1613 setStatusFilter={setStatusFilter}
1614 statusOptions={statusOptions}
1615 showColumnsDropdown={showColumnsDropdown}
1616 setShowColumnsDropdown={setShowColumnsDropdown}
1617 columnOptions={[]}
1618 onToggleColumn={() => {}}
1619 bulkMutationPending={bulkDeleteMutation.isPending}
1620 totalItems={totalSelectableItems}
1621 bulkActionOptions={bulkActionOptions}
1622 />
1623 {isItineraryError ? (
1624 <Card>
1625 <CardContent className="p-6">
1626 <div className="space-y-4">
1627 <div className="flex items-center gap-3">
1628 <div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
1629 <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
1630 </div>
1631 <div>
1632 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1633 {__("Error Loading Itinerary", "yatra")}
1634 </h3>
1635 <p className="text-sm text-gray-600 dark:text-gray-400">
1636 {__(
1637 "We couldn't connect to the itinerary service. Please refresh or try again shortly.",
1638 "We couldn't connect to the itinerary service. Please refresh or try again shortly.",
1639 )}
1640 </p>
1641 </div>
1642 </div>
1643
1644 {derivedErrorDetails && (
1645 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
1646 <div className="flex items-center justify-between mb-2">
1647 <h4 className="text-sm font-medium text-gray-900 dark:text-white">
1648 {__("Error Details", "yatra")}
1649 </h4>
1650 <button
1651 onClick={() => {
1652 const details = JSON.stringify(
1653 {
1654 error: derivedErrorDetails,
1655 request: errorContext.requestInfo,
1656 },
1657 null,
1658 2,
1659 );
1660 navigator.clipboard.writeText(details);
1661 // Show toast feedback
1662 showToast(
1663 __("Error details copied to clipboard", "yatra"),
1664 "success",
1665 );
1666 }}
1667 className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
1668 >
1669 {__("Copy", "yatra")}
1670 </button>
1671 </div>
1672 <div className="space-y-2 text-sm">
1673 <div className="text-gray-700 dark:text-gray-300 font-mono bg-white dark:bg-gray-900 p-2 rounded border">
1674 {derivedErrorDetails}
1675 </div>
1676 {errorContext.requestInfo && (
1677 <div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
1678 <div>
1679 <strong>{__("Request", "yatra")}:</strong>{" "}
1680 {errorContext.requestInfo.method}{" "}
1681 {errorContext.requestInfo.url}
1682 </div>
1683 {errorContext.requestInfo.payload && (
1684 <div>
1685 <strong>{__("Payload", "yatra")}:</strong>{" "}
1686 <pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-1 rounded overflow-auto max-h-20">
1687 {JSON.stringify(
1688 errorContext.requestInfo.payload,
1689 null,
1690 2,
1691 )}
1692 </pre>
1693 </div>
1694 )}
1695 </div>
1696 )}
1697 </div>
1698 </div>
1699 )}
1700
1701 <div className="flex gap-2">
1702 <Button
1703 onClick={() => refetch()}
1704 variant="outline"
1705 className="flex items-center gap-2"
1706 >
1707 <AlertCircle className="w-4 h-4" />
1708 {__("Retry", "yatra")}
1709 </Button>
1710 <Button
1711 onClick={() => window.location.reload()}
1712 variant="ghost"
1713 className="text-gray-600 dark:text-gray-400"
1714 >
1715 {__("Refresh Page", "yatra")}
1716 </Button>
1717 </div>
1718 </div>
1719 </CardContent>
1720 </Card>
1721 ) : isLoading || isLoadingTrips ? (
1722 <div className="space-y-3">
1723 {[1, 2, 3].map((i) => (
1724 <Card key={i} className="overflow-hidden">
1725 <CardContent className="p-0">
1726 <div className="flex items-center gap-4 p-4">
1727 <div className="flex items-center gap-4 flex-1">
1728 <div className="w-16 h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1729 <div className="flex-1">
1730 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2 animate-pulse"></div>
1731 <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1732 </div>
1733 </div>
1734 </div>
1735 </CardContent>
1736 </Card>
1737 ))}
1738 </div>
1739 ) : filteredDayGroups.length === 0 ? (
1740 <Card>
1741 <CardContent className="p-12 text-center">
1742 <div className="flex flex-col items-center gap-4">
1743 <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
1744 <Calendar className="w-8 h-8 text-gray-400" />
1745 </div>
1746 <div>
1747 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
1748 {(() => {
1749 const selectedTrip = tripsData?.find(
1750 (t: any) => t.id.toString() === tripFilter,
1751 );
1752 const isSingleDay =
1753 selectedTrip?.trip_type === "single_day";
1754 return isSingleDay
1755 ? __("No itinerary entries yet", "yatra")
1756 : __("No itinerary days yet", "yatra");
1757 })()}
1758 </h3>
1759 <p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
1760 {(() => {
1761 const selectedTrip = tripsData?.find(
1762 (t: any) => t.id.toString() === tripFilter,
1763 );
1764 const isSingleDay =
1765 selectedTrip?.trip_type === "single_day";
1766 return isSingleDay
1767 ? __(
1768 "Start building your itinerary by adding your first entry to create a detailed schedule for your trip.",
1769 "yatra",
1770 )
1771 : __(
1772 "Start building your itinerary by adding your first day to organize your trip schedule.",
1773 "yatra",
1774 );
1775 })()}
1776 </p>
1777 {can("yatra_edit_trips") && (
1778 <Button
1779 onClick={handleAddDay}
1780 className="flex items-center gap-2 mx-auto"
1781 >
1782 <Plus className="w-4 h-4" />
1783 {(() => {
1784 const selectedTrip = tripsData?.find(
1785 (t: any) => t.id.toString() === tripFilter,
1786 );
1787 const isSingleDay =
1788 selectedTrip?.trip_type === "single_day";
1789 return isSingleDay
1790 ? __("Add Your First Entry", "yatra")
1791 : __("Add Your First Day", "yatra");
1792 })()}
1793 </Button>
1794 )}
1795 </div>
1796 </div>
1797 </CardContent>
1798 </Card>
1799 ) : (
1800 <div className="space-y-3">
1801 {/* Bulk Select All Option */}
1802 {filteredDayGroups.length > 0 && (
1803 <ConditionalRender capability="yatra_delete_trips">
1804 <Card className="overflow-visible">
1805 <CardContent className="p-0">
1806 <div className="flex items-center justify-between gap-4 rounded-t-lg">
1807 <div className="flex items-center gap-3">
1808 <input
1809 type="checkbox"
1810 checked={
1811 allEntryIds.length > 0 &&
1812 allEntryIds.every((id) => selectedEntries.has(id))
1813 }
1814 onChange={handleSelectAllDays}
1815 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
1816 />
1817 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
1818 {__("Select All", "yatra")}
1819 </label>
1820 {selectedEntries.size > 0 && (
1821 <span className="text-sm text-gray-500 dark:text-gray-400">
1822 ({selectedEntries.size} {__("selected", "yatra")})
1823 </span>
1824 )}
1825 </div>
1826 <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
1827 <button
1828 type="button"
1829 className="hover:text-gray-700 dark:hover:text-gray-200 underline-offset-2 hover:underline"
1830 onClick={() => {
1831 // Expand all days
1832 const allKeys = filteredDayGroups.map(
1833 (dg) => `${dg.trip_id}-${dg.day}`,
1834 );
1835 setExpandedDays(new Set(allKeys));
1836 }}
1837 >
1838 {__("Expand all", "yatra")}
1839 </button>
1840 <button
1841 type="button"
1842 className="hover:text-gray-700 dark:hover:text-gray-200 underline-offset-2 hover:underline"
1843 onClick={() => {
1844 // Collapse all days
1845 setExpandedDays(new Set());
1846 }}
1847 >
1848 {__("Collapse all", "yatra")}
1849 </button>
1850 </div>
1851 </div>
1852 </CardContent>
1853 </Card>
1854 </ConditionalRender>
1855 )}
1856 {filteredDayGroups.map((dayGroup) => {
1857 const key = `${dayGroup.trip_id}-${dayGroup.day}`;
1858 const isExpanded = expandedDays.has(key);
1859 const counts = getItemCounts(dayGroup.entries);
1860 const sortedEntries = [...dayGroup.entries].sort((a, b) => {
1861 const timeA = a.start_time.split(":").map(Number);
1862 const timeB = b.start_time.split(":").map(Number);
1863 return timeA[0] * 60 + timeA[1] - (timeB[0] * 60 + timeB[1]);
1864 });
1865
1866 const tripsList = Array.isArray(tripsData) ? tripsData : [];
1867 const selectedTripForLayout = tripsList.find(
1868 (t: any) => t.id.toString() === tripFilter,
1869 );
1870 const isSingleDayTrip =
1871 selectedTripForLayout?.trip_type === "single_day";
1872
1873 return (
1874 <div
1875 key={key}
1876 data-day-key={key}
1877 className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 rounded-lg border border-blue-200 dark:border-gray-700 mb-4 overflow-visible"
1878 >
1879 {/* Day Header */}
1880 <div className="flex items-center gap-4 px-4 py-3 rounded-t-lg">
1881 <ConditionalRender capability="yatra_delete_trips">
1882 <input
1883 type="checkbox"
1884 checked={
1885 dayGroup.entries.length === 0
1886 ? selectedEmptyDays.has(key)
1887 : dayGroup.entries.every((e) =>
1888 selectedEntries.has(e.id),
1889 )
1890 }
1891 onChange={(e) => {
1892 e.stopPropagation();
1893 if (dayGroup.entries.length > 0) {
1894 handleSelectAll(dayGroup);
1895 } else {
1896 setSelectedEmptyDays((prev) => {
1897 const next = new Set(prev);
1898 if (next.has(key)) {
1899 next.delete(key);
1900 } else {
1901 next.add(key);
1902 }
1903 return next;
1904 });
1905 }
1906 }}
1907 onClick={(e) => e.stopPropagation()}
1908 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
1909 title={__("Select all entries for this day", "yatra")}
1910 />
1911 </ConditionalRender>
1912 <div
1913 className="flex items-center gap-4 flex-1 cursor-pointer"
1914 onClick={() =>
1915 toggleDay(dayGroup.trip_id, dayGroup.day)
1916 }
1917 >
1918 <div className="flex items-center gap-3">
1919 <div className="px-3 py-1 rounded-full bg-blue-600 text-white text-sm font-medium">
1920 {isSingleDayTrip
1921 ? __("Entry", "yatra")
1922 : __("Day", "yatra")}{" "}
1923 {dayGroup.day}
1924 </div>
1925 <div>
1926 <h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
1927 {dayGroup.day_title
1928 ? `${isSingleDayTrip ? __("Entry", "yatra") : __("Day", "yatra")} ${dayGroup.day}: ${dayGroup.day_title}`
1929 : `${isSingleDayTrip ? __("Entry", "yatra") : __("Day", "yatra")} ${dayGroup.day}`}
1930 {getDayStatusBadge(dayGroup)}
1931 </h3>
1932 <div className="flex items-center gap-3 mt-1 text-sm text-gray-600 dark:text-gray-400">
1933 <span>
1934 {counts.total} {__("items", "yatra")}
1935 </span>
1936 {counts.meals > 0 && (
1937 <div className="flex items-center gap-1">
1938 <UtensilsCrossed className="w-3.5 h-3.5" />
1939 <span>{counts.meals}</span>
1940 </div>
1941 )}
1942 {counts.activities > 0 && (
1943 <div className="flex items-center gap-1">
1944 <MapPin className="w-3.5 h-3.5" />
1945 <span>{counts.activities}</span>
1946 </div>
1947 )}
1948 {counts.accommodations > 0 && (
1949 <div className="flex items-center gap-1">
1950 <Hotel className="w-3.5 h-3.5" />
1951 <span>{__("Stay", "yatra")}</span>
1952 </div>
1953 )}
1954 </div>
1955 </div>
1956 </div>
1957 </div>
1958 <div
1959 className="flex items-center gap-2 relative cursor-pointer"
1960 onClick={(e) => {
1961 e.stopPropagation();
1962 toggleDay(dayGroup.trip_id, dayGroup.day);
1963 }}
1964 >
1965 <ConditionalRender capability="yatra_edit_trips">
1966 <div className="relative">
1967 <Button
1968 variant="ghost"
1969 size="icon"
1970 className="h-8 w-8"
1971 onClick={(e) => {
1972 e.stopPropagation();
1973 if (
1974 dayMenuOpen &&
1975 dayMenuOpen.tripId === dayGroup.trip_id &&
1976 dayMenuOpen.day === dayGroup.day
1977 ) {
1978 setDayMenuOpen(null);
1979 } else {
1980 setDayMenuOpen({
1981 tripId: dayGroup.trip_id,
1982 day: dayGroup.day,
1983 });
1984 }
1985 }}
1986 title={__("Day Options", "yatra")}
1987 >
1988 <MoreVertical className="w-4 h-4" />
1989 </Button>
1990 {dayMenuOpen &&
1991 dayMenuOpen.tripId === dayGroup.trip_id &&
1992 dayMenuOpen.day === dayGroup.day && (
1993 <div
1994 className="absolute right-0 top-10 z-50 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 py-1"
1995 onClick={(e) => e.stopPropagation()}
1996 >
1997 <button
1998 onClick={() =>
1999 handleEditDay(
2000 dayGroup.trip_id,
2001 dayGroup.day,
2002 )
2003 }
2004 className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
2005 >
2006 <Pencil className="w-4 h-4" />
2007 {isSingleDayTrip
2008 ? __("Edit Entry", "yatra")
2009 : __("Edit Day", "yatra")}
2010 </button>
2011 <ConditionalRender capability="yatra_delete_trips">
2012 <button
2013 onClick={() =>
2014 handleDeleteDay(
2015 dayGroup.trip_id,
2016 dayGroup.day,
2017 )
2018 }
2019 className="w-full px-4 py-2 text-left text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
2020 >
2021 <Trash2 className="w-4 h-4" />
2022 {isSingleDayTrip
2023 ? __("Delete Entry", "yatra")
2024 : __("Delete Day", "yatra")}
2025 </button>
2026 </ConditionalRender>
2027 </div>
2028 )}
2029 </div>
2030 </ConditionalRender>
2031 {isExpanded ? (
2032 <ChevronDown className="w-5 h-5 text-gray-400" />
2033 ) : (
2034 <ChevronRight className="w-5 h-5 text-gray-400" />
2035 )}
2036 </div>
2037 </div>
2038
2039 {/* Day Content - Expanded */}
2040 {isExpanded && (
2041 <div className="border-t border-gray-200 dark:border-gray-700 overflow-visible">
2042 {/* Quick Add Section */}
2043 <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
2044 <div className="flex flex-wrap items-center gap-3">
2045 <div className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
2046 {__("QUICK ADD", "yatra")}
2047 </div>
2048 <div className="flex flex-wrap gap-2">
2049 {quickAddOptions.length > 0 ? (
2050 quickAddOptions.map((option: any) => {
2051 // Get color classes based on typeColor
2052 const colorClasses: Record<string, string> = {
2053 blue: "border-blue-500 text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20",
2054 green:
2055 "border-green-500 text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/20",
2056 red: "border-red-500 text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20",
2057 yellow:
2058 "border-yellow-500 text-yellow-700 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20",
2059 purple:
2060 "border-purple-500 text-purple-700 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20",
2061 orange:
2062 "border-orange-500 text-orange-700 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-900/20",
2063 pink: "border-pink-500 text-pink-700 hover:bg-pink-50 dark:text-pink-400 dark:hover:bg-pink-900/20",
2064 indigo:
2065 "border-indigo-500 text-indigo-700 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900/20",
2066 gray: "border-gray-500 text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-900/20",
2067 };
2068
2069 const colorClass =
2070 colorClasses[option.typeColor] ||
2071 colorClasses.blue;
2072
2073 return (
2074 <Button
2075 key={option.typeId}
2076 variant="outline"
2077 size="sm"
2078 onClick={() =>
2079 handleQuickAdd(option.typeId)
2080 }
2081 className={`flex items-center gap-2 ${colorClass}`}
2082 style={{
2083 borderColor: option.typeColor
2084 ? `var(--color-${option.typeColor}-500)`
2085 : undefined,
2086 }}
2087 >
2088 {option.typeIconImageSrc ? (
2089 <img
2090 src={option.typeIconImageSrc}
2091 alt={option.typeName}
2092 className="w-4 h-4 object-contain"
2093 />
2094 ) : (
2095 <IconSelector
2096 iconName={option.typeIcon as any}
2097 provider={
2098 option.typeIconProvider ?? "yatra"
2099 }
2100 className="w-4 h-4"
2101 />
2102 )}
2103 {option.typeName}
2104 </Button>
2105 );
2106 })
2107 ) : (
2108 <div className="text-sm text-gray-500 dark:text-gray-400">
2109 {__("No item types available", "yatra")}
2110 </div>
2111 )}
2112 </div>
2113 </div>
2114 </div>
2115
2116 {/* Itinerary Items */}
2117 <div className="px-4 pb-3 pt-2 space-y-3 overflow-visible rounded-b-lg">
2118 {sortedEntries.map((entry) => (
2119 <div
2120 key={entry.id}
2121 className="flex items-center gap-4 p-3 bg-white dark:bg-gray-950 rounded-lg border border-gray-100/60 dark:border-gray-800/80 shadow-sm overflow-visible"
2122 >
2123 {/* Checkbox */}
2124 <ConditionalRender capability="yatra_delete_trips">
2125 <input
2126 type="checkbox"
2127 checked={selectedEntries.has(entry.id)}
2128 onChange={(e) => {
2129 e.stopPropagation();
2130 handleToggleSelect(entry.id);
2131 }}
2132 onClick={(e) => e.stopPropagation()}
2133 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
2134 title={__("Select this entry", "yatra")}
2135 />
2136 </ConditionalRender>
2137 {/* Time */}
2138 <div className="flex flex-col items-center min-w-[80px]">
2139 <div className="text-sm font-medium text-gray-900 dark:text-white">
2140 {entry.end_time
2141 ? `${formatTime(entry.start_time)} - ${formatTime(entry.end_time)}`
2142 : formatTime(entry.start_time)}
2143 </div>
2144 <div className="mt-1 flex items-center gap-1">
2145 {entry.end_time ? (
2146 <>
2147 <div className="relative flex items-center justify-center">
2148 <span className="absolute inline-flex h-5 w-5 rounded-full bg-blue-400/40 animate-ping" />
2149 <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-600" />
2150 </div>
2151 <div className="w-16 border-t border-dashed border-gray-300 dark:border-gray-600" />
2152 <div className="relative flex items-center justify-center">
2153 <span className="absolute inline-flex h-5 w-5 rounded-full bg-red-400/40 animate-ping" />
2154 <span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
2155 </div>
2156 </>
2157 ) : (
2158 <div className="relative flex items-center justify-center">
2159 <span className="absolute inline-flex h-5 w-5 rounded-full bg-blue-400/40 animate-ping" />
2160 <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-600" />
2161 </div>
2162 )}
2163 </div>
2164 </div>
2165
2166 {/* Content */}
2167 <div className="flex-1">
2168 <div className="flex items-center justify-between gap-3">
2169 <div className="flex-1">
2170 <div className="flex items-center gap-2 mb-1">
2171 <a
2172 href={
2173 entry.item_type_id
2174 ? `${baseAdminUrl}?page=yatra&subpage=itinerary&tab=item-types&action=edit&id=${entry.item_type_id}`
2175 : "#"
2176 }
2177 className="focus:outline-none text-blue-600 dark:text-blue-400"
2178 onClick={(e) => {
2179 if (!entry.item_type_id)
2180 e.preventDefault();
2181 }}
2182 >
2183 <Badge
2184 className="text-xs font-medium px-2 py-0.5 hover:underline underline-offset-2"
2185 style={{
2186 backgroundColor:
2187 entry.item_color === "blue"
2188 ? "rgb(219, 234, 254)"
2189 : entry.item_color === "green"
2190 ? "rgb(220, 252, 231)"
2191 : entry.item_color ===
2192 "orange"
2193 ? "rgb(255, 237, 213)"
2194 : entry.item_color ===
2195 "purple"
2196 ? "rgb(243, 232, 255)"
2197 : entry.item_color ===
2198 "red"
2199 ? "rgb(254, 226, 226)"
2200 : entry.item_color ===
2201 "yellow"
2202 ? "rgb(254, 249, 195)"
2203 : "rgb(243, 244, 246)",
2204 color:
2205 entry.item_color === "blue"
2206 ? "rgb(29, 78, 216)"
2207 : entry.item_color === "green"
2208 ? "rgb(21, 128, 61)"
2209 : entry.item_color ===
2210 "orange"
2211 ? "rgb(194, 65, 12)"
2212 : entry.item_color ===
2213 "purple"
2214 ? "rgb(126, 34, 206)"
2215 : entry.item_color ===
2216 "red"
2217 ? "rgb(185, 28, 28)"
2218 : entry.item_color ===
2219 "yellow"
2220 ? "rgb(161, 98, 7)"
2221 : "rgb(55, 65, 81)",
2222 borderColor:
2223 entry.item_color === "blue"
2224 ? "rgb(147, 197, 253)"
2225 : entry.item_color === "green"
2226 ? "rgb(134, 239, 172)"
2227 : entry.item_color ===
2228 "orange"
2229 ? "rgb(254, 215, 170)"
2230 : entry.item_color ===
2231 "purple"
2232 ? "rgb(221, 214, 254)"
2233 : entry.item_color ===
2234 "red"
2235 ? "rgb(252, 165, 165)"
2236 : entry.item_color ===
2237 "yellow"
2238 ? "rgb(253, 230, 138)"
2239 : "rgb(209, 213, 219)",
2240 borderWidth: "1px",
2241 borderStyle: "solid",
2242 }}
2243 >
2244 <div className="flex items-center gap-1.5">
2245 {entry.item_icon_is_image &&
2246 entry.item_icon_image_src ? (
2247 <img
2248 src={entry.item_icon_image_src}
2249 alt=""
2250 className="w-3 h-3 object-contain"
2251 />
2252 ) : (
2253 <IconSelector
2254 iconName={entry.item_icon as any}
2255 provider={
2256 entry.item_icon_provider ??
2257 "yatra"
2258 }
2259 className="w-3 h-3"
2260 />
2261 )}
2262 <span>{entry.item_type}</span>
2263 </div>
2264 </Badge>
2265 </a>
2266 </div>
2267 <div className="flex items-center gap-2 mb-1">
2268 <a
2269 href={
2270 entry.item_id
2271 ? `${baseAdminUrl}?page=yatra&subpage=items&action=edit&id=${entry.item_id}`
2272 : "#"
2273 }
2274 className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline underline-offset-2 focus:outline-none text-left"
2275 onClick={(e) => {
2276 if (!entry.item_id)
2277 e.preventDefault();
2278 }}
2279 >
2280 {entry.title}
2281 </a>
2282 {getEntryStatusBadge(entry.status)}
2283 </div>
2284 <div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
2285 {entry.description}
2286 </div>
2287 <div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-400">
2288 {/* Top row: location / duration / price */}
2289 <div className="flex items-center flex-wrap gap-4">
2290 {entry.location && (
2291 <div className="flex items-center gap-1">
2292 <MapPin className="w-3.5 h-3.5" />
2293 <span>{entry.location}</span>
2294 </div>
2295 )}
2296 {entry.duration && (
2297 <div className="flex items-center gap-1">
2298 <Clock className="w-3.5 h-3.5" />
2299 <span>{entry.duration}</span>
2300 </div>
2301 )}
2302 {entry.cost && (
2303 <div className="flex items-center gap-1">
2304 <span>{currencySymbol}</span>
2305 <span>
2306 {entry.cost}
2307 {entry.cost_per_person
2308 ? ` / ${__("person", "yatra")}`
2309 : ""}
2310 </span>
2311 </div>
2312 )}
2313 </div>
2314
2315 {/* Bottom row: Includes / Excludes inline text */}
2316 <div>
2317 {(() => {
2318 // Normalize included/excluded items in case they come as JSON strings
2319 let included: string[] = [];
2320 let excluded: string[] = [];
2321
2322 if (
2323 Array.isArray(entry.included_items)
2324 ) {
2325 included = entry.included_items;
2326 } else if (
2327 typeof entry.included_items ===
2328 "string" &&
2329 entry.included_items.trim()
2330 ) {
2331 try {
2332 const parsed = JSON.parse(
2333 entry.included_items as string,
2334 );
2335 if (Array.isArray(parsed))
2336 included = parsed;
2337 } catch {
2338 // Fallback: split by comma
2339 included = (
2340 entry.included_items as string
2341 )
2342 .split(",")
2343 .map((i: string) => i.trim())
2344 .filter(Boolean);
2345 }
2346 }
2347
2348 if (
2349 Array.isArray(entry.excluded_items)
2350 ) {
2351 excluded = entry.excluded_items;
2352 } else if (
2353 typeof entry.excluded_items ===
2354 "string" &&
2355 entry.excluded_items.trim()
2356 ) {
2357 try {
2358 const parsed = JSON.parse(
2359 entry.excluded_items as string,
2360 );
2361 if (Array.isArray(parsed))
2362 excluded = parsed;
2363 } catch {
2364 excluded = (
2365 entry.excluded_items as string
2366 )
2367 .split(",")
2368 .map((i: string) => i.trim())
2369 .filter(Boolean);
2370 }
2371 }
2372
2373 if (
2374 included.length === 0 &&
2375 excluded.length === 0
2376 ) {
2377 return null;
2378 }
2379
2380 return (
2381 <div className="text-gray-500 dark:text-gray-400">
2382 {included.length > 0 && (
2383 <span>
2384 <span className="font-medium text-green-600 dark:text-green-400">
2385 {__("Includes", "yatra")}:
2386 </span>{" "}
2387 <span>
2388 {included.join(", ")}
2389 </span>
2390 </span>
2391 )}
2392 {included.length > 0 &&
2393 excluded.length > 0 && (
2394 <span>{" · "}</span>
2395 )}
2396 {excluded.length > 0 && (
2397 <span>
2398 <span className="font-medium text-red-600 dark:text-red-400">
2399 {__("Excludes", "yatra")}:
2400 </span>{" "}
2401 <span>
2402 {excluded.join(", ")}
2403 </span>
2404 </span>
2405 )}
2406 </div>
2407 );
2408 })()}
2409 </div>
2410 </div>
2411 </div>
2412
2413 {/* Actions */}
2414 <div className="flex items-center gap-1 relative z-10">
2415 <SearchableSelect
2416 value={entry.item_id?.toString() || ""}
2417 onChange={(value) => {
2418 if (!value) return;
2419
2420 const selectedItem = itemsData?.find(
2421 (item) =>
2422 item.id.toString() === value,
2423 );
2424
2425 if (
2426 selectedItem &&
2427 entry.item_type_id
2428 ) {
2429 updateItemMutation.mutate({
2430 entryId: entry.id,
2431 itemId: selectedItem.id,
2432 itemTypeId: entry.item_type_id,
2433 });
2434 }
2435 }}
2436 options={(() => {
2437 const itemTypeId = entry.item_type_id;
2438
2439 if (!itemTypeId) return [];
2440
2441 const itemTypeIdNum =
2442 typeof itemTypeId === "string"
2443 ? parseInt(itemTypeId, 10)
2444 : Number(itemTypeId);
2445
2446 if (
2447 !itemTypeIdNum ||
2448 isNaN(itemTypeIdNum)
2449 )
2450 return [];
2451
2452 const filteredItems = itemsData
2453 .filter((item) => {
2454 const typeId = item.type_id;
2455 if (!typeId) return false;
2456 const typeIdNum =
2457 typeof typeId === "string"
2458 ? parseInt(typeId, 10)
2459 : Number(typeId);
2460 const matches =
2461 typeIdNum === itemTypeIdNum;
2462
2463 return matches;
2464 })
2465 .map((item) => ({
2466 value: item.id.toString(),
2467 label: item.name,
2468 }));
2469
2470 return filteredItems;
2471 })()}
2472 placeholder={__(
2473 "Select item...",
2474 "yatra",
2475 )}
2476 searchPlaceholder={__(
2477 "Search items...",
2478 "yatra",
2479 )}
2480 className="min-w-[140px]"
2481 disabled={updateItemMutation.isPending}
2482 />
2483 <ConditionalRender capability="yatra_edit_trips">
2484 <Button
2485 variant="ghost"
2486 size="icon"
2487 onClick={() => handleEdit(entry)}
2488 className="h-8 w-8"
2489 title={__("Edit Activity", "yatra")}
2490 >
2491 <Pencil className="w-4 h-4" />
2492 </Button>
2493 </ConditionalRender>
2494 <ConditionalRender capability="yatra_delete_trips">
2495 <Button
2496 variant="ghost"
2497 size="icon"
2498 onClick={() => handleDelete(entry)}
2499 className="h-8 w-8 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
2500 title={__("Delete", "yatra")}
2501 >
2502 <Trash2 className="w-4 h-4" />
2503 </Button>
2504 </ConditionalRender>
2505 </div>
2506 </div>
2507 </div>
2508 </div>
2509 ))}
2510
2511 {/* Add Activity Button */}
2512 <ConditionalRender capability="yatra_edit_trips">
2513 <Button
2514 variant="outline"
2515 className="w-full flex items-center justify-center gap-2 border-2 text-blue-700 bg-blue-50 hover:bg-blue-100 dark:text-blue-200 dark:bg-blue-950/40 dark:hover:bg-blue-900/70 shadow-sm transition-colors"
2516 style={{ borderColor: "#3b82f6" }}
2517 onClick={() => {
2518 const tripId = dayGroup.trip_id;
2519 const day = dayGroup.day;
2520 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&trip_id=${tripId}&day=${day}&mode=activity`;
2521 }}
2522 >
2523 <Plus className="w-4 h-4" />
2524 {__("Add Activity", "yatra")}
2525 </Button>
2526 </ConditionalRender>
2527 </div>
2528 </div>
2529 )}
2530 </div>
2531 );
2532 })}
2533 </div>
2534 )}
2535 </>
2536 </ConditionalRender>
2537
2538 {/* Delete Confirmation Dialog */}
2539 <ConfirmationDialog
2540 isOpen={deleteConfirm.isOpen}
2541 onClose={handleDeleteCancel}
2542 onConfirm={handleDeleteConfirm}
2543 title={
2544 (deleteConfirm.entry as any)?._isDayDeletion
2545 ? __("Delete Day", "yatra")
2546 : __("Delete Itinerary Entry", "yatra")
2547 }
2548 message={
2549 (deleteConfirm.entry as any)?._isDayDeletion
2550 ? __(
2551 "Are you sure you want to delete this day and all its activities? This action cannot be undone.",
2552 "yatra",
2553 )
2554 : __(
2555 "Are you sure you want to delete this itinerary entry? This action cannot be undone.",
2556 "yatra",
2557 )
2558 }
2559 confirmText={__("Delete", "yatra")}
2560 variant="danger"
2561 isLoading={deleteMutation.isPending}
2562 />
2563 </div>
2564 );
2565 };
2566
2567 export default Itinerary;
2568