PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / Itinerary.tsx

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

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