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

2,725 lines 112.5 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 { __, sprintf } 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 (payload: { ids: number[]; day_ids: number[] }) => {
684 // Days and activities are sent in separate buckets so the server never
685 // has to guess which table an id belongs to (day ids and activity ids
686 // live in different tables and can collide numerically).
687 const response = await apiClient.delete("/itinerary", {
688 data: { ids: payload.ids, day_ids: payload.day_ids },
689 });
690 return response?.data || response;
691 },
692 onSuccess: (data) => {
693 const deleted = data?.deleted || 0;
694 const failed = data?.failed || 0;
695
696 if (deleted > 0) {
697 showToast(
698 failed > 0
699 ? sprintf(
700 __(
701 "%1$d item(s) deleted successfully. %2$d item(s) failed to delete.",
702 "yatra",
703 ),
704 deleted,
705 failed,
706 )
707 : sprintf(__("%d item(s) deleted successfully.", "yatra"), deleted),
708 "success",
709 );
710 } else {
711 showToast(
712 __("Failed to delete items. Please try again.", "yatra"),
713 "error",
714 );
715 }
716
717 setSelectedEntries(new Set());
718 setSelectedEmptyDays(new Set());
719 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
720 queryClient.invalidateQueries({ queryKey: ["trips"] });
721 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
722 refetch();
723 },
724 onError: (error: any) => {
725 showToast(
726 error?.message ||
727 __("Failed to delete items. Please try again.", "yatra"),
728 "error",
729 );
730 },
731 });
732
733 const deleteMutation = useMutation({
734 mutationFn: async (entry: ItineraryEntry) => {
735 try {
736 // Determine mode based on whether this is a day entry or activity entry
737 const isDayEntry = !entry.item_type_id || entry.item_type_id === 0;
738 const mode = isDayEntry ? "day" : "activity";
739 const response = await apiClient.delete(
740 `/itinerary/${entry.id}?mode=${mode}`,
741 );
742 return { response: response?.data || response, entry };
743 } catch (error: any) {
744 throw error;
745 }
746 },
747 onSuccess: (data) => {
748 const entry = data.entry;
749 const isDayDeletion = (entry as any)?._isDayDeletion;
750 showToast(
751 isDayDeletion
752 ? __("Day and all activities deleted successfully", "yatra")
753 : __("Itinerary entry deleted successfully", "yatra"),
754 "success",
755 );
756 // Close confirmation dialog
757 setDeleteConfirm({ isOpen: false, entry: null });
758 // Refresh itinerary data
759 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
760 queryClient.invalidateQueries({ queryKey: ["trips"] });
761 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
762 // Force refetch
763 refetch();
764 },
765 onError: (error: any) => {
766 showToast(
767 error?.message || __("Failed to delete itinerary entry", "yatra"),
768 "error",
769 );
770 },
771 });
772
773 // Mutation to update itinerary entry item
774 const updateItemMutation = useMutation({
775 mutationFn: async ({
776 entryId,
777 itemId,
778 itemTypeId,
779 }: {
780 entryId: number;
781 itemId: number;
782 itemTypeId: number;
783 }) => {
784 // Item assignment is always for activities, so use mode=activity
785 const currentEntry = await apiClient.get(
786 `/itinerary/${entryId}?mode=activity`,
787 );
788 const entryData =
789 currentEntry?.data?.data || currentEntry?.data || currentEntry;
790
791 // Update with new item_id and item_type_id
792 return await apiClient.put(`/itinerary/${entryId}?mode=activity`, {
793 ...entryData,
794 item_id: itemId,
795 item_type_id: itemTypeId,
796 });
797 },
798 onSuccess: () => {
799 showToast(__("Item updated successfully", "yatra"), "success");
800 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
801 queryClient.invalidateQueries({ queryKey: ["trips"] });
802 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
803 refetch();
804 },
805 onError: (error: any) => {
806 showToast(
807 error?.message || __("Failed to update item", "yatra"),
808 "error",
809 );
810 },
811 });
812
813 const handleAddDay = () => {
814 // Navigate to add day form - this creates a new day entry
815 // If a trip is selected, pass trip_id; otherwise let the form show the trip selector
816 if (tripFilter) {
817 // Get the max day number for this trip to suggest the next day
818 const tripEntries = dayGroups.filter(
819 (dg: DayGroup) => dg.trip_id === parseInt(tripFilter),
820 );
821 const maxDay =
822 tripEntries.length > 0
823 ? Math.max(...tripEntries.map((dg: DayGroup) => dg.day))
824 : 0;
825 const nextDay = maxDay + 1;
826
827 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`;
828 } else {
829 // No trip selected - navigate without trip_id so form shows trip selector
830 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=create&mode=day`;
831 }
832 };
833
834 // Get quick add options from item types and items
835 const quickAddOptions = useMemo(() => {
836 const itemTypes = Array.isArray(itemTypesData) ? itemTypesData : [];
837
838 return itemTypes
839 .map((itemType: any) => {
840 const parsed = parseItemTypeIcon(itemType.icon, "footprints");
841 const color = itemType.color || "blue";
842
843 return {
844 typeId: itemType.id,
845 typeName: itemType.name || "",
846 typeIcon: parsed.name,
847 typeIconProvider: parsed.provider,
848 typeIconImageSrc: parsed.isImage ? parsed.imageSrc : undefined,
849 typeIconData: itemType.icon,
850 typeColor: color,
851 };
852 })
853 .filter((option: any) => option.typeName); // Filter out invalid options
854 }, [itemTypesData]);
855
856 const handleQuickAdd = (typeId: number | string) => {
857 if (!selectedDay) {
858 // Select first day if none selected
859 const firstDay = data?.[0];
860 if (firstDay) {
861 setSelectedDay({ tripId: firstDay.trip_id, day: firstDay.day });
862 const key = `${firstDay.trip_id}-${firstDay.day}`;
863 setExpandedDays(new Set([key]));
864 }
865 }
866
867 const tripId =
868 selectedDay?.tripId || (tripFilter ? parseInt(tripFilter) : 1);
869 const day = selectedDay?.day || 1;
870
871 // Use item type ID for navigation
872 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}`;
873 };
874
875 const handleEdit = (entry: ItineraryEntry) => {
876 // Determine if this is a day entry or activity entry
877 // Day entries have item_type_id: 0 or null, activity entries have item_type_id > 0
878 const isDayEntry = !entry.item_type_id || entry.item_type_id === 0;
879 const mode = isDayEntry ? "day" : "activity";
880 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&action=edit&id=${entry.id}&mode=${mode}`;
881 };
882
883 const handleEditDay = async (tripId: number, day: number) => {
884 const redirectToCreate = () => {
885 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`;
886 };
887
888 const redirectToEdit = (entryId: number) => {
889 const params = new URLSearchParams({
890 action: "edit",
891 id: entryId.toString(),
892 mode: "day",
893 trip_id: tripId.toString(),
894 day: day.toString(),
895 });
896 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&${params.toString()}`;
897 };
898
899 const dayGroup = dayGroups.find(
900 (dg: DayGroup) => dg.trip_id === tripId && dg.day === day,
901 );
902
903 if (!dayGroup) {
904 redirectToCreate();
905 setDayMenuOpen(null);
906 return;
907 }
908
909 if (dayGroup.day_id) {
910 try {
911 const response = await apiClient.get(
912 `/itinerary/day-entry-by-day-id/${dayGroup.day_id}`,
913 );
914
915 const dayEntryId =
916 response?.data?.day_entry_id ||
917 response?.day_entry_id ||
918 response?.data?.id ||
919 response?.id ||
920 null;
921
922 if (dayEntryId) {
923 redirectToEdit(dayEntryId);
924 setDayMenuOpen(null);
925 return;
926 }
927 } catch (error) {
928 console.error(
929 "[YATRA DEBUG] Failed to fetch day entry for editing:",
930 error,
931 );
932 }
933 }
934
935 if (dayGroup.entries.length > 0) {
936 // Look for day entry (item_type_id and item_id are 0, null, or undefined)
937 const dayEntry = dayGroup.entries.find((entry: any) => {
938 const isDayEntry =
939 (entry.item_type_id === null ||
940 entry.item_type_id === undefined ||
941 entry.item_type_id === 0) &&
942 (entry.item_id === null ||
943 entry.item_id === undefined ||
944 entry.item_id === 0);
945
946 return isDayEntry;
947 });
948
949 if (dayEntry && dayEntry.id) {
950 redirectToEdit(dayEntry.id);
951 setDayMenuOpen(null);
952 return;
953 }
954
955 const firstEntry = dayGroup.entries[0];
956
957 if (firstEntry && firstEntry.id) {
958 redirectToEdit(firstEntry.id);
959 setDayMenuOpen(null);
960 return;
961 }
962 }
963
964 redirectToCreate();
965 setDayMenuOpen(null);
966 };
967
968 const handleDeleteDay = async (tripId: number, day: number) => {
969 // Find the day group
970 const dayGroup = dayGroups.find(
971 (dg: DayGroup) => dg.trip_id === tripId && dg.day === day,
972 );
973
974 if (!dayGroup) {
975 showToast(__("Day not found", "yatra"), "error");
976 setDayMenuOpen(null);
977 return;
978 }
979
980 // If we have day_id, fetch the day entry ID from backend
981 if (dayGroup.day_id) {
982 try {
983 const response = await apiClient.get(
984 `/itinerary/day-entry-by-day-id/${dayGroup.day_id}`,
985 );
986 const dayEntryId =
987 response?.data?.day_entry_id ||
988 response?.day_entry_id ||
989 response?.data?.id ||
990 response?.id ||
991 null;
992
993 if (dayEntryId) {
994 const dayEntryForDeletion = {
995 id: dayEntryId,
996 trip_id: tripId,
997 day: day,
998 _isDayDeletion: true,
999 _dayNumber: day,
1000 } as ItineraryEntry & {
1001 _isDayDeletion?: boolean;
1002 _dayNumber?: number;
1003 };
1004
1005 setDeleteConfirm({ isOpen: true, entry: dayEntryForDeletion });
1006 } else {
1007 showToast(__("Day entry not found", "yatra"), "error");
1008 }
1009 } catch (error: any) {
1010 showToast(__("Failed to fetch day entry", "yatra"), "error");
1011 }
1012 } else {
1013 showToast(__("Day ID not available", "yatra"), "error");
1014 }
1015 setDayMenuOpen(null);
1016 };
1017
1018 const handleDelete = (entry: ItineraryEntry) => {
1019 setDeleteConfirm({ isOpen: true, entry });
1020 };
1021
1022 const handleDeleteConfirm = () => {
1023 if (deleteConfirm.entry) {
1024 deleteMutation.mutate(deleteConfirm.entry);
1025 }
1026 };
1027
1028 const handleDeleteCancel = () => {
1029 setDeleteConfirm({ isOpen: false, entry: null });
1030 };
1031
1032 // Bulk selection handlers
1033 const handleToggleSelect = (entryId: number) => {
1034 setSelectedEntries((prev) => {
1035 const newSet = new Set(prev);
1036 if (newSet.has(entryId)) {
1037 newSet.delete(entryId);
1038 } else {
1039 newSet.add(entryId);
1040 }
1041 return newSet;
1042 });
1043 };
1044
1045 const handleSelectAll = (dayGroup: DayGroup) => {
1046 const allEntryIds = dayGroup.entries.map((e) => e.id);
1047 setSelectedEntries((prev) => {
1048 const newSet = new Set(prev);
1049 const allSelected = allEntryIds.every((id) => newSet.has(id));
1050 if (allSelected) {
1051 allEntryIds.forEach((id) => newSet.delete(id));
1052 } else {
1053 allEntryIds.forEach((id) => newSet.add(id));
1054 }
1055 return newSet;
1056 });
1057 };
1058
1059 const handleSelectAllDays = () => {
1060 // Only operate on entries that are currently visible under the active status tab
1061 const allEntryIds = filteredDayGroups.flatMap((dg) =>
1062 dg.entries.map((e) => e.id),
1063 );
1064 const emptyDayKeys = filteredDayGroups
1065 .filter((dg) => dg.entries.length === 0)
1066 .map((dg) => `${dg.trip_id}-${dg.day}`);
1067
1068 setSelectedEntries((prev) => {
1069 const newSet = new Set(prev);
1070 const allSelected =
1071 allEntryIds.length > 0 && allEntryIds.every((id) => newSet.has(id));
1072 if (allSelected) {
1073 allEntryIds.forEach((id) => newSet.delete(id));
1074 } else {
1075 allEntryIds.forEach((id) => newSet.add(id));
1076 }
1077 return newSet;
1078 });
1079
1080 // Keep empty-day selection in sync with global Select All
1081 setSelectedEmptyDays((prev) => {
1082 const allSelected =
1083 emptyDayKeys.length > 0 && emptyDayKeys.every((key) => prev.has(key));
1084 const next = new Set(prev);
1085 if (allSelected) {
1086 emptyDayKeys.forEach((key) => next.delete(key));
1087 } else {
1088 emptyDayKeys.forEach((key) => next.add(key));
1089 }
1090 return next;
1091 });
1092 };
1093
1094 const handleBulkApply = async () => {
1095 const ids = Array.from(selectedEntries);
1096 const emptyDayKeys = Array.from(selectedEmptyDays);
1097
1098 // Also treat days that have at least one selected entry as selected days
1099 const selectedDayKeysFromEntries = new Set<string>();
1100 filteredDayGroups.forEach((dg) => {
1101 const hasSelectedEntry = dg.entries.some((e) =>
1102 selectedEntries.has(e.id),
1103 );
1104 if (hasSelectedEntry) {
1105 selectedDayKeysFromEntries.add(`${dg.trip_id}-${dg.day}`);
1106 }
1107 });
1108
1109 const combinedDayKeys = new Set<string>([
1110 ...emptyDayKeys,
1111 ...selectedDayKeysFromEntries,
1112 ]);
1113
1114 if (!bulkAction || (ids.length === 0 && combinedDayKeys.size === 0)) {
1115 showToast(
1116 __("Please select entries or days and a bulk action first.", "yatra"),
1117 "error",
1118 );
1119 return;
1120 }
1121
1122 // Resolve day-table ids for a set of "trip-day" keys (one lookup per day).
1123 const resolveDayEntryIds = async (
1124 keys: Iterable<string>,
1125 ): Promise<number[]> => {
1126 const dayEntryIds: number[] = [];
1127
1128 for (const key of keys) {
1129 const [tripIdStr, dayStr] = key.split("-");
1130 const tripIdNum = parseInt(tripIdStr, 10);
1131 const dayNum = parseInt(dayStr, 10);
1132
1133 if (Number.isNaN(tripIdNum) || Number.isNaN(dayNum)) {
1134 continue;
1135 }
1136
1137 const dg = dayGroups.find(
1138 (g) => g.trip_id === tripIdNum && g.day === dayNum,
1139 );
1140
1141 if (!dg || !dg.day_id) {
1142 continue;
1143 }
1144
1145 try {
1146 const response = await apiClient.get(
1147 `/itinerary/day-entry-by-day-id/${dg.day_id}`,
1148 );
1149 const dayEntryId =
1150 response?.data?.day_entry_id ||
1151 response?.day_entry_id ||
1152 response?.data?.id ||
1153 response?.id ||
1154 null;
1155
1156 if (dayEntryId) {
1157 dayEntryIds.push(Number(dayEntryId));
1158 }
1159 } catch (error) {
1160 // Ignore failures for individual days; bulk handler will still proceed for others
1161 console.error("Failed to resolve day entry ID for day", key, error);
1162 }
1163 }
1164
1165 return dayEntryIds;
1166 };
1167
1168 // Delete permanently (uses dedicated bulk delete endpoint).
1169 if (bulkAction === "delete") {
1170 // A day is removed as a whole unit (day row + all its activities) ONLY
1171 // when it's an explicitly-selected empty day, or when EVERY one of its
1172 // visible activities is selected. A day with only SOME activities ticked
1173 // keeps the day and deletes just those activities. Days and standalone
1174 // activities go in separate buckets so the server never has to guess
1175 // which table an id belongs to (day ids and activity ids can collide).
1176 const fullDayKeys = new Set<string>(emptyDayKeys);
1177 const activityIdsToDelete: number[] = [];
1178
1179 filteredDayGroups.forEach((dg) => {
1180 const selectedInDay = dg.entries.filter((e) =>
1181 selectedEntries.has(e.id),
1182 );
1183 if (selectedInDay.length === 0) {
1184 return;
1185 }
1186 if (
1187 dg.entries.length > 0 &&
1188 selectedInDay.length === dg.entries.length
1189 ) {
1190 // All activities of this day are selected → delete the whole day.
1191 fullDayKeys.add(`${dg.trip_id}-${dg.day}`);
1192 } else {
1193 // Partial selection → delete only the ticked activities.
1194 for (const e of selectedInDay) {
1195 activityIdsToDelete.push(e.id);
1196 }
1197 }
1198 });
1199
1200 const dayIdsToDelete = await resolveDayEntryIds(fullDayKeys);
1201
1202 if (activityIdsToDelete.length === 0 && dayIdsToDelete.length === 0) {
1203 showToast(
1204 __("No valid entries or days found to apply this action.", "yatra"),
1205 "error",
1206 );
1207 return;
1208 }
1209
1210 bulkDeleteMutation.mutate({
1211 ids: activityIdsToDelete,
1212 day_ids: dayIdsToDelete,
1213 });
1214 return;
1215 }
1216
1217 // Status changes escalate any day with a selected entry (unchanged behavior).
1218 const dayEntryIds = await resolveDayEntryIds(combinedDayKeys);
1219 const allIdsForAction = [...ids, ...dayEntryIds];
1220
1221 if (allIdsForAction.length === 0) {
1222 showToast(
1223 __("No valid entries or days found to apply this action.", "yatra"),
1224 "error",
1225 );
1226 return;
1227 }
1228
1229 // Status changes are applied one-by-one using the REST API
1230 const targetStatus = (() => {
1231 if (bulkAction === "publish") return "published";
1232 if (bulkAction === "draft") return "draft";
1233 if (bulkAction === "trash") return "trash";
1234 if (bulkAction === "restore") return "draft"; // restore from Trash -> Draft
1235 return "";
1236 })();
1237
1238 if (!targetStatus) {
1239 return;
1240 }
1241
1242 try {
1243 await Promise.all(
1244 allIdsForAction.map(async (id) => {
1245 // First fetch with activity mode (most common case)
1246 // If it fails, the backend will handle it
1247 let current;
1248 let mode = "activity";
1249
1250 try {
1251 current = await apiClient.get(`/itinerary/${id}?mode=activity`);
1252 } catch (error) {
1253 // If activity mode fails, try day mode
1254 mode = "day";
1255 current = await apiClient.get(`/itinerary/${id}?mode=day`);
1256 }
1257
1258 const entryData = current?.data?.data || current?.data || current;
1259
1260 // Apply status change while preserving other fields
1261 await apiClient.put(`/itinerary/${id}?mode=${mode}`, {
1262 ...entryData,
1263 status: targetStatus,
1264 });
1265 }),
1266 );
1267
1268 showToast(__("Bulk action applied successfully.", "yatra"), "success");
1269 setSelectedEntries(new Set());
1270 setSelectedEmptyDays(new Set());
1271 setBulkAction("");
1272 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
1273 queryClient.invalidateQueries({ queryKey: ["trips"] });
1274 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
1275 refetch();
1276 } catch (error: any) {
1277 showToast(
1278 error?.message ||
1279 __("Failed to apply bulk action. Please try again.", "yatra"),
1280 "error",
1281 );
1282 }
1283 };
1284
1285 const toggleDay = (tripId: number, day: number) => {
1286 const key = `${tripId}-${day}`;
1287 setExpandedDays((prev) => {
1288 const next = new Set(prev);
1289 if (next.has(key)) {
1290 next.delete(key);
1291 } else {
1292 next.add(key);
1293 }
1294 return next;
1295 });
1296 setSelectedDay({ tripId, day });
1297 };
1298
1299 const getItemCounts = (entries: ItineraryEntry[]) => {
1300 const meals = entries.filter((e) => e.item_type === "Meal").length;
1301 const activities = entries.filter((e) => e.item_type === "Activity").length;
1302 const accommodations = entries.filter(
1303 (e) => e.item_type === "Accommodation",
1304 ).length;
1305 return { meals, activities, accommodations, total: entries.length };
1306 };
1307
1308 const getDayStatusBadge = (dayGroup: DayGroup) => {
1309 const normalize = (value?: string | null) => (value || "").toLowerCase();
1310
1311 // Prefer explicit day_status from the day entry
1312 const explicitStatus = normalize(dayGroup.day_status);
1313
1314 let label = "";
1315 let className = "";
1316
1317 if (explicitStatus === "publish") {
1318 label = __("Published", "yatra");
1319 className =
1320 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400";
1321 } else if (explicitStatus === "draft") {
1322 label = __("Draft", "yatra");
1323 className =
1324 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1325 } else if (explicitStatus === "trash") {
1326 label = __("Trash", "yatra");
1327 className =
1328 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
1329 } else {
1330 // Fallback: infer from activity entries when day_status is not set
1331 const statuses = dayGroup.entries.map((e) => normalize(e.status));
1332
1333 const hasPublished = statuses.some((s) => s === "publish");
1334 const hasDraft = statuses.some((s) => s === "draft");
1335 const hasTrash = statuses.some((s) => s === "trash");
1336
1337 if (hasPublished) {
1338 label = __("Published", "yatra");
1339 className =
1340 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400";
1341 } else if (hasDraft) {
1342 label = __("Draft", "yatra");
1343 className =
1344 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1345 } else if (hasTrash) {
1346 label = __("Trash", "yatra");
1347 className =
1348 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
1349 } else {
1350 label = __("No Status", "yatra");
1351 className =
1352 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300";
1353 }
1354 }
1355
1356 return (
1357 <Badge className={`ml-2 text-[11px] px-2 py-0.5 ${className}`}>
1358 {label}
1359 </Badge>
1360 );
1361 };
1362
1363 const formatTime = (time: string) => {
1364 // Convert 24h to 12h format
1365 const [hours, minutes] = time.split(":");
1366 const hour = parseInt(hours);
1367 const ampm = hour >= 12 ? "PM" : "AM";
1368 const hour12 = hour % 12 || 12;
1369 return `${hour12}:${minutes} ${ampm}`;
1370 };
1371
1372 const getEntryStatusBadge = (status: string) => {
1373 const normalized = (status || "").toLowerCase();
1374
1375 if (normalized === "publish" || normalized === "published") {
1376 return (
1377 <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">
1378 {__("Published", "yatra")}
1379 </Badge>
1380 );
1381 }
1382
1383 if (normalized === "draft") {
1384 return (
1385 <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">
1386 {__("Draft", "yatra")}
1387 </Badge>
1388 );
1389 }
1390
1391 if (normalized === "trash") {
1392 return (
1393 <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">
1394 {__("Trash", "yatra")}
1395 </Badge>
1396 );
1397 }
1398
1399 return null;
1400 };
1401
1402 const dayGroups: DayGroup[] = (data || []) as DayGroup[];
1403 const errorContext = getErrorContext(error);
1404 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
1405 const derivedErrorDetails =
1406 errorContext.details ||
1407 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
1408 (error ? String(error?.message || error) : undefined);
1409
1410 // More precise error detection - only true if query ran and failed
1411 const queryEnabled = can("yatra_view_trips") && !!tripsData && !!tripFilter;
1412 const isItineraryError = queryEnabled && (!!error || !!apiErrorMessage);
1413
1414 // Debug logging to understand the state
1415
1416 const matchesStatusValue = (
1417 statusValue: string | undefined | null,
1418 filter: string,
1419 ) => {
1420 if (!filter || filter === "all") {
1421 return true;
1422 }
1423
1424 const status = (statusValue || "").toLowerCase();
1425
1426 if (filter === "publish") {
1427 return status === "publish";
1428 }
1429
1430 if (filter === "draft") {
1431 return status === "draft";
1432 }
1433
1434 if (filter === "trash") {
1435 return status === "trash";
1436 }
1437
1438 // Fallback: treat unknown filters as "match all"
1439 return true;
1440 };
1441
1442 const matchesStatusFilter = (entry: ItineraryEntry, filter: string) => {
1443 return matchesStatusValue(entry.status, filter);
1444 };
1445
1446 const matchesStatusFilterForDay = (dayGroup: DayGroup, filter: string) => {
1447 if (!filter || filter === "all") {
1448 return true;
1449 }
1450
1451 // Prefer explicit day_status when available
1452 if (matchesStatusValue(dayGroup.day_status, filter)) {
1453 return true;
1454 }
1455
1456 // Fallback: infer from any of the day's entries
1457 return dayGroup.entries.some((entry) => matchesStatusFilter(entry, filter));
1458 };
1459
1460 const filteredDayGroups: DayGroup[] = useMemo(() => {
1461 const groupsWithFilteredEntries = dayGroups.map((dg) => ({
1462 ...dg,
1463 entries: dg.entries.filter((entry) =>
1464 matchesStatusFilter(entry, statusFilter),
1465 ),
1466 }));
1467
1468 if (!statusFilter || statusFilter === "all") {
1469 return groupsWithFilteredEntries;
1470 }
1471
1472 // For specific status tabs, only show days whose own status OR any entry matches the filter
1473 return groupsWithFilteredEntries.filter((dg) =>
1474 matchesStatusFilterForDay(dg, statusFilter),
1475 );
1476 // eslint-disable-next-line react-hooks/exhaustive-deps
1477 }, [dayGroups, statusFilter]);
1478
1479 const totalEntries = filteredDayGroups.reduce(
1480 (acc, dg) => acc + dg.entries.length,
1481 0,
1482 );
1483 const totalEmptyDays = filteredDayGroups.filter(
1484 (dg) => dg.entries.length === 0,
1485 ).length;
1486 const totalSelectableItems = totalEntries + totalEmptyDays;
1487
1488 // Precompute all entry IDs for header "Select All" checkbox (respecting current status filter)
1489 const allEntryIds = filteredDayGroups.flatMap((dg) =>
1490 dg.entries.map((e) => e.id),
1491 );
1492
1493 const statusCounts = dayGroups.reduce(
1494 (acc, dg) => {
1495 if (matchesStatusFilterForDay(dg, "publish")) {
1496 acc.publish += 1;
1497 }
1498 if (matchesStatusFilterForDay(dg, "draft")) {
1499 acc.draft += 1;
1500 }
1501 if (matchesStatusFilterForDay(dg, "trash")) {
1502 acc.trash += 1;
1503 }
1504
1505 return acc;
1506 },
1507 { publish: 0, draft: 0, trash: 0 },
1508 );
1509
1510 const statusOptions = [
1511 { key: "all", label: __("All", "yatra"), count: dayGroups.length },
1512 {
1513 key: "publish",
1514 label: __("Published", "yatra"),
1515 count: statusCounts.publish,
1516 },
1517 { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft },
1518 { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash },
1519 ];
1520
1521 const bulkActionOptions = useMemo(() => {
1522 // In Trash view: allow restore to Draft and permanent delete only
1523 if (statusFilter === "trash") {
1524 return [
1525 { value: "restore", label: __("Restore to Draft", "yatra") },
1526 { value: "delete", label: __("Delete Permanently", "yatra") },
1527 ];
1528 }
1529
1530 // Other views: normal status changes + move to trash + delete
1531 return [
1532 { value: "publish", label: __("Mark as Published", "yatra") },
1533 { value: "draft", label: __("Mark as Draft", "yatra") },
1534 { value: "trash", label: __("Move to Trash", "yatra") },
1535 { value: "delete", label: __("Delete Permanently", "yatra") },
1536 ];
1537 }, [statusFilter]);
1538
1539 // Close day menu when clicking outside
1540 useEffect(() => {
1541 const handleClickOutside = () => {
1542 setDayMenuOpen(null);
1543 };
1544 if (dayMenuOpen) {
1545 document.addEventListener("click", handleClickOutside);
1546 return () => document.removeEventListener("click", handleClickOutside);
1547 }
1548 }, [dayMenuOpen]);
1549
1550 return (
1551 <div className="space-y-4 pb-6">
1552 <div className="flex items-center justify-between mb-8">
1553 <div>
1554 <p className="text-sm text-gray-500 dark:text-gray-400">
1555 {__(
1556 "Build your complete itinerary with activities, meals, and accommodations",
1557 "yatra",
1558 )}
1559 </p>
1560 </div>
1561 </div>
1562
1563 {/* Trip Selector - Clean Design */}
1564 <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">
1565 <div className="flex items-start justify-between gap-4 mb-4">
1566 <div className="flex-1 min-w-0">
1567 <div className="flex items-center gap-2 mb-1">
1568 <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
1569 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1570 {__("Select Trip", "yatra")}
1571 </h3>
1572 </div>
1573 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1574 {__(
1575 "Choose a trip to manage its itinerary entries and day-by-day schedule",
1576 "yatra",
1577 )}
1578 </p>
1579 </div>
1580 </div>
1581
1582 <div className="space-y-4 overflow-visible">
1583 <div>
1584 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1585 {__("Trip", "yatra")} <span className="text-red-500">*</span>
1586 </label>
1587 <SearchableSelect
1588 value={tripFilter}
1589 onChange={(value) => setTripFilter(value)}
1590 options={
1591 (Array.isArray(tripsData) ? tripsData : []).map(
1592 (trip: any) => ({
1593 value: trip.id.toString(),
1594 label: `${trip.title || trip.name || ""}${trip.trip_type === "single_day" ? __(" (Single Day)", "yatra") : trip.trip_type === "multi_day" ? __(" (Multi-Day)", "yatra") : ""}`,
1595 }),
1596 ) || []
1597 }
1598 placeholder={__("Search or select a trip...", "yatra")}
1599 searchPlaceholder={__("Search by trip name or ID...", "yatra")}
1600 className="w-full"
1601 required
1602 />
1603 </div>
1604
1605 {tripFilter &&
1606 (() => {
1607 const tripsList = Array.isArray(tripsData) ? tripsData : [];
1608 const selectedTrip = tripsList.find(
1609 (t: any) => t.id.toString() === tripFilter,
1610 );
1611 return selectedTrip ? (
1612 <div className="flex items-center gap-4 pt-2 border-t border-blue-200 dark:border-gray-700">
1613 <div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
1614 <MapPin className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1615 <span className="font-medium">
1616 {selectedTrip.starting_location ||
1617 selectedTrip.start_location ||
1618 "N/A"}
1619 </span>
1620 <span className="text-gray-400">→</span>
1621 <span className="font-medium">
1622 {selectedTrip.ending_location ||
1623 selectedTrip.end_location ||
1624 "N/A"}
1625 </span>
1626 </div>
1627 {selectedTrip.trip_type && (
1628 <Badge
1629 className={
1630 selectedTrip.trip_type === "single_day"
1631 ? "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"
1632 : "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400"
1633 }
1634 >
1635 {selectedTrip.trip_type === "single_day"
1636 ? __("Single Day", "yatra")
1637 : __("Multi-Day", "yatra")}
1638 </Badge>
1639 )}
1640 </div>
1641 ) : null;
1642 })()}
1643
1644 {!tripFilter && (
1645 <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">
1646 <div className="flex items-start gap-3">
1647 <div className="flex-shrink-0 mt-0.5">
1648 <div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
1649 <AlertCircle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1650 </div>
1651 </div>
1652 <div className="flex-1 min-w-0">
1653 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1654 {__("Get Started", "yatra")}
1655 </p>
1656 <p className="text-sm text-gray-600 dark:text-gray-400">
1657 {__(
1658 "Select a trip from the dropdown above to view and manage its itinerary entries and day-by-day schedule.",
1659 "yatra",
1660 )}
1661 </p>
1662 </div>
1663 </div>
1664 </div>
1665 )}
1666 </div>
1667 </div>
1668
1669 {/* Add Day / Add Entry button below trip selector */}
1670 {tripFilter &&
1671 (() => {
1672 const selectedTrip = tripsData?.find(
1673 (t: any) => t.id.toString() === tripFilter,
1674 );
1675 const isSingleDay = selectedTrip?.trip_type === "single_day";
1676 const buttonLabel = isSingleDay
1677 ? __("Add Entry", "yatra")
1678 : __("Add Day", "yatra");
1679
1680 return (
1681 <div className="mb-3 flex flex-wrap items-center justify-end gap-3">
1682 <ConditionalRender capability="yatra_edit_trips">
1683 {!isSingleDay && isAiEligible() && isAiModuleEnabled() && (
1684 <Button
1685 type="button"
1686 onClick={() => setAiModalOpen(true)}
1687 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"
1688 title={__(
1689 "Generate a day-by-day itinerary using AI, grounded in this trip's facts.",
1690 "yatra",
1691 )}
1692 >
1693 <Sparkles className="h-4 w-4 shrink-0" />
1694 <span>{__("Build with AI", "yatra")}</span>
1695 </Button>
1696 )}
1697 <Button
1698 onClick={handleAddDay}
1699 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"
1700 >
1701 <Plus className="h-4 w-4 shrink-0" />
1702 <span>{buttonLabel}</span>
1703 </Button>
1704 </ConditionalRender>
1705 </div>
1706 );
1707 })()}
1708
1709 <ConditionalRender capability="yatra_view_trips">
1710 <>
1711 <BulkActionToolbar
1712 selectedIds={[
1713 ...Array.from(selectedEntries),
1714 ...Array.from(selectedEmptyDays),
1715 ]}
1716 bulkAction={bulkAction}
1717 setBulkAction={setBulkAction}
1718 onApply={handleBulkApply}
1719 onClearSelection={() => {
1720 setSelectedEntries(new Set());
1721 setSelectedEmptyDays(new Set());
1722 }}
1723 statusFilter={statusFilter}
1724 setStatusFilter={setStatusFilter}
1725 statusOptions={statusOptions}
1726 showColumnsDropdown={showColumnsDropdown}
1727 setShowColumnsDropdown={setShowColumnsDropdown}
1728 columnOptions={[]}
1729 onToggleColumn={() => {}}
1730 bulkMutationPending={bulkDeleteMutation.isPending}
1731 totalItems={totalSelectableItems}
1732 bulkActionOptions={bulkActionOptions}
1733 />
1734 {isItineraryError ? (
1735 <Card>
1736 <CardContent className="p-6">
1737 <div className="space-y-4">
1738 <div className="flex items-center gap-3">
1739 <div className="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
1740 <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
1741 </div>
1742 <div>
1743 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1744 {__("Error Loading Itinerary", "yatra")}
1745 </h3>
1746 <p className="text-sm text-gray-600 dark:text-gray-400">
1747 {__(
1748 "We couldn't connect to the itinerary service. Please refresh or try again shortly.",
1749 "We couldn't connect to the itinerary service. Please refresh or try again shortly.",
1750 )}
1751 </p>
1752 </div>
1753 </div>
1754
1755 {derivedErrorDetails && (
1756 <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
1757 <div className="flex items-center justify-between mb-2">
1758 <h4 className="text-sm font-medium text-gray-900 dark:text-white">
1759 {__("Error Details", "yatra")}
1760 </h4>
1761 <button
1762 onClick={() => {
1763 const details = JSON.stringify(
1764 {
1765 error: derivedErrorDetails,
1766 request: errorContext.requestInfo,
1767 },
1768 null,
1769 2,
1770 );
1771 navigator.clipboard.writeText(details);
1772 // Show toast feedback
1773 showToast(
1774 __("Error details copied to clipboard", "yatra"),
1775 "success",
1776 );
1777 }}
1778 className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
1779 >
1780 {__("Copy", "yatra")}
1781 </button>
1782 </div>
1783 <div className="space-y-2 text-sm">
1784 <div className="text-gray-700 dark:text-gray-300 font-mono bg-white dark:bg-gray-900 p-2 rounded border">
1785 {derivedErrorDetails}
1786 </div>
1787 {errorContext.requestInfo && (
1788 <div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
1789 <div>
1790 <strong>{__("Request", "yatra")}:</strong>{" "}
1791 {errorContext.requestInfo.method}{" "}
1792 {errorContext.requestInfo.url}
1793 </div>
1794 {errorContext.requestInfo.payload && (
1795 <div>
1796 <strong>{__("Payload", "yatra")}:</strong>{" "}
1797 <pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-1 rounded overflow-auto max-h-20">
1798 {JSON.stringify(
1799 errorContext.requestInfo.payload,
1800 null,
1801 2,
1802 )}
1803 </pre>
1804 </div>
1805 )}
1806 </div>
1807 )}
1808 </div>
1809 </div>
1810 )}
1811
1812 <div className="flex gap-2">
1813 <Button
1814 onClick={() => refetch()}
1815 variant="outline"
1816 className="flex items-center gap-2"
1817 >
1818 <AlertCircle className="w-4 h-4" />
1819 {__("Retry", "yatra")}
1820 </Button>
1821 <Button
1822 onClick={() => window.location.reload()}
1823 variant="ghost"
1824 className="text-gray-600 dark:text-gray-400"
1825 >
1826 {__("Refresh Page", "yatra")}
1827 </Button>
1828 </div>
1829 </div>
1830 </CardContent>
1831 </Card>
1832 ) : isLoading || isLoadingTrips ? (
1833 <div className="space-y-3">
1834 {[1, 2, 3].map((i) => (
1835 <Card key={i} className="overflow-hidden">
1836 <CardContent className="p-0">
1837 <div className="flex items-center gap-4 p-4">
1838 <div className="flex items-center gap-4 flex-1">
1839 <div className="w-16 h-8 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
1840 <div className="flex-1">
1841 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2 animate-pulse"></div>
1842 <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
1843 </div>
1844 </div>
1845 </div>
1846 </CardContent>
1847 </Card>
1848 ))}
1849 </div>
1850 ) : filteredDayGroups.length === 0 ? (
1851 <Card>
1852 <CardContent className="p-12 text-center">
1853 <div className="flex flex-col items-center gap-4">
1854 <div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
1855 <Calendar className="w-8 h-8 text-gray-400" />
1856 </div>
1857 <div>
1858 <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
1859 {(() => {
1860 const selectedTrip = tripsData?.find(
1861 (t: any) => t.id.toString() === tripFilter,
1862 );
1863 const isSingleDay =
1864 selectedTrip?.trip_type === "single_day";
1865 return isSingleDay
1866 ? __("No itinerary entries yet", "yatra")
1867 : __("No itinerary days yet", "yatra");
1868 })()}
1869 </h3>
1870 <p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
1871 {(() => {
1872 const selectedTrip = tripsData?.find(
1873 (t: any) => t.id.toString() === tripFilter,
1874 );
1875 const isSingleDay =
1876 selectedTrip?.trip_type === "single_day";
1877 return isSingleDay
1878 ? __(
1879 "Start building your itinerary by adding your first entry to create a detailed schedule for your trip.",
1880 "yatra",
1881 )
1882 : __(
1883 "Start building your itinerary by adding your first day to organize your trip schedule.",
1884 "yatra",
1885 );
1886 })()}
1887 </p>
1888 {can("yatra_edit_trips") && (
1889 <Button
1890 onClick={handleAddDay}
1891 className="flex items-center gap-2 mx-auto"
1892 >
1893 <Plus className="w-4 h-4" />
1894 {(() => {
1895 const selectedTrip = tripsData?.find(
1896 (t: any) => t.id.toString() === tripFilter,
1897 );
1898 const isSingleDay =
1899 selectedTrip?.trip_type === "single_day";
1900 return isSingleDay
1901 ? __("Add Your First Entry", "yatra")
1902 : __("Add Your First Day", "yatra");
1903 })()}
1904 </Button>
1905 )}
1906 </div>
1907 </div>
1908 </CardContent>
1909 </Card>
1910 ) : (
1911 <div className="space-y-3">
1912 {/* Bulk Select All Option */}
1913 {filteredDayGroups.length > 0 && (
1914 <ConditionalRender capability="yatra_delete_trips">
1915 <Card className="overflow-visible">
1916 <CardContent className="p-0">
1917 <div className="flex items-center justify-between gap-4 rounded-t-lg">
1918 <div className="flex items-center gap-3">
1919 <input
1920 type="checkbox"
1921 checked={
1922 allEntryIds.length > 0 &&
1923 allEntryIds.every((id) => selectedEntries.has(id))
1924 }
1925 onChange={handleSelectAllDays}
1926 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
1927 />
1928 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
1929 {__("Select All", "yatra")}
1930 </label>
1931 {selectedEntries.size > 0 && (
1932 <span className="text-sm text-gray-500 dark:text-gray-400">
1933 ({selectedEntries.size} {__("selected", "yatra")})
1934 </span>
1935 )}
1936 </div>
1937 <div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
1938 <button
1939 type="button"
1940 className="hover:text-gray-700 dark:hover:text-gray-200 underline-offset-2 hover:underline"
1941 onClick={() => {
1942 // Expand all days
1943 const allKeys = filteredDayGroups.map(
1944 (dg) => `${dg.trip_id}-${dg.day}`,
1945 );
1946 setExpandedDays(new Set(allKeys));
1947 }}
1948 >
1949 {__("Expand all", "yatra")}
1950 </button>
1951 <button
1952 type="button"
1953 className="hover:text-gray-700 dark:hover:text-gray-200 underline-offset-2 hover:underline"
1954 onClick={() => {
1955 // Collapse all days
1956 setExpandedDays(new Set());
1957 }}
1958 >
1959 {__("Collapse all", "yatra")}
1960 </button>
1961 </div>
1962 </div>
1963 </CardContent>
1964 </Card>
1965 </ConditionalRender>
1966 )}
1967 {filteredDayGroups.map((dayGroup) => {
1968 const key = `${dayGroup.trip_id}-${dayGroup.day}`;
1969 const isExpanded = expandedDays.has(key);
1970 const counts = getItemCounts(dayGroup.entries);
1971 // Honor the persisted drag-sort `order` from the day-edit page.
1972 // Tiebreak on id so equal-order rows stay stable across renders.
1973 const sortedEntries = [...dayGroup.entries].sort((a, b) => {
1974 const orderA = typeof a.order === "number" ? a.order : 0;
1975 const orderB = typeof b.order === "number" ? b.order : 0;
1976 if (orderA !== orderB) return orderA - orderB;
1977 return a.id - b.id;
1978 });
1979
1980 const tripsList = Array.isArray(tripsData) ? tripsData : [];
1981 const selectedTripForLayout = tripsList.find(
1982 (t: any) => t.id.toString() === tripFilter,
1983 );
1984 const isSingleDayTrip =
1985 selectedTripForLayout?.trip_type === "single_day";
1986
1987 return (
1988 <div
1989 key={key}
1990 data-day-key={key}
1991 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"
1992 >
1993 {/* Day Header */}
1994 <div className="flex items-center gap-4 px-4 py-3 rounded-t-lg">
1995 <ConditionalRender capability="yatra_delete_trips">
1996 <input
1997 type="checkbox"
1998 checked={
1999 dayGroup.entries.length === 0
2000 ? selectedEmptyDays.has(key)
2001 : dayGroup.entries.every((e) =>
2002 selectedEntries.has(e.id),
2003 )
2004 }
2005 onChange={(e) => {
2006 e.stopPropagation();
2007 if (dayGroup.entries.length > 0) {
2008 handleSelectAll(dayGroup);
2009 } else {
2010 setSelectedEmptyDays((prev) => {
2011 const next = new Set(prev);
2012 if (next.has(key)) {
2013 next.delete(key);
2014 } else {
2015 next.add(key);
2016 }
2017 return next;
2018 });
2019 }
2020 }}
2021 onClick={(e) => e.stopPropagation()}
2022 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
2023 title={__("Select all entries for this day", "yatra")}
2024 />
2025 </ConditionalRender>
2026 <div
2027 className="flex items-center gap-4 flex-1 cursor-pointer"
2028 onClick={() =>
2029 toggleDay(dayGroup.trip_id, dayGroup.day)
2030 }
2031 >
2032 <div className="flex items-center gap-3">
2033 <div className="px-3 py-1 rounded-full bg-blue-600 text-white text-sm font-medium">
2034 {isSingleDayTrip
2035 ? __("Entry", "yatra")
2036 : __("Day", "yatra")}{" "}
2037 {dayGroup.day}
2038 </div>
2039 <div>
2040 <h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
2041 {dayGroup.day_title
2042 ? `${isSingleDayTrip ? __("Entry", "yatra") : __("Day", "yatra")} ${dayGroup.day}: ${dayGroup.day_title}`
2043 : `${isSingleDayTrip ? __("Entry", "yatra") : __("Day", "yatra")} ${dayGroup.day}`}
2044 {getDayStatusBadge(dayGroup)}
2045 </h3>
2046 <div className="flex items-center gap-3 mt-1 text-sm text-gray-600 dark:text-gray-400">
2047 <span>
2048 {counts.total} {__("items", "yatra")}
2049 </span>
2050 {counts.meals > 0 && (
2051 <div className="flex items-center gap-1">
2052 <UtensilsCrossed className="w-3.5 h-3.5" />
2053 <span>{counts.meals}</span>
2054 </div>
2055 )}
2056 {counts.activities > 0 && (
2057 <div className="flex items-center gap-1">
2058 <MapPin className="w-3.5 h-3.5" />
2059 <span>{counts.activities}</span>
2060 </div>
2061 )}
2062 {counts.accommodations > 0 && (
2063 <div className="flex items-center gap-1">
2064 <Hotel className="w-3.5 h-3.5" />
2065 <span>{__("Stay", "yatra")}</span>
2066 </div>
2067 )}
2068 </div>
2069 </div>
2070 </div>
2071 </div>
2072 <div
2073 className="flex items-center gap-2 relative cursor-pointer"
2074 onClick={(e) => {
2075 e.stopPropagation();
2076 toggleDay(dayGroup.trip_id, dayGroup.day);
2077 }}
2078 >
2079 <ConditionalRender capability="yatra_edit_trips">
2080 <div className="relative">
2081 <Button
2082 variant="ghost"
2083 size="icon"
2084 className="h-8 w-8"
2085 onClick={(e) => {
2086 e.stopPropagation();
2087 if (
2088 dayMenuOpen &&
2089 dayMenuOpen.tripId === dayGroup.trip_id &&
2090 dayMenuOpen.day === dayGroup.day
2091 ) {
2092 setDayMenuOpen(null);
2093 } else {
2094 setDayMenuOpen({
2095 tripId: dayGroup.trip_id,
2096 day: dayGroup.day,
2097 });
2098 }
2099 }}
2100 title={__("Day Options", "yatra")}
2101 >
2102 <MoreVertical className="w-4 h-4" />
2103 </Button>
2104 {dayMenuOpen &&
2105 dayMenuOpen.tripId === dayGroup.trip_id &&
2106 dayMenuOpen.day === dayGroup.day && (
2107 <div
2108 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"
2109 onClick={(e) => e.stopPropagation()}
2110 >
2111 <button
2112 onClick={() =>
2113 handleEditDay(
2114 dayGroup.trip_id,
2115 dayGroup.day,
2116 )
2117 }
2118 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"
2119 >
2120 <Pencil className="w-4 h-4" />
2121 {isSingleDayTrip
2122 ? __("Edit Entry", "yatra")
2123 : __("Edit Day", "yatra")}
2124 </button>
2125 <ConditionalRender capability="yatra_delete_trips">
2126 <button
2127 onClick={() =>
2128 handleDeleteDay(
2129 dayGroup.trip_id,
2130 dayGroup.day,
2131 )
2132 }
2133 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"
2134 >
2135 <Trash2 className="w-4 h-4" />
2136 {isSingleDayTrip
2137 ? __("Delete Entry", "yatra")
2138 : __("Delete Day", "yatra")}
2139 </button>
2140 </ConditionalRender>
2141 </div>
2142 )}
2143 </div>
2144 </ConditionalRender>
2145 {isExpanded ? (
2146 <ChevronDown className="w-5 h-5 text-gray-400" />
2147 ) : (
2148 <ChevronRight className="w-5 h-5 text-gray-400" />
2149 )}
2150 </div>
2151 </div>
2152
2153 {/* Day Content - Expanded */}
2154 {isExpanded && (
2155 <div className="border-t border-gray-200 dark:border-gray-700 overflow-visible">
2156 {/* Quick Add Section */}
2157 <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
2158 <div className="flex flex-wrap items-center gap-3">
2159 <div className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
2160 {__("QUICK ADD", "yatra")}
2161 </div>
2162 <div className="flex flex-wrap gap-2">
2163 {quickAddOptions.length > 0 ? (
2164 quickAddOptions.map((option: any) => {
2165 // Get color classes based on typeColor
2166 const colorClasses: Record<string, string> = {
2167 blue: "border-blue-500 text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20",
2168 green:
2169 "border-green-500 text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/20",
2170 red: "border-red-500 text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20",
2171 yellow:
2172 "border-yellow-500 text-yellow-700 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20",
2173 purple:
2174 "border-purple-500 text-purple-700 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20",
2175 orange:
2176 "border-orange-500 text-orange-700 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-900/20",
2177 pink: "border-pink-500 text-pink-700 hover:bg-pink-50 dark:text-pink-400 dark:hover:bg-pink-900/20",
2178 indigo:
2179 "border-indigo-500 text-indigo-700 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900/20",
2180 gray: "border-gray-500 text-gray-700 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-900/20",
2181 };
2182
2183 const colorClass =
2184 colorClasses[option.typeColor] ||
2185 colorClasses.blue;
2186
2187 return (
2188 <Button
2189 key={option.typeId}
2190 variant="outline"
2191 size="sm"
2192 onClick={() =>
2193 handleQuickAdd(option.typeId)
2194 }
2195 className={`flex items-center gap-2 ${colorClass}`}
2196 style={{
2197 borderColor: option.typeColor
2198 ? `var(--color-${option.typeColor}-500)`
2199 : undefined,
2200 }}
2201 >
2202 {option.typeIconImageSrc ? (
2203 <img
2204 src={option.typeIconImageSrc}
2205 alt={option.typeName}
2206 className="w-4 h-4 object-contain"
2207 />
2208 ) : (
2209 <IconSelector
2210 iconName={option.typeIcon as any}
2211 provider={
2212 option.typeIconProvider ?? "yatra"
2213 }
2214 className="w-4 h-4"
2215 />
2216 )}
2217 {option.typeName}
2218 </Button>
2219 );
2220 })
2221 ) : (
2222 <div className="text-sm text-gray-500 dark:text-gray-400">
2223 {__("No item types available", "yatra")}
2224 </div>
2225 )}
2226 </div>
2227 </div>
2228 </div>
2229
2230 {/* Itinerary Items */}
2231 <div className="px-4 pb-3 pt-2 space-y-3 overflow-visible rounded-b-lg">
2232 {sortedEntries.map((entry) => (
2233 <div
2234 key={entry.id}
2235 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"
2236 >
2237 {/* Checkbox */}
2238 <ConditionalRender capability="yatra_delete_trips">
2239 <input
2240 type="checkbox"
2241 checked={selectedEntries.has(entry.id)}
2242 onChange={(e) => {
2243 e.stopPropagation();
2244 handleToggleSelect(entry.id);
2245 }}
2246 onClick={(e) => e.stopPropagation()}
2247 className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer"
2248 title={__("Select this entry", "yatra")}
2249 />
2250 </ConditionalRender>
2251 {/* Time — render based on time_type: 'exact' shows the
2252 start/end range only when values exist; 'duration' and
2253 'flexible' show a label so the row isn't blank or
2254 misleading. */}
2255 <div className="flex flex-col items-center min-w-[80px]">
2256 <div className="text-sm font-medium text-gray-900 dark:text-white">
2257 {entry.time_type === "flexible"
2258 ? __("Flexible", "yatra")
2259 : entry.time_type === "duration"
2260 ? entry.duration ||
2261 __("Duration", "yatra")
2262 : entry.start_time
2263 ? entry.end_time
2264 ? `${formatTime(entry.start_time)} - ${formatTime(entry.end_time)}`
2265 : formatTime(entry.start_time)
2266 : "—"}
2267 </div>
2268 <div className="mt-1 flex items-center gap-1">
2269 {entry.end_time ? (
2270 <>
2271 <div className="relative flex items-center justify-center">
2272 <span className="absolute inline-flex h-5 w-5 rounded-full bg-blue-400/40 animate-ping" />
2273 <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-600" />
2274 </div>
2275 <div className="w-16 border-t border-dashed border-gray-300 dark:border-gray-600" />
2276 <div className="relative flex items-center justify-center">
2277 <span className="absolute inline-flex h-5 w-5 rounded-full bg-red-400/40 animate-ping" />
2278 <span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
2279 </div>
2280 </>
2281 ) : (
2282 <div className="relative flex items-center justify-center">
2283 <span className="absolute inline-flex h-5 w-5 rounded-full bg-blue-400/40 animate-ping" />
2284 <span className="relative inline-flex h-3 w-3 rounded-full bg-blue-600" />
2285 </div>
2286 )}
2287 </div>
2288 </div>
2289
2290 {/* Content */}
2291 <div className="flex-1">
2292 <div className="flex items-center justify-between gap-3">
2293 <div className="flex-1">
2294 <div className="flex items-center gap-2 mb-1">
2295 <a
2296 href={
2297 entry.item_type_id
2298 ? `${baseAdminUrl}?page=yatra&subpage=itinerary&tab=item-types&action=edit&id=${entry.item_type_id}`
2299 : "#"
2300 }
2301 className="focus:outline-none text-blue-600 dark:text-blue-400"
2302 onClick={(e) => {
2303 if (!entry.item_type_id)
2304 e.preventDefault();
2305 }}
2306 >
2307 <Badge
2308 className="text-xs font-medium px-2 py-0.5 hover:underline underline-offset-2"
2309 style={{
2310 backgroundColor:
2311 entry.item_color === "blue"
2312 ? "rgb(219, 234, 254)"
2313 : entry.item_color === "green"
2314 ? "rgb(220, 252, 231)"
2315 : entry.item_color ===
2316 "orange"
2317 ? "rgb(255, 237, 213)"
2318 : entry.item_color ===
2319 "purple"
2320 ? "rgb(243, 232, 255)"
2321 : entry.item_color ===
2322 "red"
2323 ? "rgb(254, 226, 226)"
2324 : entry.item_color ===
2325 "yellow"
2326 ? "rgb(254, 249, 195)"
2327 : "rgb(243, 244, 246)",
2328 color:
2329 entry.item_color === "blue"
2330 ? "rgb(29, 78, 216)"
2331 : entry.item_color === "green"
2332 ? "rgb(21, 128, 61)"
2333 : entry.item_color ===
2334 "orange"
2335 ? "rgb(194, 65, 12)"
2336 : entry.item_color ===
2337 "purple"
2338 ? "rgb(126, 34, 206)"
2339 : entry.item_color ===
2340 "red"
2341 ? "rgb(185, 28, 28)"
2342 : entry.item_color ===
2343 "yellow"
2344 ? "rgb(161, 98, 7)"
2345 : "rgb(55, 65, 81)",
2346 borderColor:
2347 entry.item_color === "blue"
2348 ? "rgb(147, 197, 253)"
2349 : entry.item_color === "green"
2350 ? "rgb(134, 239, 172)"
2351 : entry.item_color ===
2352 "orange"
2353 ? "rgb(254, 215, 170)"
2354 : entry.item_color ===
2355 "purple"
2356 ? "rgb(221, 214, 254)"
2357 : entry.item_color ===
2358 "red"
2359 ? "rgb(252, 165, 165)"
2360 : entry.item_color ===
2361 "yellow"
2362 ? "rgb(253, 230, 138)"
2363 : "rgb(209, 213, 219)",
2364 borderWidth: "1px",
2365 borderStyle: "solid",
2366 }}
2367 >
2368 <div className="flex items-center gap-1.5">
2369 {entry.item_icon_is_image &&
2370 entry.item_icon_image_src ? (
2371 <img
2372 src={entry.item_icon_image_src}
2373 alt=""
2374 className="w-3 h-3 object-contain"
2375 />
2376 ) : (
2377 <IconSelector
2378 iconName={
2379 entry.item_icon as any
2380 }
2381 provider={
2382 entry.item_icon_provider ??
2383 "yatra"
2384 }
2385 className="w-3 h-3"
2386 />
2387 )}
2388 <span>{entry.item_type}</span>
2389 </div>
2390 </Badge>
2391 </a>
2392 </div>
2393 <div className="flex items-center gap-2 mb-1">
2394 <a
2395 href={
2396 entry.item_id
2397 ? `${baseAdminUrl}?page=yatra&subpage=items&action=edit&id=${entry.item_id}`
2398 : "#"
2399 }
2400 className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline underline-offset-2 focus:outline-none text-left"
2401 onClick={(e) => {
2402 if (!entry.item_id)
2403 e.preventDefault();
2404 }}
2405 >
2406 {entry.title}
2407 </a>
2408 {getEntryStatusBadge(entry.status)}
2409 </div>
2410 <div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
2411 {entry.description}
2412 </div>
2413 <div className="flex flex-col gap-1 text-xs text-gray-500 dark:text-gray-400">
2414 {/* Top row: location / duration / price */}
2415 <div className="flex items-center flex-wrap gap-4">
2416 {entry.location && (
2417 <div className="flex items-center gap-1">
2418 <MapPin className="w-3.5 h-3.5" />
2419 <span>{entry.location}</span>
2420 </div>
2421 )}
2422 {entry.duration && (
2423 <div className="flex items-center gap-1">
2424 <Clock className="w-3.5 h-3.5" />
2425 <span>{entry.duration}</span>
2426 </div>
2427 )}
2428 {entry.cost && (
2429 <div className="flex items-center gap-1">
2430 <span>{currencySymbol}</span>
2431 <span>
2432 {entry.cost}
2433 {entry.cost_per_person
2434 ? ` / ${__("person", "yatra")}`
2435 : ""}
2436 </span>
2437 </div>
2438 )}
2439 </div>
2440
2441 {/* Bottom row: Includes / Excludes inline text */}
2442 <div>
2443 {(() => {
2444 // Normalize included/excluded items in case they come as JSON strings
2445 let included: string[] = [];
2446 let excluded: string[] = [];
2447
2448 if (
2449 Array.isArray(entry.included_items)
2450 ) {
2451 included = entry.included_items;
2452 } else if (
2453 typeof entry.included_items ===
2454 "string" &&
2455 entry.included_items.trim()
2456 ) {
2457 try {
2458 const parsed = JSON.parse(
2459 entry.included_items as string,
2460 );
2461 if (Array.isArray(parsed))
2462 included = parsed;
2463 } catch {
2464 // Fallback: split by comma
2465 included = (
2466 entry.included_items as string
2467 )
2468 .split(",")
2469 .map((i: string) => i.trim())
2470 .filter(Boolean);
2471 }
2472 }
2473
2474 if (
2475 Array.isArray(entry.excluded_items)
2476 ) {
2477 excluded = entry.excluded_items;
2478 } else if (
2479 typeof entry.excluded_items ===
2480 "string" &&
2481 entry.excluded_items.trim()
2482 ) {
2483 try {
2484 const parsed = JSON.parse(
2485 entry.excluded_items as string,
2486 );
2487 if (Array.isArray(parsed))
2488 excluded = parsed;
2489 } catch {
2490 excluded = (
2491 entry.excluded_items as string
2492 )
2493 .split(",")
2494 .map((i: string) => i.trim())
2495 .filter(Boolean);
2496 }
2497 }
2498
2499 if (
2500 included.length === 0 &&
2501 excluded.length === 0
2502 ) {
2503 return null;
2504 }
2505
2506 return (
2507 <div className="text-gray-500 dark:text-gray-400">
2508 {included.length > 0 && (
2509 <span>
2510 <span className="font-medium text-green-600 dark:text-green-400">
2511 {__("Includes", "yatra")}:
2512 </span>{" "}
2513 <span>
2514 {included.join(", ")}
2515 </span>
2516 </span>
2517 )}
2518 {included.length > 0 &&
2519 excluded.length > 0 && (
2520 <span>{" · "}</span>
2521 )}
2522 {excluded.length > 0 && (
2523 <span>
2524 <span className="font-medium text-red-600 dark:text-red-400">
2525 {__("Excludes", "yatra")}:
2526 </span>{" "}
2527 <span>
2528 {excluded.join(", ")}
2529 </span>
2530 </span>
2531 )}
2532 </div>
2533 );
2534 })()}
2535 </div>
2536 </div>
2537 </div>
2538
2539 {/* Actions */}
2540 <div className="flex items-center gap-1 relative z-10">
2541 <SearchableSelect
2542 value={entry.item_id?.toString() || ""}
2543 onChange={(value) => {
2544 if (!value) return;
2545
2546 const selectedItem = itemsData?.find(
2547 (item) =>
2548 item.id.toString() === value,
2549 );
2550
2551 if (
2552 selectedItem &&
2553 entry.item_type_id
2554 ) {
2555 updateItemMutation.mutate({
2556 entryId: entry.id,
2557 itemId: selectedItem.id,
2558 itemTypeId: entry.item_type_id,
2559 });
2560 }
2561 }}
2562 options={(() => {
2563 const itemTypeId = entry.item_type_id;
2564
2565 if (!itemTypeId) return [];
2566
2567 const itemTypeIdNum =
2568 typeof itemTypeId === "string"
2569 ? parseInt(itemTypeId, 10)
2570 : Number(itemTypeId);
2571
2572 if (
2573 !itemTypeIdNum ||
2574 isNaN(itemTypeIdNum)
2575 )
2576 return [];
2577
2578 const filteredItems = itemsData
2579 .filter((item) => {
2580 const typeId = item.type_id;
2581 if (!typeId) return false;
2582 const typeIdNum =
2583 typeof typeId === "string"
2584 ? parseInt(typeId, 10)
2585 : Number(typeId);
2586 const matches =
2587 typeIdNum === itemTypeIdNum;
2588
2589 return matches;
2590 })
2591 .map((item) => ({
2592 value: item.id.toString(),
2593 label: item.name,
2594 }));
2595
2596 return filteredItems;
2597 })()}
2598 placeholder={__(
2599 "Select item...",
2600 "yatra",
2601 )}
2602 searchPlaceholder={__(
2603 "Search items...",
2604 "yatra",
2605 )}
2606 className="min-w-[140px]"
2607 disabled={updateItemMutation.isPending}
2608 />
2609 <ConditionalRender capability="yatra_edit_trips">
2610 <Button
2611 variant="ghost"
2612 size="icon"
2613 onClick={() => handleEdit(entry)}
2614 className="h-8 w-8"
2615 title={__("Edit Activity", "yatra")}
2616 >
2617 <Pencil className="w-4 h-4" />
2618 </Button>
2619 </ConditionalRender>
2620 <ConditionalRender capability="yatra_delete_trips">
2621 <Button
2622 variant="ghost"
2623 size="icon"
2624 onClick={() => handleDelete(entry)}
2625 className="h-8 w-8 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
2626 title={__("Delete", "yatra")}
2627 >
2628 <Trash2 className="w-4 h-4" />
2629 </Button>
2630 </ConditionalRender>
2631 </div>
2632 </div>
2633 </div>
2634 </div>
2635 ))}
2636
2637 {/* Add Activity Button */}
2638 <ConditionalRender capability="yatra_edit_trips">
2639 <Button
2640 variant="outline"
2641 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"
2642 style={{ borderColor: "#3b82f6" }}
2643 onClick={() => {
2644 const tripId = dayGroup.trip_id;
2645 const day = dayGroup.day;
2646 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`;
2647 }}
2648 >
2649 <Plus className="w-4 h-4" />
2650 {__("Add Activity", "yatra")}
2651 </Button>
2652 </ConditionalRender>
2653 </div>
2654 </div>
2655 )}
2656 </div>
2657 );
2658 })}
2659 </div>
2660 )}
2661 </>
2662 </ConditionalRender>
2663
2664 {/* Delete Confirmation Dialog */}
2665 <ConfirmationDialog
2666 isOpen={deleteConfirm.isOpen}
2667 onClose={handleDeleteCancel}
2668 onConfirm={handleDeleteConfirm}
2669 title={
2670 (deleteConfirm.entry as any)?._isDayDeletion
2671 ? __("Delete Day", "yatra")
2672 : __("Delete Itinerary Entry", "yatra")
2673 }
2674 message={
2675 (deleteConfirm.entry as any)?._isDayDeletion
2676 ? __(
2677 "Are you sure you want to delete this day and all its activities? This action cannot be undone.",
2678 "yatra",
2679 )
2680 : __(
2681 "Are you sure you want to delete this itinerary entry? This action cannot be undone.",
2682 "yatra",
2683 )
2684 }
2685 confirmText={__("Delete", "yatra")}
2686 variant="danger"
2687 isLoading={deleteMutation.isPending}
2688 />
2689 {/* AI: Build Itinerary modal — only meaningful when a trip is
2690 selected. Mounting unconditionally so a future "open from URL"
2691 deep-link works, but the body refuses to operate if tripFilter
2692 is empty. */}
2693 {tripFilter &&
2694 (() => {
2695 const selectedTrip = tripsData?.find(
2696 (t: any) => String(t.id) === String(tripFilter),
2697 );
2698 const duration = Number(selectedTrip?.duration_days ?? 0);
2699 return (
2700 <BuildItineraryModal
2701 open={aiModalOpen}
2702 onClose={() => setAiModalOpen(false)}
2703 tripId={Number(tripFilter)}
2704 tripName={
2705 (selectedTrip?.title as string) ||
2706 (selectedTrip?.name as string) ||
2707 __("Selected trip", "yatra")
2708 }
2709 tripDurationDays={Number.isFinite(duration) ? duration : 0}
2710 onApplied={({ message }) => {
2711 showToast(message, "success");
2712 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
2713 queryClient.invalidateQueries({
2714 queryKey: ["trips-with-itinerary-meta"],
2715 });
2716 }}
2717 />
2718 );
2719 })()}
2720 </div>
2721 );
2722 };
2723
2724 export default Itinerary;
2725