| @@ -14,9 +14,10 @@ | ||
| 14 | 14 | Loader2, |
| 15 | 15 | MapPin, |
| 16 | 16 | CheckCircle2, |
| 17 | 17 | } from "lucide-react"; |
| 18 | -import { __ } from "../lib/i18n"; | |
| 18 | +import { __, sprintf } from "../lib/i18n"; | |
| 19 | +import { toDateValue } from "../lib/dateFormat"; | |
| 19 | 20 | import { Button } from "../components/ui/button"; |
| 20 | 21 | import { Input } from "../components/ui/input"; |
| 21 | 22 | import { Select } from "../components/ui/select"; |
| 22 | 23 | import { PageHeader } from "../components/common/PageHeader"; |
| @@ -90,9 +91,14 @@ | ||
| 90 | 91 | const urlParams = new URLSearchParams(window.location.search); |
| 91 | 92 | const tripIdFromUrl = urlParams.get("trip_id") |
| 92 | 93 | ? parseInt(urlParams.get("trip_id")!) |
| 93 | 94 | : null; |
| 94 | - const availabilityId = urlParams.get("id") || null; | |
| 95 | + // Create vs edit is driven by the `action` param, NOT merely the presence of | |
| 96 | + // an `id`. navigate() merges URL params, so a stale `id` from a prior edit can | |
| 97 | + // linger when the user clicks "Add" (action=create); without this guard the | |
| 98 | + // form would wrongly PUT to that dead id ("Availability date not found"). | |
| 99 | + const availabilityId = | |
| 100 | + urlParams.get("action") === "create" ? null : urlParams.get("id") || null; | |
| 95 | 101 | const isEditMode = !!availabilityId; |
| 96 | 102 | |
| 97 | 103 | // In edit mode, trip_id can come from availability data, so we'll use state |
| 98 | 104 | const [tripId, setTripId] = useState<number | null>(tripIdFromUrl); |
| @@ -141,8 +147,27 @@ | ||
| 141 | 147 | }, |
| 142 | 148 | enabled: !!tripId, |
| 143 | 149 | }); |
| 144 | 150 | |
| 151 | + const isSingleDayTrip = tripData?.trip_type === "single_day"; | |
| 152 | + | |
| 153 | + // A day tour can run several departures a day ("multiple time slots", set on | |
| 154 | + // the trip). Only then does a date need a departure time — it identifies | |
| 155 | + // which slot this row overrides, exactly as the server resolves it | |
| 156 | + // (AvailabilityResolutionService: has_default_time_slots && single_day). | |
| 157 | + // A day tour with one departure stores no time, which every consumer | |
| 158 | + // already handles (CapacityService falls back to the rule/date seats). | |
| 159 | + const hasTimeSlots = isSingleDayTrip && !!tripData?.has_default_time_slots; | |
| 160 | + | |
| 161 | + // The trip's max travellers seeds a new date's capacity so it doesn't have | |
| 162 | + // to be retyped for every date. It stays editable per date, and it is still | |
| 163 | + // stored explicitly: an empty/zero seat count means "sold out" to the | |
| 164 | + // availability layer, so it must never be left blank. | |
| 165 | + const tripMaxTravelers = (() => { | |
| 166 | + const raw = Number(tripData?.max_travelers ?? 0); | |
| 167 | + return Number.isFinite(raw) && raw > 0 ? raw : 0; | |
| 168 | + })(); | |
| 169 | + | |
| 145 | 170 | // Fetch traveler categories |
| 146 | 171 | const { data: categoriesData, isLoading: isLoadingCategories } = useQuery({ |
| 147 | 172 | queryKey: ["traveler-categories"], |
| 148 | 173 | queryFn: async () => { |
| @@ -183,10 +208,23 @@ | ||
| 183 | 208 | |
| 184 | 209 | // Load form data when trip or availability data is available |
| 185 | 210 | useEffect(() => { |
| 186 | 211 | if (tripData && !isEditMode) { |
| 212 | + const rawPriceTypes = tripData.price_types; | |
| 213 | + const hasTravelerPricing = Array.isArray(rawPriceTypes) | |
| 214 | + ? rawPriceTypes.length > 0 | |
| 215 | + : false; | |
| 216 | + const effectivePricingType = hasTravelerPricing | |
| 217 | + ? "traveler_based" | |
| 218 | + : tripData.pricing_type || "regular"; | |
| 219 | + | |
| 187 | 220 | setFormData((prev) => ({ |
| 188 | 221 | ...prev, |
| 222 | + // Only seeds an untouched field — never overwrites a typed value if | |
| 223 | + // the trip query refetches. | |
| 224 | + total_seats: | |
| 225 | + prev.total_seats || | |
| 226 | + (tripMaxTravelers > 0 ? String(tripMaxTravelers) : prev.total_seats), | |
| 189 | 227 | from_location: tripData.starting_location || "", |
| 190 | 228 | to_location: tripData.ending_location || "", |
| 191 | 229 | from_latitude: coordFromApi(tripData.starting_latitude), |
| 192 | 230 | from_longitude: coordFromApi(tripData.starting_longitude), |
| @@ -192,14 +230,12 @@ | ||
| 192 | 230 | from_longitude: coordFromApi(tripData.starting_longitude), |
| 193 | 231 | to_latitude: coordFromApi(tripData.ending_latitude), |
| 194 | 232 | to_longitude: coordFromApi(tripData.ending_longitude), |
| 195 | 233 | // Default pricing type based on trip's pricing type |
| 196 | - pricing_type: (tripData.pricing_type || "regular") as | |
| 197 | - | "regular" | |
| 198 | - | "traveler_based", | |
| 234 | + pricing_type: effectivePricingType as "regular" | "traveler_based", | |
| 199 | 235 | })); |
| 200 | 236 | } |
| 201 | - }, [tripData, isEditMode]); | |
| 237 | + }, [tripData, isEditMode, tripMaxTravelers]); | |
| 202 | 238 | |
| 203 | 239 | useEffect(() => { |
| 204 | 240 | if (availabilityData) { |
| 205 | 241 | const totalSeats = |
| @@ -211,10 +247,15 @@ | ||
| 211 | 247 | const bookedSeats = totalSeats - availableSeats; |
| 212 | 248 | |
| 213 | 249 | // ALWAYS use trip's pricing type, not the old availability pricing type |
| 214 | 250 | // This ensures availability dates match the current trip pricing configuration |
| 215 | - const pricingType = | |
| 216 | - tripData?.pricing_type || availabilityData.pricing_type || "regular"; | |
| 251 | + const rawTripPriceTypes = tripData?.price_types; | |
| 252 | + const tripHasTravelerPricing = Array.isArray(rawTripPriceTypes) | |
| 253 | + ? rawTripPriceTypes.length > 0 | |
| 254 | + : false; | |
| 255 | + const pricingType = tripHasTravelerPricing | |
| 256 | + ? "traveler_based" | |
| 257 | + : tripData?.pricing_type || availabilityData.pricing_type || "regular"; | |
| 217 | 258 | |
| 218 | 259 | setFormData({ |
| 219 | 260 | departure_date: availabilityData.departure_date || "", |
| 220 | 261 | departure_time: availabilityData.departure_time || "", |
| @@ -341,9 +382,9 @@ | ||
| 341 | 382 | newErrors.arrival_date = __("Arrival date is required", "yatra"); |
| 342 | 383 | } |
| 343 | 384 | |
| 344 | 385 | // Validate dates and times based on trip type |
| 345 | - const isSingleDay = tripData?.trip_type === "single_day"; | |
| 386 | + const isSingleDay = isSingleDayTrip; | |
| 346 | 387 | |
| 347 | 388 | if (isSingleDay) { |
| 348 | 389 | // For single day trips, both dates should be the same |
| 349 | 390 | if ( |
| @@ -355,21 +396,18 @@ | ||
| 355 | 396 | "For single day trips, departure and arrival must be on the same date", |
| 356 | 397 | "yatra", |
| 357 | 398 | ); |
| 358 | 399 | } |
| 359 | - // Validate times | |
| 360 | - if (!formData.departure_time) { | |
| 400 | + // Times are only needed when the day tour runs several departures a | |
| 401 | + // day: the departure time is what identifies the slot. A single- | |
| 402 | + // departure day tour saves without times (arrival time is display-only | |
| 403 | + // and never required). | |
| 404 | + if (hasTimeSlots && !formData.departure_time) { | |
| 361 | 405 | newErrors.departure_time = __( |
| 362 | - "Departure time is required for single day trips", | |
| 406 | + "Departure time is required when the trip has multiple time slots", | |
| 363 | 407 | "yatra", |
| 364 | 408 | ); |
| 365 | 409 | } |
| 366 | - if (!formData.arrival_time) { | |
| 367 | - newErrors.arrival_time = __( | |
| 368 | - "Arrival time is required for single day trips", | |
| 369 | - "yatra", | |
| 370 | - ); | |
| 371 | - } | |
| 372 | 410 | // Validate that arrival time is after departure time |
| 373 | 411 | if ( |
| 374 | 412 | formData.departure_time && |
| 375 | 413 | formData.arrival_time && |
| @@ -526,8 +564,13 @@ | ||
| 526 | 564 | to_latitude: data.to_latitude?.trim() || null, |
| 527 | 565 | to_longitude: data.to_longitude?.trim() || null, |
| 528 | 566 | special_notes: null, |
| 529 | 567 | cutoff_hours: 24, |
| 568 | + alert_threshold: data.alert_threshold | |
| 569 | + ? parseInt(data.alert_threshold) | |
| 570 | + : null, | |
| 571 | + is_blocked: data.is_blocked, | |
| 572 | + block_reason: data.is_blocked ? data.block_reason || null : null, | |
| 530 | 573 | }; |
| 531 | 574 | |
| 532 | 575 | if (isEditMode && availabilityId) { |
| 533 | 576 | const response = await apiClient.put( |
| @@ -684,9 +727,15 @@ | ||
| 684 | 727 | <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| 685 | 728 | <div> |
| 686 | 729 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> |
| 687 | 730 | {__("Departure Time", "yatra")}{" "} |
| 688 | - <span className="text-red-500">*</span> | |
| 731 | + {hasTimeSlots ? ( | |
| 732 | + <span className="text-red-500">*</span> | |
| 733 | + ) : ( | |
| 734 | + <span className="text-gray-400 font-normal"> | |
| 735 | + {__("(optional)", "yatra")} | |
| 736 | + </span> | |
| 737 | + )} | |
| 689 | 738 | </label> |
| 690 | 739 | <TimePicker |
| 691 | 740 | value={formData.departure_time} |
| 692 | 741 | onChange={(value: string) => |
| @@ -703,9 +752,11 @@ | ||
| 703 | 752 | </div> |
| 704 | 753 | <div> |
| 705 | 754 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"> |
| 706 | 755 | {__("Arrival Time", "yatra")}{" "} |
| 707 | - <span className="text-red-500">*</span> | |
| 756 | + <span className="text-gray-400 font-normal"> | |
| 757 | + {__("(optional)", "yatra")} | |
| 758 | + </span> | |
| 708 | 759 | </label> |
| 709 | 760 | <TimePicker |
| 710 | 761 | value={formData.arrival_time} |
| 711 | 762 | onChange={(value: string) => |
| @@ -756,9 +807,9 @@ | ||
| 756 | 807 | handleFieldChange("arrival_date", value) |
| 757 | 808 | } |
| 758 | 809 | minDate={ |
| 759 | 810 | formData.departure_date |
| 760 | - ? new Date(formData.departure_date) | |
| 811 | + ? toDateValue(formData.departure_date) | |
| 761 | 812 | : undefined |
| 762 | 813 | } |
| 763 | 814 | placeholder={__("Select arrival date", "yatra")} |
| 764 | 815 | error={!!errors.arrival_date} |
| @@ -772,14 +823,27 @@ | ||
| 772 | 823 | formData.departure_date && |
| 773 | 824 | formData.arrival_date && ( |
| 774 | 825 | <div className="mt-1.5"> |
| 775 | 826 | {(() => { |
| 827 | + // Intentionally `new Date()` (UTC), NOT toDateValue(): | |
| 828 | + // this is a day-COUNT between two dates. UTC midnights | |
| 829 | + // are DST-immune so the diff is a whole number of | |
| 830 | + // days; parsing as local could make it off by the DST | |
| 831 | + // hour and round the duration wrong. (Display of these | |
| 832 | + // dates elsewhere uses the local-safe shared formatter.) | |
| 776 | 833 | const departure = new Date(formData.departure_date); |
| 777 | 834 | const arrival = new Date(formData.arrival_date); |
| 778 | - const selectedDays = Math.ceil( | |
| 779 | - (arrival.getTime() - departure.getTime()) / | |
| 780 | - (1000 * 60 * 60 * 24), | |
| 781 | - ); | |
| 835 | + // INCLUSIVE calendar-day span (both endpoints count): | |
| 836 | + // Jun 25 → Jul 04 is 10 days, not 9. duration_days is | |
| 837 | + // also inclusive (a 9-day trip ends on departure + 8; | |
| 838 | + // see BookingRepository::calculateEndDate), so the two | |
| 839 | + // are compared like-for-like. The bare date difference | |
| 840 | + // was one day short. | |
| 841 | + const selectedDays = | |
| 842 | + Math.round( | |
| 843 | + (arrival.getTime() - departure.getTime()) / | |
| 844 | + (1000 * 60 * 60 * 24), | |
| 845 | + ) + 1; | |
| 782 | 846 | const expectedDays = tripData.duration_days; |
| 783 | 847 | const diff = selectedDays - expectedDays; |
| 784 | 848 | |
| 785 | 849 | if (diff === 0) { |
| @@ -965,10 +1029,14 @@ | ||
| 965 | 1029 | ] |
| 966 | 1030 | : tripData?.starting_latitude && |
| 967 | 1031 | tripData?.starting_longitude |
| 968 | 1032 | ? [ |
| 969 | - parseFloat(String(tripData.starting_latitude)), | |
| 970 | - parseFloat(String(tripData.starting_longitude)), | |
| 1033 | + parseFloat( | |
| 1034 | + String(tripData.starting_latitude), | |
| 1035 | + ), | |
| 1036 | + parseFloat( | |
| 1037 | + String(tripData.starting_longitude), | |
| 1038 | + ), | |
| 971 | 1039 | ] |
| 972 | 1040 | : [20, 0] |
| 973 | 1041 | } |
| 974 | 1042 | defaultZoom={ |
| @@ -1486,12 +1554,23 @@ | ||
| 1486 | 1554 | {errors.total_seats} |
| 1487 | 1555 | </p> |
| 1488 | 1556 | )} |
| 1489 | 1557 | <HelpText |
| 1490 | - text={__( | |
| 1491 | - "Maximum number of seats available for this date", | |
| 1492 | - "yatra", | |
| 1493 | - )} | |
| 1558 | + text={ | |
| 1559 | + tripMaxTravelers > 0 | |
| 1560 | + ? sprintf( | |
| 1561 | + /* translators: %d: the trip's max travellers. */ | |
| 1562 | + __( | |
| 1563 | + "Seats for this date. Pre-filled from the trip's max travellers (%d) — change it for this date if it differs.", | |
| 1564 | + "yatra", | |
| 1565 | + ), | |
| 1566 | + tripMaxTravelers, | |
| 1567 | + ) | |
| 1568 | + : __( | |
| 1569 | + "Maximum number of seats available for this date", | |
| 1570 | + "yatra", | |
| 1571 | + ) | |
| 1572 | + } | |
| 1494 | 1573 | className="mt-1" |
| 1495 | 1574 | /> |
| 1496 | 1575 | </div> |
| 1497 | 1576 | {isEditMode && ( |
| @@ -1567,8 +1646,9 @@ | ||
| 1567 | 1646 | {__("Block Reason", "yatra")} |
| 1568 | 1647 | </label> |
| 1569 | 1648 | <Input |
| 1570 | 1649 | type="text" |
| 1650 | + maxLength={255} | |
| 1571 | 1651 | value={formData.block_reason} |
| 1572 | 1652 | onChange={(e) => |
| 1573 | 1653 | handleFieldChange("block_reason", e.target.value) |
| 1574 | 1654 | } |