| @@ -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"; |
| @@ -179,9 +180,9 @@ | ||
| 179 | 180 | const tripIdFromUrl = useMemo(() => { |
| 180 | 181 | const params = new URLSearchParams(window.location.search); |
| 181 | 182 | const tripId = params.get("trip_id"); |
| 182 | 183 | return tripId ? parseInt(tripId) : null; |
| 183 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 184 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 184 | 185 | }, [urlKey]); |
| 185 | 186 | |
| 186 | 187 | // Trip selection - initialize from localStorage or URL if available |
| 187 | 188 | const [selectedTripId, setSelectedTripId] = useState<number | null>(() => { |
| @@ -347,8 +348,23 @@ | ||
| 347 | 348 | }, |
| 348 | 349 | enabled: !!selectedTripId, |
| 349 | 350 | }); |
| 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 | + | |
| 351 | 367 | // Delete availability date |
| 352 | 368 | const deleteMutation = useMutation({ |
| 353 | 369 | mutationFn: async (id: string) => { |
| 354 | 370 | return await apiClient.delete(`/availability/${id}`); |
| @@ -371,9 +387,9 @@ | ||
| 371 | 387 | |
| 372 | 388 | // Format date for display |
| 373 | 389 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 374 | 390 | const formatDate = (dateString: string) => { |
| 375 | - const date = new Date(dateString); | |
| 391 | + const date = toDateValue(dateString); | |
| 376 | 392 | return date.toLocaleDateString("en-US", { |
| 377 | 393 | year: "numeric", |
| 378 | 394 | month: "short", |
| 379 | 395 | day: "numeric", |
| @@ -528,9 +544,9 @@ | ||
| 528 | 544 | // Filter by month (only for multi-day trips) |
| 529 | 545 | if (monthFilter !== "all" && selectedTrip?.trip_type !== "single_day") { |
| 530 | 546 | const [year, month] = monthFilter.split("-"); |
| 531 | 547 | filtered = filtered.filter((date) => { |
| 532 | - const dateObj = new Date(date.departure_date); | |
| 548 | + const dateObj = toDateValue(date.departure_date); | |
| 533 | 549 | return ( |
| 534 | 550 | dateObj.getFullYear() === parseInt(year) && |
| 535 | 551 | dateObj.getMonth() === parseInt(month) - 1 |
| 536 | 552 | ); |
| @@ -539,8 +555,42 @@ | ||
| 539 | 555 | |
| 540 | 556 | return filtered; |
| 541 | 557 | }, [availabilityData, searchTerm, statusFilter, monthFilter, selectedTrip]); |
| 542 | 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 | + | |
| 543 | 593 | // Get available months for filter (only for multi-day trips) |
| 544 | 594 | const availableMonths = useMemo(() => { |
| 545 | 595 | if (!availabilityData?.dates || selectedTrip?.trip_type === "single_day") |
| 546 | 596 | return []; |
| @@ -546,9 +596,9 @@ | ||
| 546 | 596 | return []; |
| 547 | 597 | |
| 548 | 598 | const months = new Set<string>(); |
| 549 | 599 | availabilityData.dates.forEach((date) => { |
| 550 | - const dateObj = new Date(date.departure_date); | |
| 600 | + const dateObj = toDateValue(date.departure_date); | |
| 551 | 601 | const monthKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, "0")}`; |
| 552 | 602 | months.add(monthKey); |
| 553 | 603 | }); |
| 554 | 604 | |
| @@ -880,10 +930,11 @@ | ||
| 880 | 930 | const pts = Array.isArray(date.price_types) ? date.price_types : []; |
| 881 | 931 | const travelerPrices = pts |
| 882 | 932 | .map( |
| 883 | 933 | (pt) => |
| 884 | - Number(pt.sale_price ?? pt.discounted_price ?? pt.original_price) || | |
| 885 | - 0, | |
| 934 | + Number( | |
| 935 | + pt.sale_price ?? pt.discounted_price ?? pt.original_price, | |
| 936 | + ) || 0, | |
| 886 | 937 | ) |
| 887 | 938 | .filter((n) => n > 0); |
| 888 | 939 | if (travelerPrices.length > 0) { |
| 889 | 940 | const sym = getCurrencySymbol(selectedTrip?.currency || "USD"); |
| @@ -897,37 +948,37 @@ | ||
| 897 | 948 | </span> |
| 898 | 949 | ); |
| 899 | 950 | } |
| 900 | 951 | return ( |
| 901 | - <div className="flex flex-col gap-1"> | |
| 902 | - {date.discounted_price && | |
| 903 | - parseFloat(date.discounted_price) < | |
| 904 | - parseFloat(date.original_price) ? ( | |
| 905 | - <> | |
| 906 | - <span className="text-sm line-through text-gray-400"> | |
| 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"> | |
| 907 | 976 | {getCurrencySymbol(selectedTrip?.currency || "USD")} |
| 908 | 977 | {parseFloat(date.original_price).toLocaleString()} |
| 909 | 978 | </span> |
| 910 | - <div className="flex items-center gap-2"> | |
| 911 | - <span className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 912 | - {getCurrencySymbol(selectedTrip?.currency || "USD")} | |
| 913 | - {parseFloat(date.discounted_price).toLocaleString()} | |
| 914 | - </span> | |
| 915 | - {date.discount_percentage && | |
| 916 | - parseFloat(date.discount_percentage) > 0 && ( | |
| 917 | - <Badge variant="error" className="text-xs"> | |
| 918 | - {date.discount_percentage}% {__("OFF", "yatra")} | |
| 919 | - </Badge> | |
| 920 | - )} | |
| 921 | - </div> | |
| 922 | - </> | |
| 923 | - ) : ( | |
| 924 | - <span className="text-sm font-semibold"> | |
| 925 | - {getCurrencySymbol(selectedTrip?.currency || "USD")} | |
| 926 | - {parseFloat(date.original_price).toLocaleString()} | |
| 927 | - </span> | |
| 928 | - )} | |
| 929 | - </div> | |
| 979 | + )} | |
| 980 | + </div> | |
| 930 | 981 | ); |
| 931 | 982 | }, |
| 932 | 983 | }); |
| 933 | 984 | |
| @@ -1780,12 +1831,17 @@ | ||
| 1780 | 1831 | </div> |
| 1781 | 1832 | ) : ( |
| 1782 | 1833 | <div className="mt-4"> |
| 1783 | 1834 | <AvailabilityCalendar |
| 1784 | - dates={filteredDates} | |
| 1835 | + dates={calendarDates} | |
| 1785 | 1836 | tripType={selectedTrip?.trip_type} |
| 1786 | 1837 | currency={selectedTrip?.currency || "USD"} |
| 1787 | 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 | + } | |
| 1788 | 1844 | navigate({ |
| 1789 | 1845 | subpage: "trips", |
| 1790 | 1846 | tab: "availability", |
| 1791 | 1847 | action: "edit", |