PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at resources/js/components/availability/AvailabilityCalendar.tsx

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