| @@ -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", |