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

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