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

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