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

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