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

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