| 1 |
/** |
| 2 |
* Departures Page |
| 3 |
* Manage trip departures (manual and recurring-generated) |
| 4 |
*/ |
| 5 |
|
| 6 |
import React, { |
| 7 |
useState, |
| 8 |
useEffect, |
| 9 |
useRef, |
| 10 |
useMemo, |
| 11 |
useCallback, |
| 12 |
} from "react"; |
| 13 |
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 14 |
import { |
| 15 |
Plus, |
| 16 |
Search, |
| 17 |
Edit, |
| 18 |
Trash2, |
| 19 |
RotateCcw, |
| 20 |
Eye, |
| 21 |
MapPin, |
| 22 |
CalendarDays, |
| 23 |
} from "lucide-react"; |
| 24 |
import { __ } from "../lib/i18n"; |
| 25 |
import { toDateValue } from "../lib/dateFormat"; |
| 26 |
import { Button } from "../components/ui/button"; |
| 27 |
import { Input } from "../components/ui/input"; |
| 28 |
import { Select } from "../components/ui/select"; |
| 29 |
import { SearchableSelect } from "../components/ui/searchable-select"; |
| 30 |
import { PageHeader } from "../components/common/PageHeader"; |
| 31 |
import { Card, CardContent } from "../components/ui/card"; |
| 32 |
import { |
| 33 |
Table, |
| 34 |
TableBody, |
| 35 |
TableCell, |
| 36 |
TableHead, |
| 37 |
TableHeader, |
| 38 |
TableRow, |
| 39 |
} from "../components/ui/table"; |
| 40 |
import { Badge } from "../components/ui/badge"; |
| 41 |
import { DateRangePicker } from "../components/ui/date-range-picker"; |
| 42 |
import { BulkActionToolbar } from "../components/shared/BulkActionToolbar"; |
| 43 |
import { Table as SharedTable } from "../components/shared/Table"; |
| 44 |
import { Pagination } from "../components/shared/Pagination"; |
| 45 |
import { apiClient } from "../lib/api-client"; |
| 46 |
import { useToast } from "../components/ui/toast"; |
| 47 |
import { getErrorContext } from "../lib/errors"; |
| 48 |
// Format date helper |
| 49 |
const formatDate = (dateString: string): string => { |
| 50 |
if (!dateString) return "--"; |
| 51 |
try { |
| 52 |
const date = toDateValue(dateString); |
| 53 |
return date.toLocaleDateString("en-US", { |
| 54 |
year: "numeric", |
| 55 |
month: "short", |
| 56 |
day: "numeric", |
| 57 |
}); |
| 58 |
} catch { |
| 59 |
return dateString; |
| 60 |
} |
| 61 |
}; |
| 62 |
|
| 63 |
interface Traveler { |
| 64 |
id: number; |
| 65 |
booking_id: number; |
| 66 |
booking_reference: string; |
| 67 |
is_lead: boolean; |
| 68 |
first_name: string; |
| 69 |
last_name: string; |
| 70 |
email: string; |
| 71 |
phone: string; |
| 72 |
} |
| 73 |
|
| 74 |
interface Departure { |
| 75 |
id: number; |
| 76 |
trip_id: number; |
| 77 |
date: string; |
| 78 |
start_date?: string; |
| 79 |
time?: string; |
| 80 |
max_capacity: number; |
| 81 |
booked_count: number; |
| 82 |
available_capacity: number; |
| 83 |
status: "upcoming" | "full" | "past" | "cancelled" | "trash"; |
| 84 |
source: "manual" | "booking_created"; |
| 85 |
price_override?: number; |
| 86 |
total_revenue?: number; |
| 87 |
notes?: string; |
| 88 |
created_at: string; |
| 89 |
updated_at: string; |
| 90 |
trip?: { |
| 91 |
id: number; |
| 92 |
title: string; |
| 93 |
slug?: string; |
| 94 |
}; |
| 95 |
booking_ids?: number[]; |
| 96 |
bookings_count?: number; |
| 97 |
travelers?: Traveler[]; |
| 98 |
travelers_count?: number; |
| 99 |
} |
| 100 |
|
| 101 |
interface Trip { |
| 102 |
id: number; |
| 103 |
title: string; |
| 104 |
trip_type?: "single_day" | "multi_day"; |
| 105 |
starting_location?: string; |
| 106 |
ending_location?: string; |
| 107 |
} |
| 108 |
|
| 109 |
const formatCurrency = (value?: number | null) => { |
| 110 |
if (value === undefined || value === null) { |
| 111 |
return "--"; |
| 112 |
} |
| 113 |
const currency = (window as any).yatraAdmin?.currency || "USD"; |
| 114 |
try { |
| 115 |
return new Intl.NumberFormat("en-US", { |
| 116 |
style: "currency", |
| 117 |
currency, |
| 118 |
}).format(value); |
| 119 |
} catch { |
| 120 |
return `${currency} ${value.toFixed(2)}`; |
| 121 |
} |
| 122 |
}; |
| 123 |
|
| 124 |
/** |
| 125 |
* Normalize /trips list payloads and any stale React Query cache (SPA navigation can reuse bad shapes). |
| 126 |
*/ |
| 127 |
function normalizeTripsForDropdown(raw: unknown): Trip[] { |
| 128 |
if (Array.isArray(raw)) { |
| 129 |
return raw as Trip[]; |
| 130 |
} |
| 131 |
if (raw && typeof raw === "object") { |
| 132 |
const r = raw as Record<string, unknown>; |
| 133 |
if (Array.isArray(r.data)) { |
| 134 |
return r.data as Trip[]; |
| 135 |
} |
| 136 |
const inner = r.data; |
| 137 |
if ( |
| 138 |
inner && |
| 139 |
typeof inner === "object" && |
| 140 |
Array.isArray((inner as Record<string, unknown>).data) |
| 141 |
) { |
| 142 |
return (inner as Record<string, unknown>).data as Trip[]; |
| 143 |
} |
| 144 |
} |
| 145 |
return []; |
| 146 |
} |
| 147 |
|
| 148 |
const Departures: React.FC = () => { |
| 149 |
// Load selected trip from localStorage on initial render |
| 150 |
const [selectedTripId, setSelectedTripId] = useState<number | null>(() => { |
| 151 |
try { |
| 152 |
const saved = localStorage.getItem("yatra_selected_trip_id"); |
| 153 |
return saved ? parseInt(saved) : null; |
| 154 |
} catch { |
| 155 |
return null; |
| 156 |
} |
| 157 |
}); |
| 158 |
const [statusFilter, setStatusFilter] = useState< |
| 159 |
"all" | "upcoming" | "full" | "past" | "cancelled" | "trash" |
| 160 |
>("all"); |
| 161 |
const [sourceFilter, setSourceFilter] = useState< |
| 162 |
"all" | "manual" | "booking_created" |
| 163 |
>("all"); |
| 164 |
const [searchTerm, setSearchTerm] = useState(""); |
| 165 |
const [dateFrom, setDateFrom] = useState<string>(""); |
| 166 |
const [dateTo, setDateTo] = useState<string>(""); |
| 167 |
const [page, setPage] = useState(1); |
| 168 |
const [showColumnMenu, setShowColumnMenu] = useState(false); |
| 169 |
const [visibleColumns, setVisibleColumns] = useState(() => { |
| 170 |
try { |
| 171 |
const saved = localStorage.getItem("yatra_departures_visible_columns"); |
| 172 |
if (saved) { |
| 173 |
return JSON.parse(saved); |
| 174 |
} |
| 175 |
} catch { |
| 176 |
// Ignore errors |
| 177 |
} |
| 178 |
// Default column visibility (trip and source hidden by default) |
| 179 |
return { |
| 180 |
trip: false, |
| 181 |
date: true, |
| 182 |
time: true, |
| 183 |
capacity: true, |
| 184 |
booked: true, |
| 185 |
available: true, |
| 186 |
revenue: true, |
| 187 |
travelers: true, |
| 188 |
bookings: true, |
| 189 |
status: true, |
| 190 |
source: false, |
| 191 |
}; |
| 192 |
}); |
| 193 |
const columnMenuRef = useRef<HTMLDivElement>(null); |
| 194 |
const [travelerModalDeparture, setTravelerModalDeparture] = |
| 195 |
useState<Departure | null>(null); |
| 196 |
const [selectedIds, setSelectedIds] = useState<number[]>([]); |
| 197 |
const [bulkAction, setBulkAction] = useState(""); |
| 198 |
const queryClient = useQueryClient(); |
| 199 |
const { showToast } = useToast(); |
| 200 |
|
| 201 |
// Save column visibility to localStorage whenever it changes |
| 202 |
useEffect(() => { |
| 203 |
try { |
| 204 |
localStorage.setItem( |
| 205 |
"yatra_departures_visible_columns", |
| 206 |
JSON.stringify(visibleColumns), |
| 207 |
); |
| 208 |
} catch (error) { |
| 209 |
console.error("Failed to save column visibility to localStorage:", error); |
| 210 |
} |
| 211 |
}, [visibleColumns]); |
| 212 |
|
| 213 |
// Close column menu when clicking outside |
| 214 |
useEffect(() => { |
| 215 |
const handleClickOutside = (event: MouseEvent) => { |
| 216 |
if ( |
| 217 |
columnMenuRef.current && |
| 218 |
!columnMenuRef.current.contains(event.target as Node) |
| 219 |
) { |
| 220 |
setShowColumnMenu(false); |
| 221 |
} |
| 222 |
}; |
| 223 |
|
| 224 |
if (showColumnMenu) { |
| 225 |
document.addEventListener("mousedown", handleClickOutside); |
| 226 |
} |
| 227 |
|
| 228 |
return () => { |
| 229 |
document.removeEventListener("mousedown", handleClickOutside); |
| 230 |
}; |
| 231 |
}, [showColumnMenu]); |
| 232 |
|
| 233 |
const toggleColumn = (column: keyof typeof visibleColumns) => { |
| 234 |
setVisibleColumns((prev: typeof visibleColumns) => ({ |
| 235 |
...prev, |
| 236 |
[column]: !prev[column], |
| 237 |
})); |
| 238 |
}; |
| 239 |
|
| 240 |
const columnOptions = Object.entries(visibleColumns).map(([key, value]) => ({ |
| 241 |
key, |
| 242 |
label: key.charAt(0).toUpperCase() + key.slice(1), |
| 243 |
visible: Boolean(value), |
| 244 |
})); |
| 245 |
|
| 246 |
// Save selected trip to localStorage whenever it changes |
| 247 |
useEffect(() => { |
| 248 |
try { |
| 249 |
if (selectedTripId) { |
| 250 |
localStorage.setItem( |
| 251 |
"yatra_selected_trip_id", |
| 252 |
selectedTripId.toString(), |
| 253 |
); |
| 254 |
} else { |
| 255 |
localStorage.removeItem("yatra_selected_trip_id"); |
| 256 |
} |
| 257 |
} catch (error) { |
| 258 |
console.error("Failed to save trip selection to localStorage:", error); |
| 259 |
} |
| 260 |
}, [selectedTripId]); |
| 261 |
|
| 262 |
// Fetch trips for dropdown (same response shapes as Trips.tsx: data[] or data.data[]) |
| 263 |
const { data: tripsData } = useQuery({ |
| 264 |
queryKey: ["trips", "departures-dropdown", "v1"], |
| 265 |
queryFn: async () => { |
| 266 |
const response = await apiClient.get("/trips", { |
| 267 |
params: { per_page: 1000 }, |
| 268 |
}); |
| 269 |
return normalizeTripsForDropdown(response); |
| 270 |
}, |
| 271 |
}); |
| 272 |
|
| 273 |
const tripsList = useMemo( |
| 274 |
() => normalizeTripsForDropdown(tripsData), |
| 275 |
[tripsData], |
| 276 |
); |
| 277 |
|
| 278 |
// Note: We pass filter values directly to the API call instead of building queryParams |
| 279 |
|
| 280 |
// Fetch departures data for the current status tab (filtered list) |
| 281 |
const { |
| 282 |
data: departuresData, |
| 283 |
isLoading, |
| 284 |
error, |
| 285 |
refetch, |
| 286 |
} = useQuery({ |
| 287 |
queryKey: [ |
| 288 |
"departures", |
| 289 |
selectedTripId, |
| 290 |
statusFilter, |
| 291 |
sourceFilter, |
| 292 |
searchTerm, |
| 293 |
dateFrom, |
| 294 |
dateTo, |
| 295 |
page, |
| 296 |
], |
| 297 |
queryFn: async () => { |
| 298 |
// Check URL parameter for error simulation (for testing) |
| 299 |
const urlParams = new URLSearchParams(window.location.search); |
| 300 |
if (urlParams.get("simulate_error") === "true") { |
| 301 |
throw new Error( |
| 302 |
"Simulated API error for testing error UI functionality", |
| 303 |
); |
| 304 |
} |
| 305 |
|
| 306 |
// API endpoint based on if a trip is selected |
| 307 |
const endpoint = selectedTripId |
| 308 |
? `/trips/${selectedTripId}/departures` |
| 309 |
: "/departures"; |
| 310 |
|
| 311 |
const response = await apiClient.get(endpoint, { |
| 312 |
params: { |
| 313 |
status: statusFilter !== "all" ? statusFilter : undefined, |
| 314 |
source: sourceFilter !== "all" ? sourceFilter : undefined, |
| 315 |
search: searchTerm || undefined, |
| 316 |
date_from: dateFrom && dateFrom.trim() !== "" ? dateFrom : undefined, |
| 317 |
date_to: dateTo && dateTo.trim() !== "" ? dateTo : undefined, |
| 318 |
include_past: |
| 319 |
(dateFrom && dateFrom.trim() !== "") || |
| 320 |
(dateTo && dateTo.trim() !== "") |
| 321 |
? "true" |
| 322 |
: "false", |
| 323 |
page: page, |
| 324 |
per_page: 20, |
| 325 |
}, |
| 326 |
}); |
| 327 |
|
| 328 |
// apiClient returns the full response: {success, data, meta} |
| 329 |
// response.data is the array of departures |
| 330 |
// We need to return the structure as {data: [...], meta: {...}} |
| 331 |
const result = { |
| 332 |
data: response?.data || [], |
| 333 |
meta: response?.meta || { total: 0 }, |
| 334 |
}; |
| 335 |
|
| 336 |
// Debug: Log the first departure to see time and revenue values |
| 337 |
if (result.data.length > 0) { |
| 338 |
} |
| 339 |
|
| 340 |
return result; |
| 341 |
}, |
| 342 |
// Always enabled, whether trip is selected or not |
| 343 |
enabled: true, |
| 344 |
}); |
| 345 |
|
| 346 |
const departures: Departure[] = departuresData?.data || []; |
| 347 |
|
| 348 |
// Enhanced error handling |
| 349 |
const errorContext = getErrorContext(error); |
| 350 |
const apiErrorMessage = |
| 351 |
(departuresData as any)?.error || (departuresData as any)?.message; |
| 352 |
const derivedErrorDetails = |
| 353 |
errorContext.details || |
| 354 |
(apiErrorMessage ? String(apiErrorMessage) : undefined) || |
| 355 |
(error ? String(error?.message || error) : undefined); |
| 356 |
const isDeparturesError = !!error || !!apiErrorMessage; |
| 357 |
|
| 358 |
const totalItems = departuresData?.meta?.total ?? 0; |
| 359 |
const itemsPerPage = 20; |
| 360 |
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage)); |
| 361 |
|
| 362 |
// Fetch departures stats without status filter so tab counts stay stable across tabs |
| 363 |
const { data: departuresStatsData } = useQuery({ |
| 364 |
queryKey: [ |
| 365 |
"departures-stats", |
| 366 |
selectedTripId, |
| 367 |
sourceFilter, |
| 368 |
searchTerm, |
| 369 |
dateFrom, |
| 370 |
dateTo, |
| 371 |
], |
| 372 |
queryFn: async () => { |
| 373 |
const endpoint = selectedTripId |
| 374 |
? `/trips/${selectedTripId}/departures` |
| 375 |
: "/departures"; |
| 376 |
|
| 377 |
const response = await apiClient.get(endpoint, { |
| 378 |
params: { |
| 379 |
// NOTE: intentionally no status param here |
| 380 |
source: sourceFilter !== "all" ? sourceFilter : undefined, |
| 381 |
search: searchTerm || undefined, |
| 382 |
date_from: dateFrom && dateFrom.trim() !== "" ? dateFrom : undefined, |
| 383 |
date_to: dateTo && dateTo.trim() !== "" ? dateTo : undefined, |
| 384 |
include_past: |
| 385 |
(dateFrom && dateFrom.trim() !== "") || |
| 386 |
(dateTo && dateTo.trim() !== "") |
| 387 |
? "true" |
| 388 |
: "false", |
| 389 |
page: 1, |
| 390 |
per_page: 1000, |
| 391 |
}, |
| 392 |
}); |
| 393 |
|
| 394 |
return response?.data || []; |
| 395 |
}, |
| 396 |
enabled: true, |
| 397 |
}); |
| 398 |
|
| 399 |
const statusCounts = useMemo(() => { |
| 400 |
const allDepartures: Departure[] = Array.isArray(departuresStatsData) |
| 401 |
? departuresStatsData |
| 402 |
: []; |
| 403 |
|
| 404 |
const counts = { |
| 405 |
all: allDepartures.length, |
| 406 |
upcoming: 0, |
| 407 |
full: 0, |
| 408 |
past: 0, |
| 409 |
cancelled: 0, |
| 410 |
trash: 0, |
| 411 |
}; |
| 412 |
|
| 413 |
allDepartures.forEach((departure) => { |
| 414 |
if (departure.status === "upcoming") counts.upcoming += 1; |
| 415 |
if (departure.status === "full") counts.full += 1; |
| 416 |
if (departure.status === "past") counts.past += 1; |
| 417 |
if (departure.status === "cancelled") counts.cancelled += 1; |
| 418 |
if (departure.status === "trash") counts.trash += 1; |
| 419 |
}); |
| 420 |
|
| 421 |
return counts; |
| 422 |
}, [departuresStatsData]); |
| 423 |
|
| 424 |
const statusOptions = [ |
| 425 |
{ key: "all", label: __("All", "yatra"), count: statusCounts.all }, |
| 426 |
{ |
| 427 |
key: "upcoming", |
| 428 |
label: __("Upcoming", "yatra"), |
| 429 |
count: statusCounts.upcoming, |
| 430 |
}, |
| 431 |
{ key: "full", label: __("Full", "yatra"), count: statusCounts.full }, |
| 432 |
{ key: "past", label: __("Past", "yatra"), count: statusCounts.past }, |
| 433 |
{ |
| 434 |
key: "cancelled", |
| 435 |
label: __("Cancelled", "yatra"), |
| 436 |
count: statusCounts.cancelled, |
| 437 |
}, |
| 438 |
{ key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash }, |
| 439 |
]; |
| 440 |
|
| 441 |
const bulkActionOptions = useMemo(() => { |
| 442 |
if (statusFilter === "trash") { |
| 443 |
return [ |
| 444 |
{ value: "restore", label: __("Restore", "yatra") }, |
| 445 |
{ value: "delete", label: __("Delete Permanently", "yatra") }, |
| 446 |
]; |
| 447 |
} |
| 448 |
return [{ value: "trash", label: __("Move to Trash", "yatra") }]; |
| 449 |
}, [statusFilter]); |
| 450 |
|
| 451 |
const handleBulkApply = async () => { |
| 452 |
const ids = selectedIds; |
| 453 |
|
| 454 |
if (!bulkAction || ids.length === 0) { |
| 455 |
showToast( |
| 456 |
__("Please select departures and a bulk action first.", "yatra"), |
| 457 |
"error", |
| 458 |
); |
| 459 |
return; |
| 460 |
} |
| 461 |
|
| 462 |
if (!selectedTripId) { |
| 463 |
showToast(__("Please select a trip first", "yatra"), "error"); |
| 464 |
return; |
| 465 |
} |
| 466 |
|
| 467 |
try { |
| 468 |
if (bulkAction === "trash") { |
| 469 |
// Move to trash |
| 470 |
await Promise.all( |
| 471 |
ids.map((id) => |
| 472 |
apiClient.patch(`/trips/${selectedTripId}/departures/${id}`, { |
| 473 |
status: "trash", |
| 474 |
}), |
| 475 |
), |
| 476 |
); |
| 477 |
showToast( |
| 478 |
__("Selected departures moved to trash.", "yatra"), |
| 479 |
"success", |
| 480 |
); |
| 481 |
} else if (bulkAction === "restore") { |
| 482 |
// Restore from trash |
| 483 |
await Promise.all( |
| 484 |
ids.map((id) => |
| 485 |
apiClient.patch(`/trips/${selectedTripId}/departures/${id}`, { |
| 486 |
status: "upcoming", |
| 487 |
}), |
| 488 |
), |
| 489 |
); |
| 490 |
showToast(__("Selected departures restored.", "yatra"), "success"); |
| 491 |
} else if (bulkAction === "delete") { |
| 492 |
// Delete permanently (only from trash) |
| 493 |
const eligibleIds = departures |
| 494 |
.filter( |
| 495 |
(d) => |
| 496 |
ids.includes(d.id) && |
| 497 |
d.status === "trash" && |
| 498 |
d.source === "booking_created" && |
| 499 |
d.booked_count === 0, |
| 500 |
) |
| 501 |
.map((d) => d.id); |
| 502 |
|
| 503 |
if (eligibleIds.length === 0) { |
| 504 |
showToast( |
| 505 |
__( |
| 506 |
"No selected departures can be deleted (must be in trash, booking-created with zero bookings).", |
| 507 |
"No selected departures can be deleted (must be in trash, booking-created with zero bookings).", |
| 508 |
), |
| 509 |
"error", |
| 510 |
); |
| 511 |
return; |
| 512 |
} |
| 513 |
|
| 514 |
await Promise.all( |
| 515 |
eligibleIds.map((id) => |
| 516 |
apiClient.delete(`/trips/${selectedTripId}/departures/${id}`), |
| 517 |
), |
| 518 |
); |
| 519 |
showToast( |
| 520 |
__("Selected departures deleted permanently.", "yatra"), |
| 521 |
"success", |
| 522 |
); |
| 523 |
} |
| 524 |
|
| 525 |
setSelectedIds([]); |
| 526 |
setBulkAction(""); |
| 527 |
queryClient.invalidateQueries({ |
| 528 |
queryKey: ["departures", selectedTripId], |
| 529 |
}); |
| 530 |
queryClient.invalidateQueries({ |
| 531 |
queryKey: ["departures-stats", selectedTripId], |
| 532 |
}); |
| 533 |
} catch (error: any) { |
| 534 |
showToast( |
| 535 |
error?.message || |
| 536 |
__( |
| 537 |
"Failed to perform bulk action. Please try again.", |
| 538 |
"Failed to perform bulk action. Please try again.", |
| 539 |
), |
| 540 |
"error", |
| 541 |
); |
| 542 |
} |
| 543 |
}; |
| 544 |
|
| 545 |
/** Trip id for REST URLs: each row includes trip_id for /departures; filter supplies it when one trip is selected. */ |
| 546 |
const tripIdForDeparture = useCallback( |
| 547 |
(departure: Departure): number | null => { |
| 548 |
if (departure.trip_id > 0) { |
| 549 |
return departure.trip_id; |
| 550 |
} |
| 551 |
if (selectedTripId != null && selectedTripId > 0) { |
| 552 |
return selectedTripId; |
| 553 |
} |
| 554 |
return null; |
| 555 |
}, |
| 556 |
[selectedTripId], |
| 557 |
); |
| 558 |
|
| 559 |
const navigateToAdd = () => { |
| 560 |
if (!selectedTripId) { |
| 561 |
showToast(__("Please select a trip first", "yatra"), "error"); |
| 562 |
return; |
| 563 |
} |
| 564 |
window.location.href = `?page=yatra&subpage=departures&action=create&trip_id=${selectedTripId}`; |
| 565 |
}; |
| 566 |
|
| 567 |
const navigateToEdit = useCallback( |
| 568 |
(departure: Departure) => { |
| 569 |
const tid = tripIdForDeparture(departure); |
| 570 |
if (!tid) { |
| 571 |
showToast( |
| 572 |
__( |
| 573 |
"Cannot open edit: this departure has no trip id. Select a trip filter or refresh the list.", |
| 574 |
"yatra", |
| 575 |
), |
| 576 |
"error", |
| 577 |
); |
| 578 |
return; |
| 579 |
} |
| 580 |
window.location.href = `?page=yatra&subpage=departures&action=edit&id=${departure.id}&trip_id=${tid}`; |
| 581 |
}, |
| 582 |
[tripIdForDeparture, showToast], |
| 583 |
); |
| 584 |
|
| 585 |
const navigateToView = useCallback( |
| 586 |
(departure: Departure) => { |
| 587 |
const tid = tripIdForDeparture(departure); |
| 588 |
if (!tid) { |
| 589 |
showToast( |
| 590 |
__( |
| 591 |
"Cannot open departure: missing trip id. Select a trip filter or refresh the list.", |
| 592 |
"yatra", |
| 593 |
), |
| 594 |
"error", |
| 595 |
); |
| 596 |
return; |
| 597 |
} |
| 598 |
window.location.href = `?page=yatra&subpage=departures&action=view&id=${departure.id}&trip_id=${tid}`; |
| 599 |
}, |
| 600 |
[tripIdForDeparture, showToast], |
| 601 |
); |
| 602 |
|
| 603 |
// Delete departure mutation (per-row trip id supports "All trips" list) |
| 604 |
const deleteMutation = useMutation({ |
| 605 |
mutationFn: async ({ id, tripId: tid }: { id: number; tripId: number }) => { |
| 606 |
await apiClient.delete(`/trips/${tid}/departures/${id}`); |
| 607 |
}, |
| 608 |
onSuccess: () => { |
| 609 |
queryClient.invalidateQueries({ queryKey: ["departures"] }); |
| 610 |
queryClient.invalidateQueries({ queryKey: ["departures-stats"] }); |
| 611 |
showToast(__("Departure deleted successfully", "yatra"), "success"); |
| 612 |
}, |
| 613 |
onError: (error: any) => { |
| 614 |
showToast( |
| 615 |
error?.message || __("Failed to delete departure", "yatra"), |
| 616 |
"error", |
| 617 |
); |
| 618 |
}, |
| 619 |
}); |
| 620 |
|
| 621 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 622 |
const handleDelete = (departure: Departure) => { |
| 623 |
const tid = tripIdForDeparture(departure); |
| 624 |
if (!tid) { |
| 625 |
showToast( |
| 626 |
__("Cannot delete: missing trip id for this departure.", "yatra"), |
| 627 |
"error", |
| 628 |
); |
| 629 |
return; |
| 630 |
} |
| 631 |
if ( |
| 632 |
!confirm(__("Are you sure you want to delete this departure?", "yatra")) |
| 633 |
) { |
| 634 |
return; |
| 635 |
} |
| 636 |
deleteMutation.mutate({ id: departure.id, tripId: tid }); |
| 637 |
}; |
| 638 |
|
| 639 |
const isAllSelected = |
| 640 |
departures.length > 0 && selectedIds.length === departures.length; |
| 641 |
|
| 642 |
const handleSelectAll = (checked: boolean) => { |
| 643 |
if (checked) { |
| 644 |
setSelectedIds(departures.map((d) => d.id)); |
| 645 |
} else { |
| 646 |
setSelectedIds([]); |
| 647 |
} |
| 648 |
}; |
| 649 |
|
| 650 |
const handleSelectRow = (id: number, checked: boolean) => { |
| 651 |
setSelectedIds((prev) => |
| 652 |
checked ? [...prev, id] : prev.filter((existingId) => existingId !== id), |
| 653 |
); |
| 654 |
}; |
| 655 |
|
| 656 |
const getStatusBadge = (status: string) => { |
| 657 |
const statusMap: Record<string, { label: string; className: string }> = { |
| 658 |
upcoming: { |
| 659 |
label: __("Upcoming", "yatra"), |
| 660 |
className: |
| 661 |
"bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400", |
| 662 |
}, |
| 663 |
full: { |
| 664 |
label: __("Full", "yatra"), |
| 665 |
className: |
| 666 |
"bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", |
| 667 |
}, |
| 668 |
past: { |
| 669 |
label: __("Past", "yatra"), |
| 670 |
className: |
| 671 |
"bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", |
| 672 |
}, |
| 673 |
cancelled: { |
| 674 |
label: __("Cancelled", "yatra"), |
| 675 |
className: |
| 676 |
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400", |
| 677 |
}, |
| 678 |
trash: { |
| 679 |
label: __("Trash", "yatra"), |
| 680 |
className: |
| 681 |
"bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", |
| 682 |
}, |
| 683 |
}; |
| 684 |
|
| 685 |
const statusInfo = statusMap[status] || statusMap.upcoming; |
| 686 |
|
| 687 |
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>; |
| 688 |
}; |
| 689 |
|
| 690 |
const getSourceBadge = (source: string) => { |
| 691 |
if (source === "manual") { |
| 692 |
return ( |
| 693 |
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"> |
| 694 |
{__("Manual", "yatra")} |
| 695 |
</Badge> |
| 696 |
); |
| 697 |
} |
| 698 |
return ( |
| 699 |
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400"> |
| 700 |
{__("Booking Created", "yatra")} |
| 701 |
</Badge> |
| 702 |
); |
| 703 |
}; |
| 704 |
|
| 705 |
const clearDateFilters = () => { |
| 706 |
setDateFrom(""); |
| 707 |
setDateTo(""); |
| 708 |
}; |
| 709 |
|
| 710 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 711 |
const openTravelerModal = (departure: Departure) => { |
| 712 |
if (!departure.travelers || !departure.travelers.length) { |
| 713 |
return; |
| 714 |
} |
| 715 |
setTravelerModalDeparture(departure); |
| 716 |
}; |
| 717 |
|
| 718 |
const closeTravelerModal = () => setTravelerModalDeparture(null); |
| 719 |
|
| 720 |
const departureColumns = React.useMemo( |
| 721 |
() => [ |
| 722 |
{ |
| 723 |
key: "trip", |
| 724 |
label: __("Trip", "yatra"), |
| 725 |
visible: visibleColumns.trip, |
| 726 |
render: (departure: Departure) => |
| 727 |
departure.trip?.title ? ( |
| 728 |
<a |
| 729 |
href={`?page=yatra&subpage=trips&action=edit&id=${departure.trip.id}`} |
| 730 |
className="text-blue-600 hover:text-blue-800 hover:underline" |
| 731 |
title={__("View trip", "yatra")} |
| 732 |
> |
| 733 |
{departure.trip.title} |
| 734 |
</a> |
| 735 |
) : ( |
| 736 |
<span className="text-gray-400">--</span> |
| 737 |
), |
| 738 |
}, |
| 739 |
{ |
| 740 |
key: "date", |
| 741 |
label: __("Date", "yatra"), |
| 742 |
visible: visibleColumns.date, |
| 743 |
render: (departure: Departure) => |
| 744 |
formatDate(departure.start_date || departure.date), |
| 745 |
}, |
| 746 |
{ |
| 747 |
key: "time", |
| 748 |
label: __("Time", "yatra"), |
| 749 |
visible: visibleColumns.time, |
| 750 |
render: (departure: Departure) => |
| 751 |
departure.time ? ( |
| 752 |
<span className="font-mono">{departure.time}</span> |
| 753 |
) : ( |
| 754 |
<span className="text-gray-400">--</span> |
| 755 |
), |
| 756 |
}, |
| 757 |
{ |
| 758 |
key: "capacity", |
| 759 |
label: __("Capacity", "yatra"), |
| 760 |
visible: visibleColumns.capacity, |
| 761 |
render: (departure: Departure) => ( |
| 762 |
<span title={__("Max Capacity", "yatra")}> |
| 763 |
{departure.max_capacity} |
| 764 |
</span> |
| 765 |
), |
| 766 |
}, |
| 767 |
{ |
| 768 |
key: "booked", |
| 769 |
label: __("Booked", "yatra"), |
| 770 |
visible: visibleColumns.booked, |
| 771 |
render: (departure: Departure) => ( |
| 772 |
<span title={__("Booked Count", "yatra")}> |
| 773 |
{departure.booked_count} |
| 774 |
</span> |
| 775 |
), |
| 776 |
}, |
| 777 |
{ |
| 778 |
key: "available", |
| 779 |
label: __("Available", "yatra"), |
| 780 |
visible: visibleColumns.available, |
| 781 |
render: (departure: Departure) => ( |
| 782 |
<span |
| 783 |
className={ |
| 784 |
departure.available_capacity === 0 |
| 785 |
? "text-red-600 font-semibold" |
| 786 |
: "" |
| 787 |
} |
| 788 |
title={__("Available Capacity", "yatra")} |
| 789 |
> |
| 790 |
{departure.available_capacity} |
| 791 |
</span> |
| 792 |
), |
| 793 |
}, |
| 794 |
{ |
| 795 |
key: "revenue", |
| 796 |
label: __("Revenue", "yatra"), |
| 797 |
visible: visibleColumns.revenue, |
| 798 |
render: (departure: Departure) => |
| 799 |
departure.total_revenue !== undefined && |
| 800 |
departure.total_revenue > 0 ? ( |
| 801 |
<span className="font-semibold text-green-600"> |
| 802 |
{formatCurrency(departure.total_revenue)} |
| 803 |
</span> |
| 804 |
) : ( |
| 805 |
<span className="text-gray-400">--</span> |
| 806 |
), |
| 807 |
}, |
| 808 |
{ |
| 809 |
key: "travelers", |
| 810 |
label: __("Travelers", "yatra"), |
| 811 |
visible: visibleColumns.travelers, |
| 812 |
render: (departure: Departure) => ( |
| 813 |
<Button |
| 814 |
variant="outline" |
| 815 |
size="sm" |
| 816 |
onClick={() => openTravelerModal(departure)} |
| 817 |
disabled={!departure.travelers || departure.travelers.length === 0} |
| 818 |
className="px-3 py-1.5" |
| 819 |
> |
| 820 |
{departure.travelers_count || departure.travelers?.length || 0}{" "} |
| 821 |
{__("Traveler(s)", "yatra")} |
| 822 |
</Button> |
| 823 |
), |
| 824 |
}, |
| 825 |
{ |
| 826 |
key: "bookings", |
| 827 |
label: __("Bookings", "yatra"), |
| 828 |
visible: visibleColumns.bookings, |
| 829 |
render: (departure: Departure) => |
| 830 |
departure.booking_ids && departure.booking_ids.length > 0 ? ( |
| 831 |
<div className="flex flex-wrap gap-1"> |
| 832 |
{departure.booking_ids.slice(0, 3).map((bookingId) => ( |
| 833 |
<a |
| 834 |
key={bookingId} |
| 835 |
href={`?page=yatra&subpage=bookings&action=view&id=${bookingId}`} |
| 836 |
className="text-blue-600 hover:text-blue-800 hover:underline text-sm" |
| 837 |
title={__("View booking", "yatra")} |
| 838 |
> |
| 839 |
#{bookingId} |
| 840 |
</a> |
| 841 |
))} |
| 842 |
{departure.booking_ids.length > 3 && ( |
| 843 |
<span className="text-gray-500 text-sm"> |
| 844 |
+{departure.booking_ids.length - 3} |
| 845 |
</span> |
| 846 |
)} |
| 847 |
</div> |
| 848 |
) : ( |
| 849 |
<span className="text-gray-400">--</span> |
| 850 |
), |
| 851 |
}, |
| 852 |
{ |
| 853 |
key: "status", |
| 854 |
label: __("Status", "yatra"), |
| 855 |
visible: visibleColumns.status, |
| 856 |
render: (departure: Departure) => getStatusBadge(departure.status), |
| 857 |
}, |
| 858 |
{ |
| 859 |
key: "source", |
| 860 |
label: __("Source", "yatra"), |
| 861 |
visible: visibleColumns.source, |
| 862 |
render: (departure: Departure) => getSourceBadge(departure.source), |
| 863 |
}, |
| 864 |
], |
| 865 |
[visibleColumns, openTravelerModal], |
| 866 |
); |
| 867 |
|
| 868 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 869 |
const handleMoveToTrash = async (departure: Departure) => { |
| 870 |
const tid = tripIdForDeparture(departure); |
| 871 |
if (!tid) { |
| 872 |
showToast( |
| 873 |
__( |
| 874 |
"Cannot move to trash: missing trip id for this departure.", |
| 875 |
"yatra", |
| 876 |
), |
| 877 |
"error", |
| 878 |
); |
| 879 |
return; |
| 880 |
} |
| 881 |
if ( |
| 882 |
!window.confirm( |
| 883 |
__("Are you sure you want to move this departure to trash?", "yatra"), |
| 884 |
) |
| 885 |
) { |
| 886 |
return; |
| 887 |
} |
| 888 |
try { |
| 889 |
await apiClient.patch(`/trips/${tid}/departures/${departure.id}`, { |
| 890 |
status: "trash", |
| 891 |
}); |
| 892 |
showToast(__("Departure moved to trash.", "yatra"), "success"); |
| 893 |
queryClient.invalidateQueries({ queryKey: ["departures"] }); |
| 894 |
queryClient.invalidateQueries({ queryKey: ["departures-stats"] }); |
| 895 |
} catch (error: any) { |
| 896 |
showToast( |
| 897 |
error?.message || __("Failed to move departure to trash", "yatra"), |
| 898 |
"error", |
| 899 |
); |
| 900 |
} |
| 901 |
}; |
| 902 |
|
| 903 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 904 |
const handleRestore = async (departure: Departure) => { |
| 905 |
const tid = tripIdForDeparture(departure); |
| 906 |
if (!tid) { |
| 907 |
showToast( |
| 908 |
__("Cannot restore: missing trip id for this departure.", "yatra"), |
| 909 |
"error", |
| 910 |
); |
| 911 |
return; |
| 912 |
} |
| 913 |
if ( |
| 914 |
!window.confirm( |
| 915 |
__("Are you sure you want to restore this departure?", "yatra"), |
| 916 |
) |
| 917 |
) { |
| 918 |
return; |
| 919 |
} |
| 920 |
try { |
| 921 |
await apiClient.patch(`/trips/${tid}/departures/${departure.id}`, { |
| 922 |
status: "upcoming", |
| 923 |
}); |
| 924 |
showToast(__("Departure restored.", "yatra"), "success"); |
| 925 |
queryClient.invalidateQueries({ queryKey: ["departures"] }); |
| 926 |
queryClient.invalidateQueries({ queryKey: ["departures-stats"] }); |
| 927 |
} catch (error: any) { |
| 928 |
showToast( |
| 929 |
error?.message || __("Failed to restore departure", "yatra"), |
| 930 |
"error", |
| 931 |
); |
| 932 |
} |
| 933 |
}; |
| 934 |
|
| 935 |
const tableActions = React.useMemo(() => { |
| 936 |
const actions = [ |
| 937 |
{ |
| 938 |
key: "view", |
| 939 |
label: __("View", "yatra"), |
| 940 |
icon: <Eye className="w-4 h-4" />, |
| 941 |
onClick: (departure: Departure) => navigateToView(departure), |
| 942 |
}, |
| 943 |
{ |
| 944 |
key: "edit", |
| 945 |
label: __("Edit", "yatra"), |
| 946 |
icon: <Edit className="w-4 h-4" />, |
| 947 |
onClick: (departure: Departure) => navigateToEdit(departure), |
| 948 |
condition: (departure: Departure) => departure.status !== "trash", |
| 949 |
}, |
| 950 |
]; |
| 951 |
|
| 952 |
if (statusFilter === "trash") { |
| 953 |
actions.push({ |
| 954 |
key: "restore", |
| 955 |
label: __("Restore", "yatra"), |
| 956 |
icon: <RotateCcw className="w-4 h-4" />, |
| 957 |
onClick: (departure: Departure) => handleRestore(departure), |
| 958 |
} as any); |
| 959 |
actions.push({ |
| 960 |
key: "delete", |
| 961 |
label: __("Delete Permanently", "yatra"), |
| 962 |
icon: <Trash2 className="w-4 h-4" />, |
| 963 |
onClick: (departure: Departure) => handleDelete(departure), |
| 964 |
} as any); |
| 965 |
} else { |
| 966 |
actions.push({ |
| 967 |
key: "trash", |
| 968 |
label: __("Move to Trash", "yatra"), |
| 969 |
icon: <Trash2 className="w-4 h-4" />, |
| 970 |
onClick: (departure: Departure) => handleMoveToTrash(departure), |
| 971 |
variant: "destructive" as const, |
| 972 |
} as any); |
| 973 |
} |
| 974 |
|
| 975 |
return actions; |
| 976 |
}, [ |
| 977 |
statusFilter, |
| 978 |
navigateToEdit, |
| 979 |
navigateToView, |
| 980 |
handleDelete, |
| 981 |
handleRestore, |
| 982 |
handleMoveToTrash, |
| 983 |
]); |
| 984 |
|
| 985 |
return ( |
| 986 |
<> |
| 987 |
<div className="space-y-6"> |
| 988 |
<PageHeader |
| 989 |
title={__("Departures", "yatra")} |
| 990 |
description={__("Manage trip departure dates and capacity", "yatra")} |
| 991 |
actions={ |
| 992 |
<div className="flex gap-2"> |
| 993 |
{selectedTripId && ( |
| 994 |
<Button onClick={navigateToAdd} variant="default"> |
| 995 |
<Plus className="w-4 h-4 mr-2" /> |
| 996 |
{__("Add Departure", "yatra")} |
| 997 |
</Button> |
| 998 |
)} |
| 999 |
</div> |
| 1000 |
} |
| 1001 |
/> |
| 1002 |
|
| 1003 |
{/* Trip Selector - Clean Design */} |
| 1004 |
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 rounded-lg border border-blue-200 dark:border-gray-700 p-6"> |
| 1005 |
<div className="flex items-start justify-between gap-4 mb-4"> |
| 1006 |
<div className="flex-1"> |
| 1007 |
<div className="flex items-center gap-2 mb-1"> |
| 1008 |
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400" /> |
| 1009 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 1010 |
{__("Select Trip", "yatra")} |
| 1011 |
</h3> |
| 1012 |
</div> |
| 1013 |
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> |
| 1014 |
{__( |
| 1015 |
"Choose a trip to manage its departures and capacity", |
| 1016 |
"yatra", |
| 1017 |
)} |
| 1018 |
</p> |
| 1019 |
</div> |
| 1020 |
</div> |
| 1021 |
|
| 1022 |
<div className="space-y-4"> |
| 1023 |
<div> |
| 1024 |
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 1025 |
{__("Trip", "yatra")} <span className="text-red-500">*</span> |
| 1026 |
</label> |
| 1027 |
<SearchableSelect |
| 1028 |
value={selectedTripId?.toString() || ""} |
| 1029 |
onChange={(value) => { |
| 1030 |
const tripId = value ? parseInt(value) : null; |
| 1031 |
setSelectedTripId(tripId); |
| 1032 |
setPage(1); |
| 1033 |
}} |
| 1034 |
options={[ |
| 1035 |
{ value: "", label: __("-- Select a Trip --", "yatra") }, |
| 1036 |
...tripsList.map((trip: Trip) => ({ |
| 1037 |
value: trip.id.toString(), |
| 1038 |
label: `${trip.title}${trip.trip_type === "single_day" ? " (Single Day)" : trip.trip_type === "multi_day" ? " (Multi-Day)" : ""}`, |
| 1039 |
})), |
| 1040 |
]} |
| 1041 |
placeholder={__("Search or select a trip...", "yatra")} |
| 1042 |
searchPlaceholder={__("Search by trip name or ID...", "yatra")} |
| 1043 |
className="w-full" |
| 1044 |
required |
| 1045 |
/> |
| 1046 |
</div> |
| 1047 |
|
| 1048 |
{selectedTripId && |
| 1049 |
(() => { |
| 1050 |
const selectedTripData = tripsList.find( |
| 1051 |
(trip: Trip) => trip.id === selectedTripId, |
| 1052 |
); |
| 1053 |
return selectedTripData ? ( |
| 1054 |
<div className="flex items-center gap-4 pt-2 border-t border-blue-200 dark:border-gray-700"> |
| 1055 |
<div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"> |
| 1056 |
<MapPin className="w-4 h-4 text-blue-600 dark:text-blue-400" /> |
| 1057 |
<span className="font-medium"> |
| 1058 |
{selectedTripData.starting_location} |
| 1059 |
</span> |
| 1060 |
<span className="text-gray-400">→</span> |
| 1061 |
<span className="font-medium"> |
| 1062 |
{selectedTripData.ending_location} |
| 1063 |
</span> |
| 1064 |
</div> |
| 1065 |
{selectedTripData.trip_type && ( |
| 1066 |
<Badge |
| 1067 |
className={ |
| 1068 |
selectedTripData.trip_type === "single_day" |
| 1069 |
? "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400" |
| 1070 |
: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400" |
| 1071 |
} |
| 1072 |
> |
| 1073 |
{selectedTripData.trip_type === "single_day" |
| 1074 |
? __("Single Day", "yatra") |
| 1075 |
: __("Multi-Day", "yatra")} |
| 1076 |
</Badge> |
| 1077 |
)} |
| 1078 |
</div> |
| 1079 |
) : null; |
| 1080 |
})()} |
| 1081 |
</div> |
| 1082 |
</div> |
| 1083 |
|
| 1084 |
{/* Filters */} |
| 1085 |
<Card> |
| 1086 |
<CardContent className="pt-6"> |
| 1087 |
<div className="space-y-4"> |
| 1088 |
<div className="flex gap-4 items-end flex-wrap"> |
| 1089 |
<div className="flex-1 min-w-[200px]"> |
| 1090 |
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 1091 |
{__("Search", "yatra")} |
| 1092 |
</label> |
| 1093 |
<div className="relative"> |
| 1094 |
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> |
| 1095 |
<Input |
| 1096 |
type="text" |
| 1097 |
placeholder={__("Search by date or notes...", "yatra")} |
| 1098 |
value={searchTerm} |
| 1099 |
onChange={(e) => { |
| 1100 |
setSearchTerm(e.target.value); |
| 1101 |
setPage(1); |
| 1102 |
}} |
| 1103 |
className="pl-10" |
| 1104 |
/> |
| 1105 |
</div> |
| 1106 |
</div> |
| 1107 |
<div className="w-48"> |
| 1108 |
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 1109 |
{__("Status", "yatra")} |
| 1110 |
</label> |
| 1111 |
<Select |
| 1112 |
value={statusFilter} |
| 1113 |
onChange={(e) => { |
| 1114 |
setStatusFilter(e.target.value as any); |
| 1115 |
setPage(1); |
| 1116 |
}} |
| 1117 |
> |
| 1118 |
<option value="all">{__("All Status", "yatra")}</option> |
| 1119 |
<option value="upcoming">{__("Upcoming", "yatra")}</option> |
| 1120 |
<option value="full">{__("Full", "yatra")}</option> |
| 1121 |
<option value="past">{__("Past", "yatra")}</option> |
| 1122 |
<option value="cancelled"> |
| 1123 |
{__("Cancelled", "yatra")} |
| 1124 |
</option> |
| 1125 |
</Select> |
| 1126 |
</div> |
| 1127 |
<div className="w-48"> |
| 1128 |
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 1129 |
{__("Source", "yatra")} |
| 1130 |
</label> |
| 1131 |
<Select |
| 1132 |
value={sourceFilter} |
| 1133 |
onChange={(e) => { |
| 1134 |
setSourceFilter(e.target.value as any); |
| 1135 |
setPage(1); |
| 1136 |
}} |
| 1137 |
> |
| 1138 |
<option value="all">{__("All Sources", "yatra")}</option> |
| 1139 |
<option value="manual">{__("Manual", "yatra")}</option> |
| 1140 |
<option value="booking_created"> |
| 1141 |
{__("Booking Created", "yatra")} |
| 1142 |
</option> |
| 1143 |
</Select> |
| 1144 |
</div> |
| 1145 |
<div className="w-80"> |
| 1146 |
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 1147 |
{__("Date Range", "yatra")} |
| 1148 |
</label> |
| 1149 |
<DateRangePicker |
| 1150 |
dateFrom={dateFrom} |
| 1151 |
dateTo={dateTo} |
| 1152 |
onDateFromChange={(dateStr) => { |
| 1153 |
setDateFrom(dateStr || ""); |
| 1154 |
setPage(1); |
| 1155 |
}} |
| 1156 |
onDateToChange={(dateStr) => { |
| 1157 |
setDateTo(dateStr || ""); |
| 1158 |
setPage(1); |
| 1159 |
}} |
| 1160 |
onClear={clearDateFilters} |
| 1161 |
placeholder={__("Select date range...", "yatra")} |
| 1162 |
/> |
| 1163 |
</div> |
| 1164 |
</div> |
| 1165 |
</div> |
| 1166 |
</CardContent> |
| 1167 |
</Card> |
| 1168 |
|
| 1169 |
{/* Bulk actions + status tabs */} |
| 1170 |
{!isDeparturesError && ( |
| 1171 |
<BulkActionToolbar |
| 1172 |
selectedIds={selectedIds} |
| 1173 |
bulkAction={bulkAction} |
| 1174 |
setBulkAction={setBulkAction} |
| 1175 |
onApply={handleBulkApply} |
| 1176 |
onClearSelection={() => setSelectedIds([])} |
| 1177 |
statusFilter={statusFilter} |
| 1178 |
setStatusFilter={(filter) => { |
| 1179 |
setStatusFilter(filter as any); |
| 1180 |
setPage(1); |
| 1181 |
}} |
| 1182 |
statusOptions={statusOptions} |
| 1183 |
showColumnsDropdown={showColumnMenu} |
| 1184 |
setShowColumnsDropdown={setShowColumnMenu} |
| 1185 |
columnOptions={columnOptions} |
| 1186 |
onToggleColumn={(key) => |
| 1187 |
toggleColumn(key as keyof typeof visibleColumns) |
| 1188 |
} |
| 1189 |
bulkMutationPending={deleteMutation.isPending} |
| 1190 |
totalItems={departures.length} |
| 1191 |
bulkActionOptions={bulkActionOptions} |
| 1192 |
/> |
| 1193 |
)} |
| 1194 |
|
| 1195 |
{/* Departures Table */} |
| 1196 |
<Card> |
| 1197 |
<CardContent className="pt-6"> |
| 1198 |
<SharedTable |
| 1199 |
data={departures} |
| 1200 |
columns={departureColumns} |
| 1201 |
actions={tableActions} |
| 1202 |
isLoading={isLoading} |
| 1203 |
isError={isDeparturesError} |
| 1204 |
errorText={__("Error loading departures", "yatra")} |
| 1205 |
errorDescription={__( |
| 1206 |
"We couldn't connect to the departures service. Please refresh or try again shortly.", |
| 1207 |
"yatra", |
| 1208 |
)} |
| 1209 |
errorDetails={derivedErrorDetails} |
| 1210 |
errorRequestInfo={errorContext.requestInfo} |
| 1211 |
onRetry={() => refetch()} |
| 1212 |
emptyText={__("No departures found", "yatra")} |
| 1213 |
emptyDescription={__( |
| 1214 |
"Get started by creating your first departure.", |
| 1215 |
"yatra", |
| 1216 |
)} |
| 1217 |
onCreateClick={navigateToAdd} |
| 1218 |
selectedItemIds={selectedIds} |
| 1219 |
onSelectItem={(id, checked) => |
| 1220 |
handleSelectRow(id as number, checked) |
| 1221 |
} |
| 1222 |
onSelectAll={handleSelectAll} |
| 1223 |
isAllSelected={isAllSelected} |
| 1224 |
getItemId={(departure: Departure) => departure.id} |
| 1225 |
getItemStatus={(departure: Departure) => departure.status} |
| 1226 |
capability="yatra_view_trips" |
| 1227 |
/> |
| 1228 |
</CardContent> |
| 1229 |
</Card> |
| 1230 |
|
| 1231 |
{totalItems > 0 && departures.length > 0 && ( |
| 1232 |
<div className="mt-4"> |
| 1233 |
<Pagination |
| 1234 |
currentPage={page} |
| 1235 |
totalPages={totalPages} |
| 1236 |
totalItems={totalItems} |
| 1237 |
itemsPerPage={itemsPerPage} |
| 1238 |
onPageChange={(newPage) => setPage(newPage)} |
| 1239 |
itemName={__("departures", "yatra")} |
| 1240 |
/> |
| 1241 |
</div> |
| 1242 |
)} |
| 1243 |
</div> |
| 1244 |
|
| 1245 |
{travelerModalDeparture && travelerModalDeparture.travelers && ( |
| 1246 |
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> |
| 1247 |
<div className="w-full max-w-3xl rounded-lg bg-white p-6 shadow-xl dark:bg-gray-900"> |
| 1248 |
<div className="mb-4 flex items-start justify-between gap-4"> |
| 1249 |
<div> |
| 1250 |
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100"> |
| 1251 |
{__("Travelers for", "yatra")}{" "} |
| 1252 |
{travelerModalDeparture.trip?.title || __("Trip", "yatra")} |
| 1253 |
</h3> |
| 1254 |
<p className="text-sm text-gray-500 dark:text-gray-400"> |
| 1255 |
{formatDate( |
| 1256 |
travelerModalDeparture.start_date || |
| 1257 |
travelerModalDeparture.date, |
| 1258 |
)} |
| 1259 |
</p> |
| 1260 |
</div> |
| 1261 |
<Button variant="outline" onClick={closeTravelerModal}> |
| 1262 |
{__("Close", "yatra")} |
| 1263 |
</Button> |
| 1264 |
</div> |
| 1265 |
<div className="max-h-[60vh] overflow-y-auto pr-2"> |
| 1266 |
<Table> |
| 1267 |
<TableHeader> |
| 1268 |
<TableRow> |
| 1269 |
<TableHead>{__("Traveler", "yatra")}</TableHead> |
| 1270 |
<TableHead>{__("Lead", "yatra")}</TableHead> |
| 1271 |
<TableHead>{__("Email", "yatra")}</TableHead> |
| 1272 |
<TableHead>{__("Phone", "yatra")}</TableHead> |
| 1273 |
<TableHead>{__("Booking", "yatra")}</TableHead> |
| 1274 |
</TableRow> |
| 1275 |
</TableHeader> |
| 1276 |
<TableBody> |
| 1277 |
{travelerModalDeparture.travelers.map((traveler) => ( |
| 1278 |
<TableRow key={traveler.id}> |
| 1279 |
<TableCell className="font-semibold text-gray-800 dark:text-gray-100"> |
| 1280 |
{traveler.first_name || "—"} {traveler.last_name || ""} |
| 1281 |
</TableCell> |
| 1282 |
<TableCell> |
| 1283 |
{traveler.is_lead ? ( |
| 1284 |
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"> |
| 1285 |
{__("Lead", "yatra")} |
| 1286 |
</Badge> |
| 1287 |
) : ( |
| 1288 |
<span className="text-gray-400">—</span> |
| 1289 |
)} |
| 1290 |
</TableCell> |
| 1291 |
<TableCell>{traveler.email || "—"}</TableCell> |
| 1292 |
<TableCell>{traveler.phone || "—"}</TableCell> |
| 1293 |
<TableCell> |
| 1294 |
<a |
| 1295 |
href={`?page=yatra&subpage=bookings&action=view&id=${traveler.booking_id}`} |
| 1296 |
className="text-blue-600 hover:underline" |
| 1297 |
> |
| 1298 |
{traveler.booking_reference || |
| 1299 |
`#${traveler.booking_id}`} |
| 1300 |
</a> |
| 1301 |
</TableCell> |
| 1302 |
</TableRow> |
| 1303 |
))} |
| 1304 |
</TableBody> |
| 1305 |
</Table> |
| 1306 |
</div> |
| 1307 |
</div> |
| 1308 |
</div> |
| 1309 |
)} |
| 1310 |
</> |
| 1311 |
); |
| 1312 |
}; |
| 1313 |
|
| 1314 |
export default Departures; |
| 1315 |
|