PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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.5, at resources/js/pages/Itinerary.tsx

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