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

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