PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / Departures.tsx

Departures.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.8, at resources/js/pages/Departures.tsx

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