PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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 / Availability.tsx

Availability.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.8, at resources/js/pages/Availability.tsx

1,867 lines 68.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Availability Management Page
3 * Manage trip availability dates separately from trip packages
4 * Allows adding/editing dates for trips without modifying the trip itself
5 */
6
7 import React, { useState, useMemo, useEffect } from "react";
8 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
9 import {
10 CalendarDays,
11 Plus,
12 Search,
13 X,
14 Edit,
15 Copy,
16 Trash2,
17 MapPin,
18 Users,
19 AlertCircle,
20 List,
21 Calendar,
22 RefreshCw,
23 Bell,
24 Ban,
25 Clock,
26 Eye,
27 Settings,
28 } from "lucide-react";
29 import { __ } from "../lib/i18n";
30 import { Button } from "../components/ui/button";
31 import { Input } from "../components/ui/input";
32 import { Select } from "../components/ui/select";
33 import { SearchableSelect } from "../components/ui/searchable-select";
34 import { PageHeader } from "../components/common/PageHeader";
35 import {
36 Card,
37 CardContent,
38 CardHeader,
39 CardTitle,
40 CardDescription,
41 } from "../components/ui/card";
42 import { Badge } from "../components/ui/badge";
43 import { Alert } from "../components/ui/alert";
44 import { useNavigate } from "../hooks/useNavigate";
45 import { AvailabilityCalendar } from "../components/availability/AvailabilityCalendar";
46 import { RecurringRules } from "../components/availability/RecurringRules";
47 import { apiClient } from "../lib/api-client";
48 import { getErrorContext } from "../lib/errors";
49 import { useToast } from "../components/ui/toast";
50 import { BulkActionToolbar, Table as SharedTable } from "../components/shared";
51 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
52 import { DatePicker } from "../components/ui/date-picker";
53 import { TimePicker } from "../components/ui/time-picker";
54
55 interface Trip {
56 id: number;
57 title: string;
58 slug: string;
59 currency: string;
60 starting_location?: string;
61 ending_location?: string;
62 trip_type?: "single_day" | "multi_day";
63 }
64
65 interface AvailabilityDate {
66 id: string;
67 trip_id: number;
68 departure_date: string;
69 departure_time?: string;
70 arrival_date: string;
71 arrival_time?: string;
72 total_seats: number; // Total capacity
73 booked_seats: number; // Currently booked
74 available_seats: number; // Calculated: total_seats - booked_seats
75 waitlist_count: number; // Number of people on waitlist
76 seats_remaining: string; // Legacy field for display
77 original_price: string;
78 discounted_price: string;
79 discount_percentage: string;
80 // Per-category pricing for traveler-based trips (adult/child/senior, etc.).
81 // When present, the single original/discounted columns are NULL.
82 price_types?: Array<{
83 category_id: number;
84 category_label?: string;
85 original_price: number;
86 discounted_price?: number | null;
87 sale_price?: number | null;
88 }>;
89 status:
90 | "available"
91 | "sold_out"
92 | "limited"
93 | "closed"
94 | "blocked"
95 | "cancelled";
96 from_location?: string;
97 to_location?: string;
98 is_blocked?: boolean; // Blocked for maintenance/holidays
99 block_reason?: string; // Reason for blocking
100 alert_threshold?: number; // Alert when seats drop below this
101 last_synced_at?: string; // Last real-time sync timestamp
102 created_at?: string;
103 updated_at?: string;
104 }
105
106 function mapApiRowToTrip(trip: Record<string, unknown>): Trip {
107 return {
108 id: Number(trip.id),
109 title: String(trip.title ?? ""),
110 slug: String(trip.slug ?? ""),
111 currency: String(trip.currency || "USD"),
112 starting_location: trip.starting_location as string | undefined,
113 ending_location: trip.ending_location as string | undefined,
114 trip_type: (trip.trip_type as Trip["trip_type"]) || "multi_day",
115 };
116 }
117
118 /**
119 * Normalize /trips payloads and stale TanStack Query cache (client nav can reuse envelopes without `trips`).
120 */
121 function tripsFromAvailabilityQueryCached(raw: unknown): Trip[] {
122 if (raw == null) {
123 return [];
124 }
125 if (Array.isArray(raw)) {
126 return raw as Trip[];
127 }
128 if (typeof raw === "object") {
129 const o = raw as Record<string, unknown>;
130 if (Array.isArray(o.trips)) {
131 return o.trips as Trip[];
132 }
133 let rows: unknown[] = [];
134 if (Array.isArray(o.data)) {
135 rows = o.data as unknown[];
136 } else if (
137 o.data &&
138 typeof o.data === "object" &&
139 Array.isArray((o.data as Record<string, unknown>).data)
140 ) {
141 rows = (o.data as Record<string, unknown>).data as unknown[];
142 }
143 return rows.map((t) => mapApiRowToTrip(t as Record<string, unknown>));
144 }
145 return [];
146 }
147
148 const Availability: React.FC = () => {
149 const { navigate } = useNavigate();
150 const queryClient = useQueryClient();
151 const { showToast } = useToast();
152
153 // Get trip_id from URL on mount and when URL changes
154 const [urlKey, setUrlKey] = useState(0);
155 useEffect(() => {
156 const handleLocationChange = () => {
157 setUrlKey((prev) => prev + 1);
158 };
159
160 // Listen for popstate (back/forward button)
161 window.addEventListener("popstate", handleLocationChange);
162
163 // Also check periodically (fallback for direct navigation)
164 const interval = setInterval(() => {
165 const currentSearch = window.location.search;
166 if (currentSearch !== (window as any).__lastAvailabilitySearch) {
167 (window as any).__lastAvailabilitySearch = currentSearch;
168 handleLocationChange();
169 }
170 }, 100);
171
172 return () => {
173 window.removeEventListener("popstate", handleLocationChange);
174 clearInterval(interval);
175 };
176 }, []);
177
178 // Read trip_id from URL
179 const tripIdFromUrl = useMemo(() => {
180 const params = new URLSearchParams(window.location.search);
181 const tripId = params.get("trip_id");
182 return tripId ? parseInt(tripId) : null;
183 // eslint-disable-next-line react-hooks/exhaustive-deps
184 }, [urlKey]);
185
186 // Trip selection - initialize from localStorage or URL if available
187 const [selectedTripId, setSelectedTripId] = useState<number | null>(() => {
188 const storedTripId = localStorage.getItem("yatra_selected_trip_id");
189 if (storedTripId) {
190 const parsed = parseInt(storedTripId, 10);
191 if (!Number.isNaN(parsed)) {
192 return parsed;
193 }
194 }
195 return tripIdFromUrl;
196 });
197
198 // When URL trip_id changes, prefer it over stored value
199 useEffect(() => {
200 if (tripIdFromUrl && tripIdFromUrl !== selectedTripId) {
201 setSelectedTripId(tripIdFromUrl);
202 localStorage.setItem("yatra_selected_trip_id", tripIdFromUrl.toString());
203 }
204 }, [tripIdFromUrl, selectedTripId]);
205
206 // Persist current selection
207 useEffect(() => {
208 if (selectedTripId) {
209 localStorage.setItem("yatra_selected_trip_id", selectedTripId.toString());
210 } else {
211 localStorage.removeItem("yatra_selected_trip_id");
212 }
213 }, [selectedTripId]);
214
215 // View mode: 'list' or 'calendar'
216 const [viewMode, setViewMode] = useState<"list" | "calendar">("list");
217
218 // Tab mode: 'specific' or 'recurring'
219 const [tabMode, _setTabMode] = useState<"specific" | "recurring">(() => {
220 const params = new URLSearchParams(window.location.search);
221 const tabModeParam = params.get("tab_mode");
222 return tabModeParam === "recurring" ? "recurring" : "specific";
223 });
224
225 const setTabMode = (mode: "specific" | "recurring") => {
226 _setTabMode(mode);
227 const params = new URLSearchParams(window.location.search);
228 params.set("tab_mode", mode);
229 const newUrl = `${window.location.pathname}?${params.toString()}`;
230 window.history.replaceState({}, "", newUrl);
231 };
232
233 // Filters
234 const [searchTerm, setSearchTerm] = useState("");
235 const [statusFilter, setStatusFilter] = useState("all");
236 const [monthFilter, setMonthFilter] = useState<string>("all");
237
238 // Real-time sync
239 const [isSyncing, setIsSyncing] = useState(false);
240 const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
241
242 // Inventory alerts visibility
243 const [showAlerts, setShowAlerts] = useState(true);
244
245 // Pagination
246 const [page, setPage] = useState(1);
247
248 // Bulk selection & columns state
249 const [selectedIds, setSelectedIds] = useState<string[]>([]);
250 const [bulkAction, setBulkAction] = useState("");
251 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
252 const [visibleColumns, setVisibleColumns] = useState({
253 departure: true,
254 arrival: true,
255 locations: true,
256 capacity: true,
257 booked: true,
258 available: true,
259 waitlist: true,
260 price: true,
261 status: true, // Always show status column
262 });
263
264 const toggleColumn = (key: keyof typeof visibleColumns) => {
265 setVisibleColumns((prev) => ({
266 ...prev,
267 [key]: !prev[key],
268 }));
269 };
270
271 // Fetch all trips for dropdown — store as Trip[]; normalize stale cache via tripsList
272 const { data: tripsData } = useQuery({
273 queryKey: ["trips", "availability-dropdown", "v2"],
274 queryFn: async () => {
275 const response = await apiClient.get("/trips", {
276 params: { per_page: 100, status: "publish" },
277 });
278 return tripsFromAvailabilityQueryCached(response);
279 },
280 });
281
282 const tripsList = useMemo(
283 () => tripsFromAvailabilityQueryCached(tripsData),
284 [tripsData],
285 );
286
287 // Fetch availability dates for selected trip
288 const {
289 data: availabilityData,
290 isLoading,
291 error: availabilityError,
292 } = useQuery({
293 queryKey: ["availability", selectedTripId, monthFilter, page, searchTerm],
294 queryFn: async () => {
295 if (!selectedTripId) return { dates: [], total: 0 };
296
297 const response = await apiClient.get("/availability", {
298 params: {
299 trip_id: selectedTripId,
300 month: monthFilter !== "all" ? monthFilter : undefined,
301 search: searchTerm || undefined,
302 page,
303 per_page: 50,
304 },
305 });
306
307 return {
308 dates: (response?.dates || []).map((date: any) => ({
309 id: date.id.toString(),
310 trip_id: date.trip_id,
311 departure_date: date.departure_date,
312 departure_time: date.departure_time,
313 arrival_date: date.arrival_date || date.departure_date,
314 arrival_time: date.arrival_time,
315 total_seats: date.total_seats || date.seats_total || 0,
316 booked_seats:
317 date.booked_seats ||
318 (date.total_seats || date.seats_total) -
319 (date.available_seats || date.seats_available || 0),
320 available_seats: date.available_seats || date.seats_available || 0,
321 waitlist_count: date.waitlist_count || date.seats_waitlist || 0,
322 seats_remaining:
323 (date.available_seats || date.seats_available || 0) > 10
324 ? "10+"
325 : (date.available_seats || date.seats_available || 0).toString(),
326 original_price: date.original_price?.toString() || "0",
327 discounted_price:
328 date.discounted_price?.toString() ||
329 date.original_price?.toString() ||
330 "0",
331 discount_percentage: date.discount_percentage?.toString() || "0",
332 // Carry per-category pricing through so the Price column can show the
333 // real (traveler-based) price; otherwise it falls back to "0".
334 price_types: Array.isArray(date.price_types) ? date.price_types : [],
335 status: date.status || "available",
336 is_blocked: date.is_blocked || date.status === "blocked",
337 block_reason: date.block_reason,
338 alert_threshold: date.alert_threshold || 5,
339 last_synced_at: date.last_synced_at || date.updated_at,
340 from_location: date.from_location,
341 to_location: date.to_location,
342 created_at: date.created_at,
343 updated_at: date.updated_at,
344 })) as AvailabilityDate[],
345 total: response?.total || 0,
346 };
347 },
348 enabled: !!selectedTripId,
349 });
350
351 // Delete availability date
352 const deleteMutation = useMutation({
353 mutationFn: async (id: string) => {
354 return await apiClient.delete(`/availability/${id}`);
355 },
356 onSuccess: () => {
357 queryClient.invalidateQueries({ queryKey: ["availability"] });
358 showToast(
359 __("Availability date deleted successfully", "yatra"),
360 "success",
361 );
362 setDeleteConfirm({ isOpen: false, date: null });
363 },
364 onError: (error: any) => {
365 showToast(
366 error?.message || __("Failed to delete availability date", "yatra"),
367 "error",
368 );
369 },
370 });
371
372 // Format date for display
373 // eslint-disable-next-line react-hooks/exhaustive-deps
374 const formatDate = (dateString: string) => {
375 const date = new Date(dateString);
376 return date.toLocaleDateString("en-US", {
377 year: "numeric",
378 month: "short",
379 day: "numeric",
380 });
381 };
382
383 // Format time for display
384 // eslint-disable-next-line react-hooks/exhaustive-deps
385 const formatTime = (timeString: string) => {
386 if (!timeString) return "";
387 const [hours, minutes] = timeString.split(":");
388 const hour = parseInt(hours);
389 const ampm = hour >= 12 ? "PM" : "AM";
390 const displayHour = hour % 12 || 12;
391 return `${displayHour}:${minutes} ${ampm}`;
392 };
393
394 // Get currency symbol
395 // eslint-disable-next-line react-hooks/exhaustive-deps
396 const getCurrencySymbol = (currency: string) => {
397 const symbols: Record<string, string> = {
398 USD: "$",
399 EUR: "€",
400 GBP: "£",
401 INR: "₹",
402 };
403 return symbols[currency] || currency;
404 };
405
406 // Get status badge variant
407 // eslint-disable-next-line react-hooks/exhaustive-deps
408 const getStatusBadge = (status: string, isBlocked?: boolean) => {
409 // Debug: Log the status values to help with debugging
410
411 if (isBlocked) {
412 return (
413 <Badge className="text-xs bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400">
414 {__("Blocked", "yatra")}
415 </Badge>
416 );
417 }
418 switch (status) {
419 case "available":
420 return (
421 <Badge
422 variant="success"
423 className="text-xs bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"
424 >
425 {__("Available", "yatra")}
426 </Badge>
427 );
428 case "limited":
429 return (
430 <Badge
431 variant="warning"
432 className="text-xs bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400"
433 >
434 {__("Limited", "yatra")}
435 </Badge>
436 );
437 case "sold_out":
438 return (
439 <Badge
440 variant="error"
441 className="text-xs bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400"
442 >
443 {__("Sold Out", "yatra")}
444 </Badge>
445 );
446 case "closed":
447 return (
448 <Badge
449 variant="outline"
450 className="text-xs border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400"
451 >
452 {__("Closed", "yatra")}
453 </Badge>
454 );
455 case "blocked":
456 return (
457 <Badge className="text-xs bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400">
458 {__("Blocked", "yatra")}
459 </Badge>
460 );
461 case "cancelled":
462 return (
463 <Badge className="text-xs bg-gray-100 text-gray-700 dark:bg-gray-900/20 dark:text-gray-400">
464 {__("Cancelled", "yatra")}
465 </Badge>
466 );
467 default:
468 return (
469 <Badge
470 variant="outline"
471 className="text-xs border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-400"
472 >
473 {status || "Unknown"}
474 </Badge>
475 );
476 }
477 };
478
479 // Real-time sync function
480 const handleSync = async () => {
481 setIsSyncing(true);
482 try {
483 // Refresh availability data
484 await queryClient.invalidateQueries({ queryKey: ["availability"] });
485 setLastSyncTime(new Date());
486 showToast(
487 __("Availability data synced successfully", "yatra"),
488 "success",
489 );
490 } catch (error) {
491 console.error("Sync failed:", error);
492 showToast(__("Failed to sync availability data", "yatra"), "error");
493 } finally {
494 setIsSyncing(false);
495 }
496 };
497
498 const selectedTrip = tripsList.find((t) => t.id === selectedTripId);
499 const availabilityErrorContext = availabilityError
500 ? getErrorContext(availabilityError)
501 : null;
502
503 // Filter availability dates
504 const filteredDates = useMemo(() => {
505 if (!availabilityData?.dates) return [];
506
507 let filtered = [...availabilityData.dates];
508
509 // Filter by search term (date range or time)
510 if (searchTerm) {
511 const searchLower = searchTerm.toLowerCase();
512 filtered = filtered.filter(
513 (date) =>
514 date.departure_date.includes(searchLower) ||
515 date.arrival_date.includes(searchLower) ||
516 date.departure_time?.toLowerCase().includes(searchLower) ||
517 date.arrival_time?.toLowerCase().includes(searchLower) ||
518 date.from_location?.toLowerCase().includes(searchLower) ||
519 date.to_location?.toLowerCase().includes(searchLower),
520 );
521 }
522
523 // Filter by status
524 if (statusFilter !== "all") {
525 filtered = filtered.filter((date) => date.status === statusFilter);
526 }
527
528 // Filter by month (only for multi-day trips)
529 if (monthFilter !== "all" && selectedTrip?.trip_type !== "single_day") {
530 const [year, month] = monthFilter.split("-");
531 filtered = filtered.filter((date) => {
532 const dateObj = new Date(date.departure_date);
533 return (
534 dateObj.getFullYear() === parseInt(year) &&
535 dateObj.getMonth() === parseInt(month) - 1
536 );
537 });
538 }
539
540 return filtered;
541 }, [availabilityData, searchTerm, statusFilter, monthFilter, selectedTrip]);
542
543 // Get available months for filter (only for multi-day trips)
544 const availableMonths = useMemo(() => {
545 if (!availabilityData?.dates || selectedTrip?.trip_type === "single_day")
546 return [];
547
548 const months = new Set<string>();
549 availabilityData.dates.forEach((date) => {
550 const dateObj = new Date(date.departure_date);
551 const monthKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}`;
552 months.add(monthKey);
553 });
554
555 return Array.from(months).sort();
556 }, [availabilityData, selectedTrip]);
557
558 // Calculate inventory alerts (after filteredDates is defined)
559 const inventoryAlerts = useMemo(() => {
560 if (!filteredDates || filteredDates.length === 0) return [];
561 return filteredDates.filter((date) => {
562 if (date.is_blocked) return false;
563 const threshold = date.alert_threshold || 5;
564 return date.available_seats <= threshold && date.available_seats > 0;
565 });
566 }, [filteredDates]);
567
568 // Get waitlist entries
569 const waitlistEntries = useMemo(() => {
570 if (!filteredDates || filteredDates.length === 0) return [];
571 return filteredDates.filter((date) => date.waitlist_count > 0);
572 }, [filteredDates]);
573
574 // Get blocked dates
575 const blockedDates = useMemo(() => {
576 if (!filteredDates || filteredDates.length === 0) return [];
577 return filteredDates.filter((date) => date.is_blocked);
578 }, [filteredDates]);
579
580 // Status counts for bulk toolbar
581 const statusCounts = useMemo(() => {
582 const counts = {
583 all: 0,
584 available: 0,
585 limited: 0,
586 sold_out: 0,
587 closed: 0,
588 blocked: 0,
589 };
590
591 if (!availabilityData?.dates) return counts;
592
593 availabilityData.dates.forEach((date) => {
594 counts.all++;
595 if (date.status === "available") counts.available++;
596 else if (date.status === "limited") counts.limited++;
597 else if (date.status === "sold_out") counts.sold_out++;
598 else if (date.status === "closed") counts.closed++;
599 else if (date.status === "blocked") counts.blocked++;
600 });
601
602 return counts;
603 }, [availabilityData]);
604
605 // Bulk delete mutation
606 const bulkDeleteMutation = useMutation({
607 mutationFn: async (ids: string[]) => {
608 await Promise.all(
609 ids.map((id) => apiClient.delete(`/availability/${id}`)),
610 );
611 },
612 onSuccess: () => {
613 queryClient.invalidateQueries({ queryKey: ["availability"] });
614 showToast(
615 __("Selected availability dates deleted successfully", "yatra"),
616 "success",
617 );
618 setSelectedIds([]);
619 setBulkAction("");
620 },
621 onError: (error: any) => {
622 showToast(
623 error?.message ||
624 __("Failed to delete selected availability dates", "yatra"),
625 "error",
626 );
627 },
628 });
629
630 const handleBulkApply = () => {
631 if (!bulkAction) {
632 showToast(__("Select a bulk action first.", "yatra"), "warning");
633 return;
634 }
635
636 if (selectedIds.length === 0) {
637 showToast(
638 __("Select at least one availability date.", "yatra"),
639 "warning",
640 );
641 return;
642 }
643
644 if (bulkAction === "delete") {
645 bulkDeleteMutation.mutate(selectedIds);
646 } else {
647 showToast(
648 __("Unsupported bulk action for availability.", "yatra"),
649 "warning",
650 );
651 }
652 };
653
654 const bulkStatusOptions = [
655 { value: "delete", label: __("Delete Permanently", "yatra") },
656 ];
657
658 const totalFiltered = filteredDates.length;
659
660 // Single delete confirmation state
661 const [deleteConfirm, setDeleteConfirm] = useState<{
662 isOpen: boolean;
663 date: AvailabilityDate | null;
664 }>({
665 isOpen: false,
666 date: null,
667 });
668
669 const [duplicateConfirm, setDuplicateConfirm] = useState<{
670 isOpen: boolean;
671 date: AvailabilityDate | null;
672 }>({
673 isOpen: false,
674 date: null,
675 });
676
677 const [duplicateDepartureDate, setDuplicateDepartureDate] =
678 useState<string>("");
679 const [duplicateDepartureTime, setDuplicateDepartureTime] =
680 useState<string>("");
681
682 const duplicateMutation = useMutation({
683 mutationFn: async (payload: {
684 id: string;
685 departure_date: string;
686 departure_time?: string | null;
687 }) => {
688 return await apiClient.post(`/availability/${payload.id}/duplicate`, {
689 departure_date: payload.departure_date,
690 departure_time: payload.departure_time ?? null,
691 });
692 },
693 onSuccess: () => {
694 queryClient.invalidateQueries({ queryKey: ["availability"] });
695 showToast(
696 __("Availability date duplicated successfully", "yatra"),
697 "success",
698 );
699 setDuplicateConfirm({ isOpen: false, date: null });
700 setDuplicateDepartureDate("");
701 setDuplicateDepartureTime("");
702 },
703 onError: (error: any) => {
704 showToast(
705 error?.message || __("Failed to duplicate availability date", "yatra"),
706 "error",
707 );
708 },
709 });
710
711 // Table columns and actions for shared Table
712 const tableColumns = useMemo(() => {
713 const cols: {
714 key: string;
715 label: string;
716 visible?: boolean;
717 render?: (date: AvailabilityDate, index: number) => React.ReactNode;
718 }[] = [];
719
720 // Departure
721 cols.push({
722 key: "departure",
723 label:
724 selectedTrip?.trip_type === "single_day"
725 ? __("Departure Time", "yatra")
726 : __("Departure", "yatra"),
727 visible: visibleColumns.departure,
728 render: (date) =>
729 selectedTrip?.trip_type === "single_day" ? (
730 <div className="flex items-center gap-2">
731 <CalendarDays className="w-4 h-4 text-gray-400" />
732 <div>
733 <div className="text-sm font-medium">
734 {formatDate(date.departure_date)}
735 </div>
736 <div className="text-xs text-gray-500 dark:text-gray-400">
737 {date.departure_time ? formatTime(date.departure_time) : ""}
738 </div>
739 </div>
740 </div>
741 ) : (
742 <div className="flex items-center gap-2">
743 <CalendarDays className="w-4 h-4 text-gray-400" />
744 <span className="text-sm font-medium">
745 {formatDate(date.departure_date)}
746 </span>
747 </div>
748 ),
749 });
750
751 // Arrival
752 cols.push({
753 key: "arrival",
754 label:
755 selectedTrip?.trip_type === "single_day"
756 ? __("Arrival Time", "yatra")
757 : __("Arrival", "yatra"),
758 visible: visibleColumns.arrival,
759 render: (date) =>
760 selectedTrip?.trip_type === "single_day" ? (
761 <div>
762 <div className="text-sm font-medium">
763 {formatDate(date.arrival_date)}
764 </div>
765 <div className="text-xs text-gray-500 dark:text-gray-400">
766 {date.arrival_time ? formatTime(date.arrival_time) : ""}
767 </div>
768 </div>
769 ) : (
770 <span className="text-sm">{formatDate(date.arrival_date)}</span>
771 ),
772 });
773
774 // From/To
775 cols.push({
776 key: "locations",
777 label: __("From/To", "yatra"),
778 visible: visibleColumns.locations,
779 render: (date) => (
780 <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
781 <MapPin className="w-3 h-3" />
782 <span>{date.from_location || selectedTrip?.starting_location}</span>
783 <span>→</span>
784 <span>{date.to_location || selectedTrip?.ending_location}</span>
785 </div>
786 ),
787 });
788
789 // Capacity
790 cols.push({
791 key: "capacity",
792 label: __("Capacity", "yatra"),
793 visible: visibleColumns.capacity,
794 render: (date) => (
795 <div className="flex flex-col items-center">
796 <span className="text-sm font-medium text-gray-900 dark:text-white">
797 {date.total_seats || 0}
798 </span>
799 <span className="text-xs text-gray-500 dark:text-gray-400">
800 {__("total", "yatra")}
801 </span>
802 </div>
803 ),
804 });
805
806 // Booked
807 cols.push({
808 key: "booked",
809 label: __("Booked", "yatra"),
810 visible: visibleColumns.booked,
811 render: (date) => (
812 <div className="flex flex-col items-center">
813 <span className="text-sm font-medium text-orange-600 dark:text-orange-400">
814 {date.booked_seats || 0}
815 </span>
816 <span className="text-xs text-gray-500 dark:text-gray-400">
817 {__("booked", "yatra")}
818 </span>
819 </div>
820 ),
821 });
822
823 // Available
824 cols.push({
825 key: "available",
826 label: __("Available", "yatra"),
827 visible: visibleColumns.available,
828 render: (date) => (
829 <div className="flex flex-col items-center">
830 <span
831 className={`text-sm font-semibold ${
832 date.available_seats === 0
833 ? "text-red-600 dark:text-red-400"
834 : date.available_seats <= (date.alert_threshold || 5)
835 ? "text-yellow-600 dark:text-yellow-400"
836 : "text-green-600 dark:text-green-400"
837 }`}
838 >
839 {date.available_seats || 0}
840 </span>
841 <span className="text-xs text-gray-500 dark:text-gray-400">
842 {__("available", "yatra")}
843 </span>
844 {date.available_seats <= (date.alert_threshold || 5) &&
845 date.available_seats > 0 && (
846 <Bell className="w-3 h-3 text-yellow-500 mt-0.5" />
847 )}
848 </div>
849 ),
850 });
851
852 // Waitlist
853 cols.push({
854 key: "waitlist",
855 label: __("Waitlist", "yatra"),
856 visible: visibleColumns.waitlist,
857 render: (date) =>
858 date.waitlist_count > 0 ? (
859 <div className="flex flex-col items-center">
860 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 text-xs">
861 {date.waitlist_count}
862 </Badge>
863 <span className="text-xs text-gray-500 dark:text-gray-400 mt-1">
864 {__("people", "yatra")}
865 </span>
866 </div>
867 ) : (
868 <span className="text-sm text-gray-400 dark:text-gray-500">-</span>
869 ),
870 });
871
872 // Price
873 cols.push({
874 key: "price",
875 label: __("Price", "yatra"),
876 visible: visibleColumns.price,
877 render: (date) => {
878 // Traveler-based dates carry per-category prices in price_types and leave
879 // original_price/discounted_price NULL. Show a price (or min–max range).
880 const pts = Array.isArray(date.price_types) ? date.price_types : [];
881 const travelerPrices = pts
882 .map(
883 (pt) =>
884 Number(pt.sale_price ?? pt.discounted_price ?? pt.original_price) ||
885 0,
886 )
887 .filter((n) => n > 0);
888 if (travelerPrices.length > 0) {
889 const sym = getCurrencySymbol(selectedTrip?.currency || "USD");
890 const min = Math.min(...travelerPrices);
891 const max = Math.max(...travelerPrices);
892 return (
893 <span className="text-sm font-semibold">
894 {min === max
895 ? `${sym}${min.toLocaleString()}`
896 : `${sym}${min.toLocaleString()} – ${sym}${max.toLocaleString()}`}
897 </span>
898 );
899 }
900 return (
901 <div className="flex flex-col gap-1">
902 {date.discounted_price &&
903 parseFloat(date.discounted_price) <
904 parseFloat(date.original_price) ? (
905 <>
906 <span className="text-sm line-through text-gray-400">
907 {getCurrencySymbol(selectedTrip?.currency || "USD")}
908 {parseFloat(date.original_price).toLocaleString()}
909 </span>
910 <div className="flex items-center gap-2">
911 <span className="text-sm font-semibold text-gray-900 dark:text-white">
912 {getCurrencySymbol(selectedTrip?.currency || "USD")}
913 {parseFloat(date.discounted_price).toLocaleString()}
914 </span>
915 {date.discount_percentage &&
916 parseFloat(date.discount_percentage) > 0 && (
917 <Badge variant="error" className="text-xs">
918 {date.discount_percentage}% {__("OFF", "yatra")}
919 </Badge>
920 )}
921 </div>
922 </>
923 ) : (
924 <span className="text-sm font-semibold">
925 {getCurrencySymbol(selectedTrip?.currency || "USD")}
926 {parseFloat(date.original_price).toLocaleString()}
927 </span>
928 )}
929 </div>
930 );
931 },
932 });
933
934 // Status
935 cols.push({
936 key: "status",
937 label: __("Status", "yatra"),
938 visible: visibleColumns.status,
939 render: (date) => (
940 <div className="flex flex-col gap-1">
941 {getStatusBadge(date.status, date.is_blocked)}
942 {date.is_blocked && date.block_reason && (
943 <span className="text-xs text-gray-500 dark:text-gray-400 italic">
944 {date.block_reason}
945 </span>
946 )}
947 {date.last_synced_at && (
948 <div className="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
949 <Clock className="w-3 h-3" />
950 <span>{new Date(date.last_synced_at).toLocaleTimeString()}</span>
951 </div>
952 )}
953 </div>
954 ),
955 });
956
957 return cols;
958 }, [
959 selectedTrip,
960 visibleColumns,
961 getCurrencySymbol,
962 formatDate,
963 formatTime,
964 getStatusBadge,
965 ]);
966
967 const tableActions = [
968 {
969 key: "edit",
970 label: __("Edit", "yatra"),
971 icon: <Edit className="w-4 h-4" />,
972 onClick: (date: AvailabilityDate) =>
973 navigate({
974 subpage: "trips",
975 tab: "availability",
976 action: "edit",
977 id: date.id,
978 trip_id: date.trip_id,
979 }),
980 },
981 {
982 key: "duplicate",
983 label: __("Duplicate", "yatra"),
984 icon: <Copy className="w-4 h-4" />,
985 onClick: (date: AvailabilityDate) => {
986 setDuplicateConfirm({ isOpen: true, date });
987 setDuplicateDepartureDate(date.departure_date || "");
988 setDuplicateDepartureTime(date.departure_time || "");
989 },
990 },
991 {
992 key: "delete",
993 label: __("Delete", "yatra"),
994 icon: <Trash2 className="w-4 h-4" />,
995 onClick: (date: AvailabilityDate) =>
996 setDeleteConfirm({ isOpen: true, date }),
997 variant: "destructive" as const,
998 },
999 ];
1000
1001 return (
1002 <div className="space-y-6">
1003 <ConfirmationDialog
1004 isOpen={deleteConfirm.isOpen}
1005 onClose={() => setDeleteConfirm({ isOpen: false, date: null })}
1006 onConfirm={() => {
1007 if (deleteConfirm.date) {
1008 deleteMutation.mutate(deleteConfirm.date.id);
1009 }
1010 }}
1011 title={__("Delete Availability Date", "yatra")}
1012 message={
1013 deleteConfirm.date
1014 ? __(
1015 "Are you sure you want to delete this availability date on {date}? This action cannot be undone.",
1016 "yatra",
1017 ).replace("{date}", formatDate(deleteConfirm.date.departure_date))
1018 : __(
1019 "Are you sure you want to delete this availability date? This action cannot be undone.",
1020 "yatra",
1021 )
1022 }
1023 confirmText={__("Delete", "yatra")}
1024 cancelText={__("Cancel", "yatra")}
1025 variant="danger"
1026 isLoading={deleteMutation.isPending}
1027 />
1028
1029 {duplicateConfirm.isOpen && duplicateConfirm.date && (
1030 <div
1031 className="fixed inset-0 z-50 flex items-center justify-center p-4"
1032 style={{ marginTop: "-32px" }}
1033 >
1034 <div
1035 className="absolute inset-0 bg-black/50"
1036 onClick={() => {
1037 if (!duplicateMutation.isPending) {
1038 setDuplicateConfirm({ isOpen: false, date: null });
1039 }
1040 }}
1041 />
1042 <div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
1043 <div className="p-6 border-b border-gray-200 dark:border-gray-700">
1044 <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
1045 {__("Duplicate Availability Date", "yatra")}
1046 </h2>
1047 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
1048 {__(
1049 "Select the new departure date. Arrival/return will be shifted automatically.",
1050 "yatra",
1051 )}
1052 </p>
1053 </div>
1054 <div className="p-6 space-y-4">
1055 <div>
1056 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1057 {__("Departure Date", "yatra")}
1058 </label>
1059 <DatePicker
1060 value={duplicateDepartureDate}
1061 onChange={(value: string) => setDuplicateDepartureDate(value)}
1062 placeholder={__("Select date", "yatra")}
1063 />
1064 </div>
1065
1066 {selectedTrip?.trip_type === "single_day" && (
1067 <div>
1068 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1069 {__("Departure Time", "yatra")}
1070 </label>
1071 <TimePicker
1072 value={duplicateDepartureTime}
1073 onChange={(value: string) =>
1074 setDuplicateDepartureTime(value)
1075 }
1076 placeholder={__("Select departure time", "yatra")}
1077 />
1078 </div>
1079 )}
1080 </div>
1081 <div className="p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
1082 <Button
1083 variant="outline"
1084 onClick={() =>
1085 setDuplicateConfirm({ isOpen: false, date: null })
1086 }
1087 disabled={duplicateMutation.isPending}
1088 >
1089 {__("Cancel", "yatra")}
1090 </Button>
1091 <Button
1092 onClick={() => {
1093 if (!duplicateConfirm.date) return;
1094 if (!duplicateDepartureDate) {
1095 showToast(
1096 __("Please select a departure date", "yatra"),
1097 "warning",
1098 );
1099 return;
1100 }
1101 duplicateMutation.mutate({
1102 id: duplicateConfirm.date.id,
1103 departure_date: duplicateDepartureDate,
1104 departure_time:
1105 selectedTrip?.trip_type === "single_day"
1106 ? duplicateDepartureTime || null
1107 : null,
1108 });
1109 }}
1110 disabled={duplicateMutation.isPending}
1111 >
1112 {duplicateMutation.isPending
1113 ? __("Duplicating...", "yatra")
1114 : __("Duplicate", "yatra")}
1115 </Button>
1116 </div>
1117 </div>
1118 </div>
1119 )}
1120
1121 <PageHeader
1122 title={__("Availability Management", "yatra")}
1123 description={__(
1124 "Manage departure dates and availability for your trips. Add dates for this month or plan ahead for the entire year.",
1125 "yatra",
1126 )}
1127 />
1128
1129 {/* Trip Selector - Clean Design */}
1130 <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">
1131 <div className="flex items-start justify-between gap-4 mb-4">
1132 <div className="flex-1">
1133 <div className="flex items-center gap-2 mb-1">
1134 <CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400" />
1135 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1136 {__("Select Trip", "yatra")}
1137 </h3>
1138 </div>
1139 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
1140 {__(
1141 "Choose a trip to manage its availability dates and departure schedules",
1142 "yatra",
1143 )}
1144 </p>
1145 </div>
1146 </div>
1147
1148 <div className="space-y-4">
1149 <div>
1150 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1151 {__("Trip", "yatra")} <span className="text-red-500">*</span>
1152 </label>
1153 <SearchableSelect
1154 value={selectedTripId?.toString() || ""}
1155 onChange={(value) => {
1156 const tripId = value ? parseInt(value) : null;
1157 setSelectedTripId(tripId);
1158 setPage(1);
1159 }}
1160 options={[
1161 { value: "", label: __("-- Select a Trip --", "yatra") },
1162 ...tripsList.map((trip) => ({
1163 value: trip.id.toString(),
1164 label: `${trip.title}${trip.trip_type === "single_day" ? " (Single Day)" : trip.trip_type === "multi_day" ? " (Multi-Day)" : ""}`,
1165 })),
1166 ]}
1167 placeholder={__("Search or select a trip...", "yatra")}
1168 searchPlaceholder={__("Search by trip name or ID...", "yatra")}
1169 className="w-full"
1170 required
1171 />
1172 </div>
1173
1174 {selectedTrip && (
1175 <div className="flex items-center gap-4 pt-2 border-t border-blue-200 dark:border-gray-700">
1176 <div className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
1177 <MapPin className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1178 <span className="font-medium">
1179 {selectedTrip.starting_location}
1180 </span>
1181 <span className="text-gray-400">→</span>
1182 <span className="font-medium">
1183 {selectedTrip.ending_location}
1184 </span>
1185 </div>
1186 {selectedTrip.trip_type && (
1187 <Badge
1188 className={
1189 selectedTrip.trip_type === "single_day"
1190 ? "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
1191 : "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400"
1192 }
1193 >
1194 {selectedTrip.trip_type === "single_day"
1195 ? __("Single Day", "yatra")
1196 : __("Multi-Day", "yatra")}
1197 </Badge>
1198 )}
1199 </div>
1200 )}
1201
1202 {!selectedTripId && (
1203 <div className="mt-4 p-4 bg-white/60 dark:bg-gray-800/60 rounded-lg border border-blue-200/50 dark:border-gray-700/50">
1204 <div className="flex items-start gap-3">
1205 <div className="flex-shrink-0 mt-0.5">
1206 <div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
1207 <AlertCircle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1208 </div>
1209 </div>
1210 <div className="flex-1">
1211 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1212 {__("Get Started", "yatra")}
1213 </p>
1214 <p className="text-sm text-gray-600 dark:text-gray-400">
1215 {__(
1216 "Select a trip from the dropdown above to view and manage its availability dates, pricing, and booking status.",
1217 "yatra",
1218 )}
1219 </p>
1220 </div>
1221 </div>
1222 </div>
1223 )}
1224 </div>
1225 </div>
1226
1227 {selectedTripId && (
1228 <>
1229 {/* Tab Navigation */}
1230 <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-1">
1231 <div className="flex items-center justify-between">
1232 <div className="flex items-center">
1233 <button
1234 onClick={() => setTabMode("specific")}
1235 className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
1236 tabMode === "specific"
1237 ? "bg-blue-600 text-white"
1238 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
1239 }`}
1240 >
1241 <CalendarDays className="w-4 h-4 inline mr-2" />
1242 {__("Specific Dates", "yatra")}
1243 </button>
1244 <button
1245 onClick={() => setTabMode("recurring")}
1246 className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
1247 tabMode === "recurring"
1248 ? "bg-blue-600 text-white"
1249 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
1250 }`}
1251 >
1252 <RefreshCw className="w-4 h-4 inline mr-2" />
1253 {__("Recurring Rules", "yatra")}
1254 </button>
1255 </div>
1256 </div>
1257 </div>
1258
1259 {/* Recurring Rules Tab Content */}
1260 {tabMode === "recurring" && (
1261 <RecurringRules
1262 tripId={selectedTripId}
1263 tripName={selectedTrip?.title}
1264 tripType={selectedTrip?.trip_type}
1265 onAddRule={() =>
1266 navigate({
1267 subpage: "trips",
1268 tab: "availability",
1269 action: "create-rule",
1270 trip_id: selectedTripId.toString(),
1271 })
1272 }
1273 onEditRule={(id: number) =>
1274 navigate({
1275 subpage: "trips",
1276 tab: "availability",
1277 action: "edit-rule",
1278 id: id.toString(),
1279 })
1280 }
1281 />
1282 )}
1283
1284 {/* Specific Dates Tab Content */}
1285 {tabMode === "specific" && (
1286 <>
1287 {/* Action Buttons - Right Aligned */}
1288 {selectedTripId && (
1289 <div className="flex items-center justify-end gap-2 mb-4">
1290 {/* View Toggle */}
1291 <div className="flex items-center border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
1292 <Button
1293 variant={viewMode === "list" ? "default" : "ghost"}
1294 size="sm"
1295 onClick={() => setViewMode("list")}
1296 className="rounded-none border-0"
1297 >
1298 <List className="w-4 h-4 mr-1" />
1299 {__("List", "yatra")}
1300 </Button>
1301 <Button
1302 variant={viewMode === "calendar" ? "default" : "ghost"}
1303 size="sm"
1304 onClick={() => setViewMode("calendar")}
1305 className="rounded-none border-0"
1306 >
1307 <Calendar className="w-4 h-4 mr-1" />
1308 {__("Calendar", "yatra")}
1309 </Button>
1310 </div>
1311
1312 {/* Sync Button */}
1313 <Button
1314 variant="outline"
1315 size="sm"
1316 onClick={handleSync}
1317 disabled={isSyncing}
1318 title={
1319 lastSyncTime
1320 ? `Last synced: ${lastSyncTime.toLocaleTimeString()}`
1321 : __("Sync availability", "yatra")
1322 }
1323 >
1324 <RefreshCw
1325 className={`w-4 h-4 mr-2 ${isSyncing ? "animate-spin" : ""}`}
1326 />
1327 {__("Sync", "yatra")}
1328 </Button>
1329
1330 {/* Add Availability Button */}
1331 <Button
1332 onClick={() =>
1333 navigate({
1334 subpage: "trips",
1335 tab: "availability",
1336 action: "create",
1337 trip_id: selectedTripId.toString(),
1338 // Clear any stale `id` left in the URL from a prior edit so
1339 // the form opens cleanly in create mode (navigate() merges
1340 // params; an undefined value deletes the key).
1341 id: undefined,
1342 })
1343 }
1344 >
1345 <Plus className="w-4 h-4 mr-2" />
1346 {__("Add Availability Date", "yatra")}
1347 </Button>
1348 </div>
1349 )}
1350
1351 {/* Inventory Alerts */}
1352 {showAlerts && inventoryAlerts.length > 0 && (
1353 <Alert
1354 variant="warning"
1355 className="border-yellow-300 bg-yellow-50 dark:bg-yellow-900/10"
1356 >
1357 <div className="flex items-start justify-between w-full">
1358 <div className="flex items-start gap-3 flex-1">
1359 <Bell className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
1360 <div className="flex-1">
1361 <div className="flex items-center gap-2 mb-1">
1362 <h4 className="font-semibold text-yellow-900 dark:text-yellow-200">
1363 {__("Inventory Alerts", "yatra")}
1364 </h4>
1365 <Badge className="bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200">
1366 {inventoryAlerts.length}
1367 </Badge>
1368 </div>
1369 <p className="text-sm text-yellow-800 dark:text-yellow-300 mb-2">
1370 {__(
1371 "The following dates have low availability (below threshold).",
1372 "yatra",
1373 )}
1374 </p>
1375 <div className="flex flex-wrap gap-2">
1376 {inventoryAlerts.slice(0, 5).map((alert) => (
1377 <Button
1378 key={alert.id}
1379 variant="outline"
1380 size="sm"
1381 onClick={() =>
1382 navigate({
1383 subpage: "trips",
1384 tab: "availability",
1385 action: "edit",
1386 id: alert.id,
1387 trip_id: alert.trip_id,
1388 })
1389 }
1390 className="text-xs border-yellow-300 text-yellow-800 hover:bg-yellow-100 dark:border-yellow-700 dark:text-yellow-300"
1391 >
1392 {formatDate(alert.departure_date)}
1393 {alert.departure_time &&
1394 ` ${formatTime(alert.departure_time)}`}
1395 <span className="ml-1 font-semibold">
1396 ({alert.available_seats} {__("seats", "yatra")})
1397 </span>
1398 </Button>
1399 ))}
1400 {inventoryAlerts.length > 5 && (
1401 <span className="text-xs text-yellow-700 dark:text-yellow-400 self-center">
1402 +{inventoryAlerts.length - 5}{" "}
1403 {__("more", "yatra")}
1404 </span>
1405 )}
1406 </div>
1407 </div>
1408 </div>
1409 <Button
1410 variant="ghost"
1411 size="sm"
1412 onClick={() => setShowAlerts(false)}
1413 className="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400"
1414 >
1415 <X className="w-4 h-4" />
1416 </Button>
1417 </div>
1418 </Alert>
1419 )}
1420
1421 {/* Waitlist Summary */}
1422 {waitlistEntries.length > 0 && (
1423 <Card className="border-blue-200 bg-blue-50 dark:bg-blue-900/10 dark:border-blue-800">
1424 <CardHeader className="pb-3">
1425 <div className="flex items-center justify-between">
1426 <div className="flex items-center gap-2">
1427 <Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
1428 <CardTitle className="text-base">
1429 {__("Waitlist", "yatra")}
1430 </CardTitle>
1431 <Badge className="bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200">
1432 {waitlistEntries.reduce(
1433 (sum, entry) => sum + entry.waitlist_count,
1434 0,
1435 )}{" "}
1436 {__("people", "yatra")}
1437 </Badge>
1438 </div>
1439 <Button
1440 variant="ghost"
1441 size="sm"
1442 onClick={() =>
1443 navigate({
1444 subpage: "trips",
1445 tab: "availability",
1446 action: "waitlist",
1447 trip_id: selectedTripId.toString(),
1448 })
1449 }
1450 >
1451 <Eye className="w-4 h-4 mr-1" />
1452 {__("View All", "yatra")}
1453 </Button>
1454 </div>
1455 </CardHeader>
1456 <CardContent>
1457 <div className="space-y-2">
1458 {waitlistEntries.slice(0, 3).map((entry) => (
1459 <div
1460 key={entry.id}
1461 className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-800"
1462 >
1463 <div className="flex items-center gap-3">
1464 <CalendarDays className="w-4 h-4 text-blue-600 dark:text-blue-400" />
1465 <div>
1466 <div className="text-sm font-medium text-gray-900 dark:text-white">
1467 {formatDate(entry.departure_date)}
1468 {entry.departure_time &&
1469 ` ${formatTime(entry.departure_time)}`}
1470 </div>
1471 <div className="text-xs text-gray-500 dark:text-gray-400">
1472 {entry.booked_seats}/{entry.total_seats}{" "}
1473 {__("booked", "yatra")}
1474 </div>
1475 </div>
1476 </div>
1477 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
1478 {entry.waitlist_count} {__("on waitlist", "yatra")}
1479 </Badge>
1480 </div>
1481 ))}
1482 </div>
1483 </CardContent>
1484 </Card>
1485 )}
1486
1487 {/* Blocked Dates Summary */}
1488 {blockedDates.length > 0 && (
1489 <Card className="border-red-200 bg-red-50 dark:bg-red-900/10 dark:border-red-800">
1490 <CardHeader className="pb-3">
1491 <div className="flex items-center justify-between">
1492 <div className="flex items-center gap-2">
1493 <Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
1494 <CardTitle className="text-base">
1495 {__("Blocked Dates", "yatra")}
1496 </CardTitle>
1497 <Badge className="bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200">
1498 {blockedDates.length}
1499 </Badge>
1500 </div>
1501 </div>
1502 </CardHeader>
1503 <CardContent>
1504 <div className="space-y-2">
1505 {blockedDates.slice(0, 3).map((entry) => (
1506 <div
1507 key={entry.id}
1508 className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-red-200 dark:border-red-800"
1509 >
1510 <div className="flex items-center gap-3">
1511 <Ban className="w-4 h-4 text-red-600 dark:text-red-400" />
1512 <div>
1513 <div className="text-sm font-medium text-gray-900 dark:text-white">
1514 {formatDate(entry.departure_date)}
1515 {entry.departure_time &&
1516 ` ${formatTime(entry.departure_time)}`}
1517 </div>
1518 {entry.block_reason && (
1519 <div className="text-xs text-gray-500 dark:text-gray-400">
1520 {__("Reason", "yatra")}: {entry.block_reason}
1521 </div>
1522 )}
1523 </div>
1524 </div>
1525 <Button
1526 variant="ghost"
1527 size="sm"
1528 onClick={() =>
1529 navigate({
1530 subpage: "trips",
1531 tab: "availability",
1532 action: "edit",
1533 id: entry.id,
1534 trip_id: entry.trip_id,
1535 })
1536 }
1537 >
1538 <Settings className="w-4 h-4" />
1539 </Button>
1540 </div>
1541 ))}
1542 </div>
1543 </CardContent>
1544 </Card>
1545 )}
1546
1547 {/* Filters */}
1548 <Card>
1549 <CardContent className="pt-6">
1550 <div
1551 className={`grid grid-cols-1 ${selectedTrip?.trip_type === "single_day" ? "md:grid-cols-3" : "md:grid-cols-4"} gap-4`}
1552 >
1553 <div>
1554 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1555 {__("Search", "yatra")}
1556 </label>
1557 <div className="relative">
1558 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
1559 <Input
1560 type="text"
1561 value={searchTerm}
1562 onChange={(e) => setSearchTerm(e.target.value)}
1563 placeholder={__(
1564 "Search dates, locations...",
1565 "yatra",
1566 )}
1567 className="pl-10"
1568 />
1569 </div>
1570 </div>
1571 <div>
1572 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1573 {__("Status", "yatra")}
1574 </label>
1575 <Select
1576 value={statusFilter}
1577 onChange={(e) => setStatusFilter(e.target.value)}
1578 >
1579 <option value="all">{__("All Status", "yatra")}</option>
1580 <option value="available">
1581 {__("Available", "yatra")}
1582 </option>
1583 <option value="limited">
1584 {__("Limited", "yatra")}
1585 </option>
1586 <option value="sold_out">
1587 {__("Sold Out", "yatra")}
1588 </option>
1589 <option value="closed">{__("Closed", "yatra")}</option>
1590 <option value="blocked">
1591 {__("Blocked", "yatra")}
1592 </option>
1593 </Select>
1594 </div>
1595 {selectedTrip?.trip_type !== "single_day" && (
1596 <div>
1597 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1598 {__("Month", "yatra")}
1599 </label>
1600 <Select
1601 value={monthFilter}
1602 onChange={(e) => setMonthFilter(e.target.value)}
1603 >
1604 <option value="all">
1605 {__("All Months", "yatra")}
1606 </option>
1607 {availableMonths.map((month) => {
1608 const [year, monthNum] = month.split("-");
1609 const date = new Date(
1610 parseInt(year),
1611 parseInt(monthNum) - 1,
1612 );
1613 return (
1614 <option key={month} value={month}>
1615 {date.toLocaleDateString("en-US", {
1616 year: "numeric",
1617 month: "long",
1618 })}
1619 </option>
1620 );
1621 })}
1622 </Select>
1623 </div>
1624 )}
1625 <div className="flex items-end">
1626 <Button
1627 variant="outline"
1628 onClick={() => {
1629 setSearchTerm("");
1630 setStatusFilter("all");
1631 setMonthFilter("all");
1632 }}
1633 className="w-full"
1634 >
1635 <X className="w-4 h-4 mr-2" />
1636 {__("Clear Filters", "yatra")}
1637 </Button>
1638 </div>
1639 </div>
1640 </CardContent>
1641 </Card>
1642
1643 {/* Bulk actions toolbar for availability list (directly above table, no extra card background) */}
1644 <BulkActionToolbar
1645 selectedIds={selectedIds}
1646 bulkAction={bulkAction}
1647 setBulkAction={setBulkAction}
1648 onApply={handleBulkApply}
1649 onClearSelection={() => setSelectedIds([])}
1650 statusFilter={statusFilter}
1651 setStatusFilter={setStatusFilter}
1652 statusOptions={[
1653 {
1654 key: "all",
1655 label: __("All", "yatra"),
1656 count: statusCounts.all,
1657 },
1658 {
1659 key: "available",
1660 label: __("Available", "yatra"),
1661 count: statusCounts.available,
1662 },
1663 {
1664 key: "limited",
1665 label: __("Limited", "yatra"),
1666 count: statusCounts.limited,
1667 },
1668 {
1669 key: "sold_out",
1670 label: __("Sold Out", "yatra"),
1671 count: statusCounts.sold_out,
1672 },
1673 {
1674 key: "closed",
1675 label: __("Closed", "yatra"),
1676 count: statusCounts.closed,
1677 },
1678 {
1679 key: "blocked",
1680 label: __("Blocked", "yatra"),
1681 count: statusCounts.blocked,
1682 },
1683 ]}
1684 showColumnsDropdown={showColumnsDropdown}
1685 setShowColumnsDropdown={setShowColumnsDropdown}
1686 columnOptions={[
1687 {
1688 key: "departure",
1689 label: __("Departure", "yatra"),
1690 visible: visibleColumns.departure,
1691 },
1692 {
1693 key: "arrival",
1694 label: __("Arrival", "yatra"),
1695 visible: visibleColumns.arrival,
1696 },
1697 {
1698 key: "locations",
1699 label: __("From/To", "yatra"),
1700 visible: visibleColumns.locations,
1701 },
1702 {
1703 key: "capacity",
1704 label: __("Capacity", "yatra"),
1705 visible: visibleColumns.capacity,
1706 },
1707 {
1708 key: "booked",
1709 label: __("Booked", "yatra"),
1710 visible: visibleColumns.booked,
1711 },
1712 {
1713 key: "available",
1714 label: __("Available", "yatra"),
1715 visible: visibleColumns.available,
1716 },
1717 {
1718 key: "waitlist",
1719 label: __("Waitlist", "yatra"),
1720 visible: visibleColumns.waitlist,
1721 },
1722 {
1723 key: "price",
1724 label: __("Price", "yatra"),
1725 visible: visibleColumns.price,
1726 },
1727 {
1728 key: "status",
1729 label: __("Status", "yatra"),
1730 visible: visibleColumns.status,
1731 },
1732 ]}
1733 onToggleColumn={(key) =>
1734 toggleColumn(key as keyof typeof visibleColumns)
1735 }
1736 bulkMutationPending={bulkDeleteMutation.isPending}
1737 totalItems={totalFiltered}
1738 bulkActionOptions={bulkStatusOptions}
1739 />
1740
1741 {/* Availability Dates List */}
1742 <Card>
1743 <CardHeader>
1744 <CardTitle>{__("Availability Dates", "yatra")}</CardTitle>
1745 <CardDescription>
1746 {selectedTrip && (
1747 <>
1748 {__("Managing availability for", "yatra")}{" "}
1749 <strong>{selectedTrip.title}</strong>
1750 {filteredDates.length > 0 && (
1751 <span className="ml-2">
1752 ({filteredDates.length}{" "}
1753 {selectedTrip?.trip_type === "single_day"
1754 ? __("time slots", "yatra")
1755 : __("dates", "yatra")}
1756 )
1757 </span>
1758 )}
1759 </>
1760 )}
1761 </CardDescription>
1762 </CardHeader>
1763 <CardContent>
1764 {viewMode === "calendar" ? (
1765 filteredDates.length === 0 ? (
1766 <div className="py-12 text-center text-gray-500 dark:text-gray-400">
1767 <Calendar className="w-10 h-10 mx-auto mb-3 text-gray-400 dark:text-gray-600" />
1768 <p className="text-sm font-medium mb-1">
1769 {__(
1770 "No availability dates found for this trip.",
1771 "yatra",
1772 )}
1773 </p>
1774 <p className="text-xs">
1775 {__(
1776 "Try adjusting your filters or add a new availability date.",
1777 "yatra",
1778 )}
1779 </p>
1780 </div>
1781 ) : (
1782 <div className="mt-4">
1783 <AvailabilityCalendar
1784 dates={filteredDates}
1785 tripType={selectedTrip?.trip_type}
1786 currency={selectedTrip?.currency || "USD"}
1787 onDateClick={(date) => {
1788 navigate({
1789 subpage: "trips",
1790 tab: "availability",
1791 action: "edit",
1792 id: date.id,
1793 trip_id: date.trip_id,
1794 });
1795 }}
1796 />
1797 </div>
1798 )
1799 ) : (
1800 <SharedTable
1801 data={filteredDates}
1802 columns={tableColumns}
1803 actions={tableActions}
1804 isLoading={isLoading}
1805 isError={!!availabilityError}
1806 errorText={__("Error loading availability", "yatra")}
1807 errorDescription={__(
1808 "We couldn’t connect to the availability service. Please refresh or try again shortly.",
1809 "We couldn’t connect to the availability service. Please refresh or try again shortly.",
1810 )}
1811 errorDetails={availabilityErrorContext?.details || ""}
1812 errorRequestInfo={availabilityErrorContext?.requestInfo}
1813 onRetry={() =>
1814 queryClient.invalidateQueries({
1815 queryKey: ["availability"],
1816 })
1817 }
1818 emptyText={__(
1819 "No availability dates found for this trip.",
1820 "yatra",
1821 )}
1822 emptyDescription={__(
1823 "Try adjusting your filters or add a new availability date.",
1824 "yatra",
1825 )}
1826 selectedItemIds={selectedIds}
1827 onSelectItem={(id, checked) => {
1828 if (checked) {
1829 setSelectedIds((prev) =>
1830 Array.from(new Set([...prev, id as string])),
1831 );
1832 } else {
1833 setSelectedIds((prev) =>
1834 prev.filter((existingId) => existingId !== id),
1835 );
1836 }
1837 }}
1838 onSelectAll={(checked) => {
1839 if (checked) {
1840 setSelectedIds(filteredDates.map((d) => d.id));
1841 } else {
1842 setSelectedIds([]);
1843 }
1844 }}
1845 isAllSelected={
1846 filteredDates.length > 0 &&
1847 selectedIds.length === filteredDates.length
1848 }
1849 getItemId={(date: AvailabilityDate) => date.id}
1850 getItemStatus={(date: AvailabilityDate) => date.status}
1851 statusFilter={statusFilter}
1852 skeletonRows={5}
1853 capability="yatra_view_trips"
1854 />
1855 )}
1856 </CardContent>
1857 </Card>
1858 </>
1859 )}
1860 </>
1861 )}
1862 </div>
1863 );
1864 };
1865
1866 export default Availability;
1867