PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/Availability.tsx +133 -32 3.0.4 → 3.0.17 View file →
@@ -26,8 +26,9 @@
26 26 Eye,
27 27 Settings,
28 28 } from "lucide-react";
29 29 import { __ } from "../lib/i18n";
30 +import { toDateValue } from "../lib/dateFormat";
30 31 import { Button } from "../components/ui/button";
31 32 import { Input } from "../components/ui/input";
32 33 import { Select } from "../components/ui/select";
33 34 import { SearchableSelect } from "../components/ui/searchable-select";
@@ -76,8 +77,17 @@
76 77 seats_remaining: string; // Legacy field for display
77 78 original_price: string;
78 79 discounted_price: string;
79 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 + }>;
80 90 status:
81 91 | "available"
82 92 | "sold_out"
83 93 | "limited"
@@ -170,8 +180,9 @@
170 180 const tripIdFromUrl = useMemo(() => {
171 181 const params = new URLSearchParams(window.location.search);
172 182 const tripId = params.get("trip_id");
173 183 return tripId ? parseInt(tripId) : null;
184 + // eslint-disable-next-line react-hooks/exhaustive-deps
174 185 }, [urlKey]);
175 186
176 187 // Trip selection - initialize from localStorage or URL if available
177 188 const [selectedTripId, setSelectedTripId] = useState<number | null>(() => {
@@ -318,8 +329,11 @@
318 329 date.discounted_price?.toString() ||
319 330 date.original_price?.toString() ||
320 331 "0",
321 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 : [],
322 336 status: date.status || "available",
323 337 is_blocked: date.is_blocked || date.status === "blocked",
324 338 block_reason: date.block_reason,
325 339 alert_threshold: date.alert_threshold || 5,
@@ -334,8 +348,23 @@
334 348 },
335 349 enabled: !!selectedTripId,
336 350 });
337 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 +
338 367 // Delete availability date
339 368 const deleteMutation = useMutation({
340 369 mutationFn: async (id: string) => {
341 370 return await apiClient.delete(`/availability/${id}`);
@@ -356,10 +385,11 @@
356 385 },
357 386 });
358 387
359 388 // Format date for display
389 + // eslint-disable-next-line react-hooks/exhaustive-deps
360 390 const formatDate = (dateString: string) => {
361 - const date = new Date(dateString);
391 + const date = toDateValue(dateString);
362 392 return date.toLocaleDateString("en-US", {
363 393 year: "numeric",
364 394 month: "short",
365 395 day: "numeric",
@@ -366,8 +396,9 @@
366 396 });
367 397 };
368 398
369 399 // Format time for display
400 + // eslint-disable-next-line react-hooks/exhaustive-deps
370 401 const formatTime = (timeString: string) => {
371 402 if (!timeString) return "";
372 403 const [hours, minutes] = timeString.split(":");
373 404 const hour = parseInt(hours);
@@ -376,8 +407,9 @@
376 407 return `${displayHour}:${minutes} ${ampm}`;
377 408 };
378 409
379 410 // Get currency symbol
411 + // eslint-disable-next-line react-hooks/exhaustive-deps
380 412 const getCurrencySymbol = (currency: string) => {
381 413 const symbols: Record<string, string> = {
382 414 USD: "$",
383 415 EUR: "€",
@@ -387,8 +419,9 @@
387 419 return symbols[currency] || currency;
388 420 };
389 421
390 422 // Get status badge variant
423 + // eslint-disable-next-line react-hooks/exhaustive-deps
391 424 const getStatusBadge = (status: string, isBlocked?: boolean) => {
392 425 // Debug: Log the status values to help with debugging
393 426
394 427 if (isBlocked) {
@@ -511,9 +544,9 @@
511 544 // Filter by month (only for multi-day trips)
512 545 if (monthFilter !== "all" && selectedTrip?.trip_type !== "single_day") {
513 546 const [year, month] = monthFilter.split("-");
514 547 filtered = filtered.filter((date) => {
515 - const dateObj = new Date(date.departure_date);
548 + const dateObj = toDateValue(date.departure_date);
516 549 return (
517 550 dateObj.getFullYear() === parseInt(year) &&
518 551 dateObj.getMonth() === parseInt(month) - 1
519 552 );
@@ -522,8 +555,42 @@
522 555
523 556 return filtered;
524 557 }, [availabilityData, searchTerm, statusFilter, monthFilter, selectedTrip]);
525 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 +
526 593 // Get available months for filter (only for multi-day trips)
527 594 const availableMonths = useMemo(() => {
528 595 if (!availabilityData?.dates || selectedTrip?.trip_type === "single_day")
529 596 return [];
@@ -529,9 +596,9 @@
529 596 return [];
530 597
531 598 const months = new Set<string>();
532 599 availabilityData.dates.forEach((date) => {
533 - const dateObj = new Date(date.departure_date);
600 + const dateObj = toDateValue(date.departure_date);
534 601 const monthKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}`;
535 602 months.add(monthKey);
536 603 });
537 604
@@ -856,39 +923,64 @@
856 923 cols.push({
857 924 key: "price",
858 925 label: __("Price", "yatra"),
859 926 visible: visibleColumns.price,
860 - render: (date) => (
861 - <div className="flex flex-col gap-1">
862 - {date.discounted_price &&
863 - parseFloat(date.discounted_price) <
864 - parseFloat(date.original_price) ? (
865 - <>
866 - <span className="text-sm line-through text-gray-400">
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">
867 976 {getCurrencySymbol(selectedTrip?.currency || "USD")}
868 977 {parseFloat(date.original_price).toLocaleString()}
869 978 </span>
870 - <div className="flex items-center gap-2">
871 - <span className="text-sm font-semibold text-gray-900 dark:text-white">
872 - {getCurrencySymbol(selectedTrip?.currency || "USD")}
873 - {parseFloat(date.discounted_price).toLocaleString()}
874 - </span>
875 - {date.discount_percentage &&
876 - parseFloat(date.discount_percentage) > 0 && (
877 - <Badge variant="error" className="text-xs">
878 - {date.discount_percentage}% {__("OFF", "yatra")}
879 - </Badge>
880 - )}
881 - </div>
882 - </>
883 - ) : (
884 - <span className="text-sm font-semibold">
885 - {getCurrencySymbol(selectedTrip?.currency || "USD")}
886 - {parseFloat(date.original_price).toLocaleString()}
887 - </span>
888 - )}
889 - </div>
890 - ),
979 + )}
980 + </div>
981 + );
982 + },
891 983 });
892 984
893 985 // Status
894 986 cols.push({
@@ -1293,8 +1385,12 @@
1293 1385 subpage: "trips",
1294 1386 tab: "availability",
1295 1387 action: "create",
1296 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,
1297 1393 })
1298 1394 }
1299 1395 >
1300 1396 <Plus className="w-4 h-4 mr-2" />
@@ -1735,12 +1831,17 @@
1735 1831 </div>
1736 1832 ) : (
1737 1833 <div className="mt-4">
1738 1834 <AvailabilityCalendar
1739 - dates={filteredDates}
1835 + dates={calendarDates}
1740 1836 tripType={selectedTrip?.trip_type}
1741 1837 currency={selectedTrip?.currency || "USD"}
1742 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 + }
1743 1844 navigate({
1744 1845 subpage: "trips",
1745 1846 tab: "availability",
1746 1847 action: "edit",