PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 2.0.1 All 84 releases
← All changes | resources/js/pages/AvailabilityForm.tsx +83 -24 3.0.7 → 3.0.16 View file →
@@ -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 () => {
@@ -193,8 +218,13 @@
193 218 : tripData.pricing_type || "regular";
194 219
195 220 setFormData((prev) => ({
196 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),
197 227 from_location: tripData.starting_location || "",
198 228 to_location: tripData.ending_location || "",
199 229 from_latitude: coordFromApi(tripData.starting_latitude),
200 230 from_longitude: coordFromApi(tripData.starting_longitude),
@@ -203,9 +233,9 @@
203 233 // Default pricing type based on trip's pricing type
204 234 pricing_type: effectivePricingType as "regular" | "traveler_based",
205 235 }));
206 236 }
207 - }, [tripData, isEditMode]);
237 + }, [tripData, isEditMode, tripMaxTravelers]);
208 238
209 239 useEffect(() => {
210 240 if (availabilityData) {
211 241 const totalSeats =
@@ -352,9 +382,9 @@
352 382 newErrors.arrival_date = __("Arrival date is required", "yatra");
353 383 }
354 384
355 385 // Validate dates and times based on trip type
356 - const isSingleDay = tripData?.trip_type === "single_day";
386 + const isSingleDay = isSingleDayTrip;
357 387
358 388 if (isSingleDay) {
359 389 // For single day trips, both dates should be the same
360 390 if (
@@ -366,21 +396,18 @@
366 396 "For single day trips, departure and arrival must be on the same date",
367 397 "yatra",
368 398 );
369 399 }
370 - // Validate times
371 - 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) {
372 405 newErrors.departure_time = __(
373 - "Departure time is required for single day trips",
406 + "Departure time is required when the trip has multiple time slots",
374 407 "yatra",
375 408 );
376 409 }
377 - if (!formData.arrival_time) {
378 - newErrors.arrival_time = __(
379 - "Arrival time is required for single day trips",
380 - "yatra",
381 - );
382 - }
383 410 // Validate that arrival time is after departure time
384 411 if (
385 412 formData.departure_time &&
386 413 formData.arrival_time &&
@@ -700,9 +727,15 @@
700 727 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
701 728 <div>
702 729 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
703 730 {__("Departure Time", "yatra")}{" "}
704 - <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 + )}
705 738 </label>
706 739 <TimePicker
707 740 value={formData.departure_time}
708 741 onChange={(value: string) =>
@@ -719,9 +752,11 @@
719 752 </div>
720 753 <div>
721 754 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
722 755 {__("Arrival Time", "yatra")}{" "}
723 - <span className="text-red-500">*</span>
756 + <span className="text-gray-400 font-normal">
757 + {__("(optional)", "yatra")}
758 + </span>
724 759 </label>
725 760 <TimePicker
726 761 value={formData.arrival_time}
727 762 onChange={(value: string) =>
@@ -772,9 +807,9 @@
772 807 handleFieldChange("arrival_date", value)
773 808 }
774 809 minDate={
775 810 formData.departure_date
776 - ? new Date(formData.departure_date)
811 + ? toDateValue(formData.departure_date)
777 812 : undefined
778 813 }
779 814 placeholder={__("Select arrival date", "yatra")}
780 815 error={!!errors.arrival_date}
@@ -788,14 +823,27 @@
788 823 formData.departure_date &&
789 824 formData.arrival_date && (
790 825 <div className="mt-1.5">
791 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.)
792 833 const departure = new Date(formData.departure_date);
793 834 const arrival = new Date(formData.arrival_date);
794 - const selectedDays = Math.ceil(
795 - (arrival.getTime() - departure.getTime()) /
796 - (1000 * 60 * 60 * 24),
797 - );
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;
798 846 const expectedDays = tripData.duration_days;
799 847 const diff = selectedDays - expectedDays;
800 848
801 849 if (diff === 0) {
@@ -1506,12 +1554,23 @@
1506 1554 {errors.total_seats}
1507 1555 </p>
1508 1556 )}
1509 1557 <HelpText
1510 - text={__(
1511 - "Maximum number of seats available for this date",
1512 - "yatra",
1513 - )}
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 + }
1514 1573 className="mt-1"
1515 1574 />
1516 1575 </div>
1517 1576 {isEditMode && (