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 +129 -33 3.0.7 → 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,9 +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;
174 - // eslint-disable-next-line react-hooks/exhaustive-deps
184 + // eslint-disable-next-line react-hooks/exhaustive-deps
175 185 }, [urlKey]);
176 186
177 187 // Trip selection - initialize from localStorage or URL if available
178 188 const [selectedTripId, setSelectedTripId] = useState<number | null>(() => {
@@ -319,8 +329,11 @@
319 329 date.discounted_price?.toString() ||
320 330 date.original_price?.toString() ||
321 331 "0",
322 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 : [],
323 336 status: date.status || "available",
324 337 is_blocked: date.is_blocked || date.status === "blocked",
325 338 block_reason: date.block_reason,
326 339 alert_threshold: date.alert_threshold || 5,
@@ -335,8 +348,23 @@
335 348 },
336 349 enabled: !!selectedTripId,
337 350 });
338 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 +
339 367 // Delete availability date
340 368 const deleteMutation = useMutation({
341 369 mutationFn: async (id: string) => {
342 370 return await apiClient.delete(`/availability/${id}`);
@@ -359,9 +387,9 @@
359 387
360 388 // Format date for display
361 389 // eslint-disable-next-line react-hooks/exhaustive-deps
362 390 const formatDate = (dateString: string) => {
363 - const date = new Date(dateString);
391 + const date = toDateValue(dateString);
364 392 return date.toLocaleDateString("en-US", {
365 393 year: "numeric",
366 394 month: "short",
367 395 day: "numeric",
@@ -516,9 +544,9 @@
516 544 // Filter by month (only for multi-day trips)
517 545 if (monthFilter !== "all" && selectedTrip?.trip_type !== "single_day") {
518 546 const [year, month] = monthFilter.split("-");
519 547 filtered = filtered.filter((date) => {
520 - const dateObj = new Date(date.departure_date);
548 + const dateObj = toDateValue(date.departure_date);
521 549 return (
522 550 dateObj.getFullYear() === parseInt(year) &&
523 551 dateObj.getMonth() === parseInt(month) - 1
524 552 );
@@ -527,8 +555,42 @@
527 555
528 556 return filtered;
529 557 }, [availabilityData, searchTerm, statusFilter, monthFilter, selectedTrip]);
530 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 +
531 593 // Get available months for filter (only for multi-day trips)
532 594 const availableMonths = useMemo(() => {
533 595 if (!availabilityData?.dates || selectedTrip?.trip_type === "single_day")
534 596 return [];
@@ -534,9 +596,9 @@
534 596 return [];
535 597
536 598 const months = new Set<string>();
537 599 availabilityData.dates.forEach((date) => {
538 - const dateObj = new Date(date.departure_date);
600 + const dateObj = toDateValue(date.departure_date);
539 601 const monthKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}`;
540 602 months.add(monthKey);
541 603 });
542 604
@@ -861,39 +923,64 @@
861 923 cols.push({
862 924 key: "price",
863 925 label: __("Price", "yatra"),
864 926 visible: visibleColumns.price,
865 - render: (date) => (
866 - <div className="flex flex-col gap-1">
867 - {date.discounted_price &&
868 - parseFloat(date.discounted_price) <
869 - parseFloat(date.original_price) ? (
870 - <>
871 - <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">
872 976 {getCurrencySymbol(selectedTrip?.currency || "USD")}
873 977 {parseFloat(date.original_price).toLocaleString()}
874 978 </span>
875 - <div className="flex items-center gap-2">
876 - <span className="text-sm font-semibold text-gray-900 dark:text-white">
877 - {getCurrencySymbol(selectedTrip?.currency || "USD")}
878 - {parseFloat(date.discounted_price).toLocaleString()}
879 - </span>
880 - {date.discount_percentage &&
881 - parseFloat(date.discount_percentage) > 0 && (
882 - <Badge variant="error" className="text-xs">
883 - {date.discount_percentage}% {__("OFF", "yatra")}
884 - </Badge>
885 - )}
886 - </div>
887 - </>
888 - ) : (
889 - <span className="text-sm font-semibold">
890 - {getCurrencySymbol(selectedTrip?.currency || "USD")}
891 - {parseFloat(date.original_price).toLocaleString()}
892 - </span>
893 - )}
894 - </div>
895 - ),
979 + )}
980 + </div>
981 + );
982 + },
896 983 });
897 984
898 985 // Status
899 986 cols.push({
@@ -1298,8 +1385,12 @@
1298 1385 subpage: "trips",
1299 1386 tab: "availability",
1300 1387 action: "create",
1301 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,
1302 1393 })
1303 1394 }
1304 1395 >
1305 1396 <Plus className="w-4 h-4 mr-2" />
@@ -1740,12 +1831,17 @@
1740 1831 </div>
1741 1832 ) : (
1742 1833 <div className="mt-4">
1743 1834 <AvailabilityCalendar
1744 - dates={filteredDates}
1835 + dates={calendarDates}
1745 1836 tripType={selectedTrip?.trip_type}
1746 1837 currency={selectedTrip?.currency || "USD"}
1747 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 + }
1748 1844 navigate({
1749 1845 subpage: "trips",
1750 1846 tab: "availability",
1751 1847 action: "edit",