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

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