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

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