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 / components / availability / AvailabilityCalendar.tsx

AvailabilityCalendar.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/components/availability/AvailabilityCalendar.tsx

538 lines 22.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Availability Calendar Component
3 * Displays availability dates in a calendar grid with color coding
4 */
5
6 import React, { useState, useMemo } from "react";
7 import {
8 ChevronLeft,
9 ChevronRight,
10 Users,
11 Bell,
12 Ban,
13 Edit,
14 Eye,
15 } from "lucide-react";
16 import {
17 format,
18 startOfMonth,
19 endOfMonth,
20 startOfWeek,
21 endOfWeek,
22 eachDayOfInterval,
23 isSameMonth,
24 addMonths,
25 subMonths,
26 isToday,
27 parseISO,
28 } from "date-fns";
29 import { __ } from "../../lib/i18n";
30 import { Button } from "../ui/button";
31 import { Badge } from "../ui/badge";
32 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
33 import { useNavigate } from "../../hooks/useNavigate";
34
35 interface AvailabilityDate {
36 id: string;
37 trip_id?: number;
38 departure_date: string;
39 departure_time?: string;
40 arrival_date: string;
41 arrival_time?: string;
42 total_seats: number;
43 booked_seats: number;
44 available_seats: number;
45 waitlist_count: number;
46 /** True for read-only dates generated by a recurring rule */
47 is_virtual?: boolean;
48 status:
49 | "available"
50 | "sold_out"
51 | "limited"
52 | "closed"
53 | "blocked"
54 | "cancelled";
55 is_blocked?: boolean;
56 block_reason?: string;
57 alert_threshold?: number;
58 original_price: string;
59 discounted_price: string;
60 }
61
62 interface AvailabilityCalendarProps {
63 dates: AvailabilityDate[];
64 tripType?: "single_day" | "multi_day";
65 currency?: string;
66 onDateClick?: (date: AvailabilityDate) => void;
67 }
68
69 export const AvailabilityCalendar: React.FC<AvailabilityCalendarProps> = ({
70 dates,
71 tripType = "multi_day",
72 currency = "USD",
73 onDateClick,
74 }) => {
75 const { navigate } = useNavigate();
76 const [currentMonth, setCurrentMonth] = useState(new Date());
77
78 // Group dates by departure date
79 const datesByDate = useMemo(() => {
80 const grouped: Record<string, AvailabilityDate[]> = {};
81 dates.forEach((date) => {
82 const key = date.departure_date;
83 if (!grouped[key]) {
84 grouped[key] = [];
85 }
86 grouped[key].push(date);
87 });
88 return grouped;
89 }, [dates]);
90
91 // Get availability info for a specific date
92 const getDateAvailability = (date: Date): AvailabilityDate[] => {
93 const dateKey = format(date, "yyyy-MM-dd");
94 return datesByDate[dateKey] || [];
95 };
96
97 // Get color class based on availability status
98 const getDateColorClass = (
99 dateAvailabilities: AvailabilityDate[],
100 ): string => {
101 if (dateAvailabilities.length === 0) {
102 return "bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700";
103 }
104
105 // Check if any date is blocked
106 if (dateAvailabilities.some((d) => d.is_blocked)) {
107 return "bg-red-100 dark:bg-red-900/20 border-red-300 dark:border-red-800";
108 }
109
110 // Check if all are sold out
111 if (
112 dateAvailabilities.every(
113 (d) => d.available_seats === 0 || d.status === "sold_out",
114 )
115 ) {
116 return "bg-gray-200 dark:bg-gray-700 border-gray-300 dark:border-gray-600";
117 }
118
119 // Check if any has low availability (below threshold)
120 const hasLowAvailability = dateAvailabilities.some((d) => {
121 const threshold = d.alert_threshold || 5;
122 return d.available_seats > 0 && d.available_seats <= threshold;
123 });
124
125 if (hasLowAvailability) {
126 return "bg-yellow-100 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-800";
127 }
128
129 // Check if any has limited availability
130 if (dateAvailabilities.some((d) => d.status === "limited")) {
131 return "bg-orange-100 dark:bg-orange-900/20 border-orange-300 dark:border-orange-800";
132 }
133
134 // Available
135 return "bg-green-100 dark:bg-green-900/20 border-green-300 dark:border-green-800";
136 };
137
138 const monthStart = startOfMonth(currentMonth);
139 const monthEnd = endOfMonth(currentMonth);
140 const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
141 const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
142 const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
143
144 const previousMonth = () => {
145 setCurrentMonth(subMonths(currentMonth, 1));
146 };
147
148 const nextMonth = () => {
149 setCurrentMonth(addMonths(currentMonth, 1));
150 };
151
152 const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
153
154 const formatTime = (timeString: string) => {
155 if (!timeString) return "";
156 const [hours, minutes] = timeString.split(":");
157 const hour = parseInt(hours);
158 const ampm = hour >= 12 ? "PM" : "AM";
159 return `${hour % 12 || 12}:${minutes} ${ampm}`;
160 };
161
162 const formatTimeForDisplay = (timeString: string): string => {
163 if (!timeString) return "";
164 const time = new Date(`1970-01-01T${timeString}`);
165 const timeStringFormatted = time.toLocaleTimeString("en-US", {
166 hour12: false,
167 hour: "numeric",
168 minute: "2-digit",
169 });
170 return timeStringFormatted;
171 };
172
173 const getCurrencySymbol = (currency: string) => {
174 const symbols: Record<string, string> = {
175 USD: "$",
176 EUR: "€",
177 GBP: "£",
178 INR: "₹",
179 };
180 return symbols[currency] || currency;
181 };
182
183 return (
184 <div className="space-y-4">
185 {/* Calendar Header */}
186 <div className="flex items-center justify-between">
187 <Button
188 variant="outline"
189 size="sm"
190 onClick={previousMonth}
191 className="h-8 w-8 p-0"
192 >
193 <ChevronLeft className="w-4 h-4" />
194 </Button>
195 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
196 {format(currentMonth, "MMMM yyyy")}
197 </h3>
198 <Button
199 variant="outline"
200 size="sm"
201 onClick={nextMonth}
202 className="h-8 w-8 p-0"
203 >
204 <ChevronRight className="w-4 h-4" />
205 </Button>
206 </div>
207
208 {/* Calendar Grid */}
209 <div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
210 {/* Week Day Headers */}
211 <div className="grid grid-cols-7 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
212 {weekDays.map((day) => (
213 <div
214 key={day}
215 className="p-2 text-center text-xs font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 last:border-r-0"
216 >
217 {day}
218 </div>
219 ))}
220 </div>
221
222 {/* Calendar Days */}
223 <div className="grid grid-cols-7">
224 {days.map((day, dayIdx) => {
225 const isCurrentMonth = isSameMonth(day, currentMonth);
226 const isTodayDate = isToday(day);
227 const dateAvailabilities = getDateAvailability(day);
228 const colorClass = getDateColorClass(dateAvailabilities);
229 const hasMultipleSlots = dateAvailabilities.length > 1;
230
231 return (
232 <div
233 key={dayIdx}
234 className={`
235 min-h-[80px] p-1 border-r border-b border-gray-200 dark:border-gray-700
236 ${isCurrentMonth ? "" : "bg-gray-50 dark:bg-gray-900/50 opacity-50"}
237 ${colorClass}
238 `}
239 >
240 <div className="flex flex-col h-full">
241 {/* Date Number */}
242 <div className="flex items-center justify-between mb-1">
243 <span
244 className={`
245 text-xs font-medium
246 ${isTodayDate ? "bg-blue-600 text-white rounded-full w-5 h-5 flex items-center justify-center" : ""}
247 ${isCurrentMonth ? "text-gray-900 dark:text-white" : "text-gray-400 dark:text-gray-600"}
248 `}
249 >
250 {isTodayDate ? format(day, "d") : format(day, "d")}
251 </span>
252 {hasMultipleSlots && (
253 <Badge className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
254 {dateAvailabilities.length}
255 </Badge>
256 )}
257 </div>
258
259 {/* Availability Info */}
260 {isCurrentMonth && dateAvailabilities.length > 0 && (
261 <div className="flex-1 space-y-1 relative">
262 {dateAvailabilities.slice(0, 2).map((availability) => {
263 const isBlocked = availability.is_blocked;
264 const isSoldOut = availability.available_seats === 0;
265 const isLow =
266 availability.available_seats > 0 &&
267 availability.available_seats <=
268 (availability.alert_threshold || 5);
269
270 return (
271 <Popover key={availability.id}>
272 <PopoverTrigger asChild>
273 <button
274 type="button"
275 className="w-full text-left p-1 rounded text-xs hover:bg-white/50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer"
276 >
277 <div className="flex items-center gap-1 mb-0.5">
278 {tripType === "single_day" &&
279 availability.departure_time && (
280 <span className="text-xs font-medium">
281 {formatTime(
282 availability.departure_time,
283 )}
284 </span>
285 )}
286 {isBlocked && (
287 <Ban className="w-3 h-3 text-red-600 dark:text-red-400" />
288 )}
289 {isLow && !isBlocked && (
290 <Bell className="w-3 h-3 text-yellow-600 dark:text-yellow-400" />
291 )}
292 </div>
293 <div className="flex items-center gap-1">
294 <Users className="w-3 h-3 text-gray-400" />
295 <span
296 className={`text-xs font-semibold ${
297 isSoldOut
298 ? "text-red-600 dark:text-red-400"
299 : isLow
300 ? "text-yellow-600 dark:text-yellow-400"
301 : "text-green-600 dark:text-green-400"
302 }`}
303 >
304 {availability.available_seats}
305 </span>
306 {availability.waitlist_count > 0 && (
307 <span className="text-xs text-blue-600 dark:text-blue-400">
308 +{availability.waitlist_count}
309 </span>
310 )}
311 </div>
312 </button>
313 </PopoverTrigger>
314 <PopoverContent
315 className="w-80 p-4 z-50"
316 align="start"
317 side="right"
318 >
319 <div className="space-y-3">
320 <div className="flex items-center justify-between">
321 <h4 className="font-semibold text-sm text-gray-900 dark:text-white">
322 {format(
323 parseISO(availability.departure_date),
324 "EEEE, MMMM d, yyyy",
325 )}
326 </h4>
327 <Button
328 variant="ghost"
329 size="sm"
330 onClick={() => {
331 if (onDateClick) {
332 onDateClick(availability);
333 } else {
334 navigate({
335 subpage: "trips",
336 tab: "availability",
337 action: "edit",
338 id: availability.id,
339 });
340 }
341 }}
342 className="h-7 w-7 p-0"
343 >
344 <Edit className="w-3.5 h-3.5" />
345 </Button>
346 </div>
347
348 {tripType === "single_day" &&
349 availability.departure_time && (
350 <div className="text-xs text-gray-600 dark:text-gray-400">
351 {__("Time", "yatra")}:{" "}
352 {formatTimeForDisplay(
353 availability.departure_time,
354 )}{" "}
355 -{" "}
356 {availability.arrival_time
357 ? formatTimeForDisplay(
358 availability.arrival_time,
359 )
360 : ""}
361 </div>
362 )}
363
364 {availability.is_virtual && (
365 <div className="mb-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
366 {__("From rule", "yatra")}
367 </div>
368 )}
369 <div className="grid grid-cols-2 gap-3 text-xs">
370 <div>
371 <div className="text-gray-500 dark:text-gray-400 mb-1">
372 {__("Capacity", "yatra")}
373 </div>
374 <div className="font-semibold text-gray-900 dark:text-white">
375 {availability.total_seats}
376 </div>
377 </div>
378 <div>
379 <div className="text-gray-500 dark:text-gray-400 mb-1">
380 {__("Booked", "yatra")}
381 </div>
382 <div className="font-semibold text-orange-600 dark:text-orange-400">
383 {availability.booked_seats}
384 </div>
385 </div>
386 <div>
387 <div className="text-gray-500 dark:text-gray-400 mb-1">
388 {__("Available", "yatra")}
389 </div>
390 <div
391 className={`font-semibold ${
392 availability.available_seats === 0
393 ? "text-red-600 dark:text-red-400"
394 : availability.available_seats <=
395 (availability.alert_threshold ||
396 5)
397 ? "text-yellow-600 dark:text-yellow-400"
398 : "text-green-600 dark:text-green-400"
399 }`}
400 >
401 {availability.available_seats}
402 </div>
403 </div>
404 <div>
405 <div className="text-gray-500 dark:text-gray-400 mb-1">
406 {__("Price", "yatra")}
407 </div>
408 <div className="font-semibold text-gray-900 dark:text-white">
409 {getCurrencySymbol(currency)}
410 {parseFloat(
411 availability.discounted_price ||
412 availability.original_price,
413 ).toLocaleString()}
414 </div>
415 </div>
416 </div>
417
418 {availability.waitlist_count > 0 && (
419 <div className="flex items-center gap-2 text-xs">
420 <Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
421 <span className="text-gray-600 dark:text-gray-400">
422 {availability.waitlist_count}{" "}
423 {__("people on waitlist", "yatra")}
424 </span>
425 </div>
426 )}
427
428 {availability.is_blocked &&
429 availability.block_reason && (
430 <div className="flex items-center gap-2 text-xs text-red-600 dark:text-red-400">
431 <Ban className="w-4 h-4" />
432 <span>
433 {__("Blocked", "yatra")}:{" "}
434 {availability.block_reason}
435 </span>
436 </div>
437 )}
438
439 <div className="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
440 <Button
441 variant="outline"
442 size="sm"
443 onClick={() => {
444 if (onDateClick) {
445 onDateClick(availability);
446 } else {
447 navigate({
448 subpage: "trips",
449 tab: "availability",
450 action: "edit",
451 id: availability.id,
452 });
453 }
454 }}
455 className="flex-1 text-xs h-7"
456 >
457 <Edit className="w-3 h-3 mr-1" />
458 {__("Edit", "yatra")}
459 </Button>
460 <Button
461 variant="outline"
462 size="sm"
463 onClick={() =>
464 navigate({
465 subpage: "trips",
466 tab: "availability",
467 action: "view",
468 id: availability.id,
469 })
470 }
471 className="flex-1 text-xs h-7"
472 >
473 <Eye className="w-3 h-3 mr-1" />
474 {__("View", "yatra")}
475 </Button>
476 </div>
477 </div>
478 </PopoverContent>
479 </Popover>
480 );
481 })}
482 {dateAvailabilities.length > 2 && (
483 <div className="text-xs text-gray-500 dark:text-gray-400 text-center pt-1">
484 +{dateAvailabilities.length - 2} {__("more", "yatra")}
485 </div>
486 )}
487 </div>
488 )}
489 </div>
490 </div>
491 );
492 })}
493 </div>
494 </div>
495
496 {/* Legend */}
497 <div className="flex flex-wrap items-center gap-4 text-xs">
498 <div className="flex items-center gap-2">
499 <div className="w-4 h-4 rounded border-2 border-green-300 dark:border-green-800 bg-green-100 dark:bg-green-900/20"></div>
500 <span className="text-gray-600 dark:text-gray-400">
501 {__("Available", "yatra")}
502 </span>
503 </div>
504 <div className="flex items-center gap-2">
505 <div className="w-4 h-4 rounded border-2 border-yellow-300 dark:border-yellow-800 bg-yellow-100 dark:bg-yellow-900/20"></div>
506 <span className="text-gray-600 dark:text-gray-400">
507 {__("Low Availability", "yatra")}
508 </span>
509 </div>
510 <div className="flex items-center gap-2">
511 <div className="w-4 h-4 rounded border-2 border-orange-300 dark:border-orange-800 bg-orange-100 dark:bg-orange-900/20"></div>
512 <span className="text-gray-600 dark:text-gray-400">
513 {__("Limited", "yatra")}
514 </span>
515 </div>
516 <div className="flex items-center gap-2">
517 <div className="w-4 h-4 rounded border-2 border-gray-300 dark:border-gray-600 bg-gray-200 dark:bg-gray-700"></div>
518 <span className="text-gray-600 dark:text-gray-400">
519 {__("Sold Out", "yatra")}
520 </span>
521 </div>
522 <div className="flex items-center gap-2">
523 <div className="w-4 h-4 rounded border-2 border-red-300 dark:border-red-800 bg-red-100 dark:bg-red-900/20"></div>
524 <span className="text-gray-600 dark:text-gray-400">
525 {__("Blocked", "yatra")}
526 </span>
527 </div>
528 <div className="flex items-center gap-2">
529 <div className="w-4 h-4 rounded border-2 border-gray-300 dark:border-gray-600 bg-gray-200 dark:bg-gray-700"></div>
530 <span className="text-gray-600 dark:text-gray-400">
531 {__("Not Available", "yatra")}
532 </span>
533 </div>
534 </div>
535 </div>
536 );
537 };
538