PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / Availability.tsx

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

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