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

1,315 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 { 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