PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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.5.1, at resources/js/pages/Availability.tsx

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