PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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.6, at resources/js/pages/Departures.tsx

1,314 lines 41.9 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 // eslint-disable-next-line react-hooks/exhaustive-deps
621 const handleDelete = (departure: Departure) => {
622 const tid = tripIdForDeparture(departure);
623 if (!tid) {
624 showToast(
625 __("Cannot delete: missing trip id for this departure.", "yatra"),
626 "error",
627 );
628 return;
629 }
630 if (
631 !confirm(__("Are you sure you want to delete this departure?", "yatra"))
632 ) {
633 return;
634 }
635 deleteMutation.mutate({ id: departure.id, tripId: tid });
636 };
637
638 const isAllSelected =
639 departures.length > 0 && selectedIds.length === departures.length;
640
641 const handleSelectAll = (checked: boolean) => {
642 if (checked) {
643 setSelectedIds(departures.map((d) => d.id));
644 } else {
645 setSelectedIds([]);
646 }
647 };
648
649 const handleSelectRow = (id: number, checked: boolean) => {
650 setSelectedIds((prev) =>
651 checked ? [...prev, id] : prev.filter((existingId) => existingId !== id),
652 );
653 };
654
655 const getStatusBadge = (status: string) => {
656 const statusMap: Record<string, { label: string; className: string }> = {
657 upcoming: {
658 label: __("Upcoming", "yatra"),
659 className:
660 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
661 },
662 full: {
663 label: __("Full", "yatra"),
664 className:
665 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
666 },
667 past: {
668 label: __("Past", "yatra"),
669 className:
670 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
671 },
672 cancelled: {
673 label: __("Cancelled", "yatra"),
674 className:
675 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
676 },
677 trash: {
678 label: __("Trash", "yatra"),
679 className:
680 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
681 },
682 };
683
684 const statusInfo = statusMap[status] || statusMap.upcoming;
685
686 return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
687 };
688
689 const getSourceBadge = (source: string) => {
690 if (source === "manual") {
691 return (
692 <Badge className="bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400">
693 {__("Manual", "yatra")}
694 </Badge>
695 );
696 }
697 return (
698 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
699 {__("Booking Created", "yatra")}
700 </Badge>
701 );
702 };
703
704 const clearDateFilters = () => {
705 setDateFrom("");
706 setDateTo("");
707 };
708
709 // eslint-disable-next-line react-hooks/exhaustive-deps
710 const openTravelerModal = (departure: Departure) => {
711 if (!departure.travelers || !departure.travelers.length) {
712 return;
713 }
714 setTravelerModalDeparture(departure);
715 };
716
717 const closeTravelerModal = () => setTravelerModalDeparture(null);
718
719 const departureColumns = React.useMemo(
720 () => [
721 {
722 key: "trip",
723 label: __("Trip", "yatra"),
724 visible: visibleColumns.trip,
725 render: (departure: Departure) =>
726 departure.trip?.title ? (
727 <a
728 href={`?page=yatra&subpage=trips&action=edit&id=${departure.trip.id}`}
729 className="text-blue-600 hover:text-blue-800 hover:underline"
730 title={__("View trip", "yatra")}
731 >
732 {departure.trip.title}
733 </a>
734 ) : (
735 <span className="text-gray-400">--</span>
736 ),
737 },
738 {
739 key: "date",
740 label: __("Date", "yatra"),
741 visible: visibleColumns.date,
742 render: (departure: Departure) =>
743 formatDate(departure.start_date || departure.date),
744 },
745 {
746 key: "time",
747 label: __("Time", "yatra"),
748 visible: visibleColumns.time,
749 render: (departure: Departure) =>
750 departure.time ? (
751 <span className="font-mono">{departure.time}</span>
752 ) : (
753 <span className="text-gray-400">--</span>
754 ),
755 },
756 {
757 key: "capacity",
758 label: __("Capacity", "yatra"),
759 visible: visibleColumns.capacity,
760 render: (departure: Departure) => (
761 <span title={__("Max Capacity", "yatra")}>
762 {departure.max_capacity}
763 </span>
764 ),
765 },
766 {
767 key: "booked",
768 label: __("Booked", "yatra"),
769 visible: visibleColumns.booked,
770 render: (departure: Departure) => (
771 <span title={__("Booked Count", "yatra")}>
772 {departure.booked_count}
773 </span>
774 ),
775 },
776 {
777 key: "available",
778 label: __("Available", "yatra"),
779 visible: visibleColumns.available,
780 render: (departure: Departure) => (
781 <span
782 className={
783 departure.available_capacity === 0
784 ? "text-red-600 font-semibold"
785 : ""
786 }
787 title={__("Available Capacity", "yatra")}
788 >
789 {departure.available_capacity}
790 </span>
791 ),
792 },
793 {
794 key: "revenue",
795 label: __("Revenue", "yatra"),
796 visible: visibleColumns.revenue,
797 render: (departure: Departure) =>
798 departure.total_revenue !== undefined &&
799 departure.total_revenue > 0 ? (
800 <span className="font-semibold text-green-600">
801 {formatCurrency(departure.total_revenue)}
802 </span>
803 ) : (
804 <span className="text-gray-400">--</span>
805 ),
806 },
807 {
808 key: "travelers",
809 label: __("Travelers", "yatra"),
810 visible: visibleColumns.travelers,
811 render: (departure: Departure) => (
812 <Button
813 variant="outline"
814 size="sm"
815 onClick={() => openTravelerModal(departure)}
816 disabled={!departure.travelers || departure.travelers.length === 0}
817 className="px-3 py-1.5"
818 >
819 {departure.travelers_count || departure.travelers?.length || 0}{" "}
820 {__("Traveler(s)", "yatra")}
821 </Button>
822 ),
823 },
824 {
825 key: "bookings",
826 label: __("Bookings", "yatra"),
827 visible: visibleColumns.bookings,
828 render: (departure: Departure) =>
829 departure.booking_ids && departure.booking_ids.length > 0 ? (
830 <div className="flex flex-wrap gap-1">
831 {departure.booking_ids.slice(0, 3).map((bookingId) => (
832 <a
833 key={bookingId}
834 href={`?page=yatra&subpage=bookings&action=view&id=${bookingId}`}
835 className="text-blue-600 hover:text-blue-800 hover:underline text-sm"
836 title={__("View booking", "yatra")}
837 >
838 #{bookingId}
839 </a>
840 ))}
841 {departure.booking_ids.length > 3 && (
842 <span className="text-gray-500 text-sm">
843 +{departure.booking_ids.length - 3}
844 </span>
845 )}
846 </div>
847 ) : (
848 <span className="text-gray-400">--</span>
849 ),
850 },
851 {
852 key: "status",
853 label: __("Status", "yatra"),
854 visible: visibleColumns.status,
855 render: (departure: Departure) => getStatusBadge(departure.status),
856 },
857 {
858 key: "source",
859 label: __("Source", "yatra"),
860 visible: visibleColumns.source,
861 render: (departure: Departure) => getSourceBadge(departure.source),
862 },
863 ],
864 [visibleColumns, openTravelerModal],
865 );
866
867 // eslint-disable-next-line react-hooks/exhaustive-deps
868 const handleMoveToTrash = async (departure: Departure) => {
869 const tid = tripIdForDeparture(departure);
870 if (!tid) {
871 showToast(
872 __(
873 "Cannot move to trash: missing trip id for this departure.",
874 "yatra",
875 ),
876 "error",
877 );
878 return;
879 }
880 if (
881 !window.confirm(
882 __("Are you sure you want to move this departure to trash?", "yatra"),
883 )
884 ) {
885 return;
886 }
887 try {
888 await apiClient.patch(`/trips/${tid}/departures/${departure.id}`, {
889 status: "trash",
890 });
891 showToast(__("Departure moved to trash.", "yatra"), "success");
892 queryClient.invalidateQueries({ queryKey: ["departures"] });
893 queryClient.invalidateQueries({ queryKey: ["departures-stats"] });
894 } catch (error: any) {
895 showToast(
896 error?.message || __("Failed to move departure to trash", "yatra"),
897 "error",
898 );
899 }
900 };
901
902 // eslint-disable-next-line react-hooks/exhaustive-deps
903 const handleRestore = async (departure: Departure) => {
904 const tid = tripIdForDeparture(departure);
905 if (!tid) {
906 showToast(
907 __("Cannot restore: missing trip id for this departure.", "yatra"),
908 "error",
909 );
910 return;
911 }
912 if (
913 !window.confirm(
914 __("Are you sure you want to restore this departure?", "yatra"),
915 )
916 ) {
917 return;
918 }
919 try {
920 await apiClient.patch(`/trips/${tid}/departures/${departure.id}`, {
921 status: "upcoming",
922 });
923 showToast(__("Departure restored.", "yatra"), "success");
924 queryClient.invalidateQueries({ queryKey: ["departures"] });
925 queryClient.invalidateQueries({ queryKey: ["departures-stats"] });
926 } catch (error: any) {
927 showToast(
928 error?.message || __("Failed to restore departure", "yatra"),
929 "error",
930 );
931 }
932 };
933
934 const tableActions = React.useMemo(() => {
935 const actions = [
936 {
937 key: "view",
938 label: __("View", "yatra"),
939 icon: <Eye className="w-4 h-4" />,
940 onClick: (departure: Departure) => navigateToView(departure),
941 },
942 {
943 key: "edit",
944 label: __("Edit", "yatra"),
945 icon: <Edit className="w-4 h-4" />,
946 onClick: (departure: Departure) => navigateToEdit(departure),
947 condition: (departure: Departure) => departure.status !== "trash",
948 },
949 ];
950
951 if (statusFilter === "trash") {
952 actions.push({
953 key: "restore",
954 label: __("Restore", "yatra"),
955 icon: <RotateCcw className="w-4 h-4" />,
956 onClick: (departure: Departure) => handleRestore(departure),
957 } as any);
958 actions.push({
959 key: "delete",
960 label: __("Delete Permanently", "yatra"),
961 icon: <Trash2 className="w-4 h-4" />,
962 onClick: (departure: Departure) => handleDelete(departure),
963 } as any);
964 } else {
965 actions.push({
966 key: "trash",
967 label: __("Move to Trash", "yatra"),
968 icon: <Trash2 className="w-4 h-4" />,
969 onClick: (departure: Departure) => handleMoveToTrash(departure),
970 variant: "destructive" as const,
971 } as any);
972 }
973
974 return actions;
975 }, [
976 statusFilter,
977 navigateToEdit,
978 navigateToView,
979 handleDelete,
980 handleRestore,
981 handleMoveToTrash,
982 ]);
983
984 return (
985 <>
986 <div className="space-y-6">
987 <PageHeader
988 title={__("Departures", "yatra")}
989 description={__("Manage trip departure dates and capacity", "yatra")}
990 actions={
991 <div className="flex gap-2">
992 {selectedTripId && (
993 <Button onClick={navigateToAdd} variant="default">
994 <Plus className="w-4 h-4 mr-2" />
995 {__("Add Departure", "yatra")}
996 </Button>
997 )}
998 </div>
999 }
1000 />
1001
1002 {/* Trip Selector - Clean Design */}
1003 <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">
1004 <div className="flex items-start justify-between gap-4 mb-4">
1005 <div className="flex-1">
1006 <div className="flex items-center gap-2 mb-1">
1007 <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400" />
1008 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1009 {__("Select Trip", "yatra")}
1010 </h3>
1011 </div>
1012 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1013 {__(
1014 "Choose a trip to manage its departures and capacity",
1015 "yatra",
1016 )}
1017 </p>
1018 </div>
1019 </div>
1020
1021 <div className="space-y-4">
1022 <div>
1023 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1024 {__("Trip", "yatra")} <span className="text-red-500">*</span>
1025 </label>
1026 <SearchableSelect
1027 value={selectedTripId?.toString() || ""}
1028 onChange={(value) => {
1029 const tripId = value ? parseInt(value) : null;
1030 setSelectedTripId(tripId);
1031 setPage(1);
1032 }}
1033 options={[
1034 { value: "", label: __("-- Select a Trip --", "yatra") },
1035 ...tripsList.map((trip: Trip) => ({
1036 value: trip.id.toString(),
1037 label: `${trip.title}${trip.trip_type === "single_day" ? " (Single Day)" : trip.trip_type === "multi_day" ? " (Multi-Day)" : ""}`,
1038 })),
1039 ]}
1040 placeholder={__("Search or select a trip...", "yatra")}
1041 searchPlaceholder={__("Search by trip name or ID...", "yatra")}
1042 className="w-full"
1043 required
1044 />
1045 </div>
1046
1047 {selectedTripId &&
1048 (() => {
1049 const selectedTripData = tripsList.find(
1050 (trip: Trip) => trip.id === selectedTripId,
1051 );
1052 return selectedTripData ? (
1053 <div className="flex items-center gap-4 pt-2 border-t border-blue-200 dark:border-gray-700">
1054 <div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
1055 <MapPin className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1056 <span className="font-medium">
1057 {selectedTripData.starting_location}
1058 </span>
1059 <span className="text-gray-400">→</span>
1060 <span className="font-medium">
1061 {selectedTripData.ending_location}
1062 </span>
1063 </div>
1064 {selectedTripData.trip_type && (
1065 <Badge
1066 className={
1067 selectedTripData.trip_type === "single_day"
1068 ? "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
1069 : "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400"
1070 }
1071 >
1072 {selectedTripData.trip_type === "single_day"
1073 ? __("Single Day", "yatra")
1074 : __("Multi-Day", "yatra")}
1075 </Badge>
1076 )}
1077 </div>
1078 ) : null;
1079 })()}
1080 </div>
1081 </div>
1082
1083 {/* Filters */}
1084 <Card>
1085 <CardContent className="pt-6">
1086 <div className="space-y-4">
1087 <div className="flex gap-4 items-end flex-wrap">
1088 <div className="flex-1 min-w-[200px]">
1089 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1090 {__("Search", "yatra")}
1091 </label>
1092 <div className="relative">
1093 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
1094 <Input
1095 type="text"
1096 placeholder={__("Search by date or notes...", "yatra")}
1097 value={searchTerm}
1098 onChange={(e) => {
1099 setSearchTerm(e.target.value);
1100 setPage(1);
1101 }}
1102 className="pl-10"
1103 />
1104 </div>
1105 </div>
1106 <div className="w-48">
1107 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1108 {__("Status", "yatra")}
1109 </label>
1110 <Select
1111 value={statusFilter}
1112 onChange={(e) => {
1113 setStatusFilter(e.target.value as any);
1114 setPage(1);
1115 }}
1116 >
1117 <option value="all">{__("All Status", "yatra")}</option>
1118 <option value="upcoming">{__("Upcoming", "yatra")}</option>
1119 <option value="full">{__("Full", "yatra")}</option>
1120 <option value="past">{__("Past", "yatra")}</option>
1121 <option value="cancelled">
1122 {__("Cancelled", "yatra")}
1123 </option>
1124 </Select>
1125 </div>
1126 <div className="w-48">
1127 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1128 {__("Source", "yatra")}
1129 </label>
1130 <Select
1131 value={sourceFilter}
1132 onChange={(e) => {
1133 setSourceFilter(e.target.value as any);
1134 setPage(1);
1135 }}
1136 >
1137 <option value="all">{__("All Sources", "yatra")}</option>
1138 <option value="manual">{__("Manual", "yatra")}</option>
1139 <option value="booking_created">
1140 {__("Booking Created", "yatra")}
1141 </option>
1142 </Select>
1143 </div>
1144 <div className="w-80">
1145 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1146 {__("Date Range", "yatra")}
1147 </label>
1148 <DateRangePicker
1149 dateFrom={dateFrom}
1150 dateTo={dateTo}
1151 onDateFromChange={(dateStr) => {
1152 setDateFrom(dateStr || "");
1153 setPage(1);
1154 }}
1155 onDateToChange={(dateStr) => {
1156 setDateTo(dateStr || "");
1157 setPage(1);
1158 }}
1159 onClear={clearDateFilters}
1160 placeholder={__("Select date range...", "yatra")}
1161 />
1162 </div>
1163 </div>
1164 </div>
1165 </CardContent>
1166 </Card>
1167
1168 {/* Bulk actions + status tabs */}
1169 {!isDeparturesError && (
1170 <BulkActionToolbar
1171 selectedIds={selectedIds}
1172 bulkAction={bulkAction}
1173 setBulkAction={setBulkAction}
1174 onApply={handleBulkApply}
1175 onClearSelection={() => setSelectedIds([])}
1176 statusFilter={statusFilter}
1177 setStatusFilter={(filter) => {
1178 setStatusFilter(filter as any);
1179 setPage(1);
1180 }}
1181 statusOptions={statusOptions}
1182 showColumnsDropdown={showColumnMenu}
1183 setShowColumnsDropdown={setShowColumnMenu}
1184 columnOptions={columnOptions}
1185 onToggleColumn={(key) =>
1186 toggleColumn(key as keyof typeof visibleColumns)
1187 }
1188 bulkMutationPending={deleteMutation.isPending}
1189 totalItems={departures.length}
1190 bulkActionOptions={bulkActionOptions}
1191 />
1192 )}
1193
1194 {/* Departures Table */}
1195 <Card>
1196 <CardContent className="pt-6">
1197 <SharedTable
1198 data={departures}
1199 columns={departureColumns}
1200 actions={tableActions}
1201 isLoading={isLoading}
1202 isError={isDeparturesError}
1203 errorText={__("Error loading departures", "yatra")}
1204 errorDescription={__(
1205 "We couldn't connect to the departures service. Please refresh or try again shortly.",
1206 "yatra",
1207 )}
1208 errorDetails={derivedErrorDetails}
1209 errorRequestInfo={errorContext.requestInfo}
1210 onRetry={() => refetch()}
1211 emptyText={__("No departures found", "yatra")}
1212 emptyDescription={__(
1213 "Get started by creating your first departure.",
1214 "yatra",
1215 )}
1216 onCreateClick={navigateToAdd}
1217 selectedItemIds={selectedIds}
1218 onSelectItem={(id, checked) =>
1219 handleSelectRow(id as number, checked)
1220 }
1221 onSelectAll={handleSelectAll}
1222 isAllSelected={isAllSelected}
1223 getItemId={(departure: Departure) => departure.id}
1224 getItemStatus={(departure: Departure) => departure.status}
1225 capability="yatra_view_trips"
1226 />
1227 </CardContent>
1228 </Card>
1229
1230 {totalItems > 0 && departures.length > 0 && (
1231 <div className="mt-4">
1232 <Pagination
1233 currentPage={page}
1234 totalPages={totalPages}
1235 totalItems={totalItems}
1236 itemsPerPage={itemsPerPage}
1237 onPageChange={(newPage) => setPage(newPage)}
1238 itemName={__("departures", "yatra")}
1239 />
1240 </div>
1241 )}
1242 </div>
1243
1244 {travelerModalDeparture && travelerModalDeparture.travelers && (
1245 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
1246 <div className="w-full max-w-3xl rounded-lg bg-white p-6 shadow-xl dark:bg-gray-900">
1247 <div className="mb-4 flex items-start justify-between gap-4">
1248 <div>
1249 <h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
1250 {__("Travelers for", "yatra")}{" "}
1251 {travelerModalDeparture.trip?.title || __("Trip", "yatra")}
1252 </h3>
1253 <p className="text-sm text-gray-500 dark:text-gray-400">
1254 {formatDate(
1255 travelerModalDeparture.start_date ||
1256 travelerModalDeparture.date,
1257 )}
1258 </p>
1259 </div>
1260 <Button variant="outline" onClick={closeTravelerModal}>
1261 {__("Close", "yatra")}
1262 </Button>
1263 </div>
1264 <div className="max-h-[60vh] overflow-y-auto pr-2">
1265 <Table>
1266 <TableHeader>
1267 <TableRow>
1268 <TableHead>{__("Traveler", "yatra")}</TableHead>
1269 <TableHead>{__("Lead", "yatra")}</TableHead>
1270 <TableHead>{__("Email", "yatra")}</TableHead>
1271 <TableHead>{__("Phone", "yatra")}</TableHead>
1272 <TableHead>{__("Booking", "yatra")}</TableHead>
1273 </TableRow>
1274 </TableHeader>
1275 <TableBody>
1276 {travelerModalDeparture.travelers.map((traveler) => (
1277 <TableRow key={traveler.id}>
1278 <TableCell className="font-semibold text-gray-800 dark:text-gray-100">
1279 {traveler.first_name || "—"} {traveler.last_name || ""}
1280 </TableCell>
1281 <TableCell>
1282 {traveler.is_lead ? (
1283 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
1284 {__("Lead", "yatra")}
1285 </Badge>
1286 ) : (
1287 <span className="text-gray-400">—</span>
1288 )}
1289 </TableCell>
1290 <TableCell>{traveler.email || "—"}</TableCell>
1291 <TableCell>{traveler.phone || "—"}</TableCell>
1292 <TableCell>
1293 <a
1294 href={`?page=yatra&subpage=bookings&action=view&id=${traveler.booking_id}`}
1295 className="text-blue-600 hover:underline"
1296 >
1297 {traveler.booking_reference ||
1298 `#${traveler.booking_id}`}
1299 </a>
1300 </TableCell>
1301 </TableRow>
1302 ))}
1303 </TableBody>
1304 </Table>
1305 </div>
1306 </div>
1307 </div>
1308 )}
1309 </>
1310 );
1311 };
1312
1313 export default Departures;
1314