| 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 |
|