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

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