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 +98 -33 3.0.4 → 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 () => {
@@ -184,16 +209,22 @@
184 209 // Load form data when trip or availability data is available
185 210 useEffect(() => {
186 211 if (tripData && !isEditMode) {
187 212 const rawPriceTypes = tripData.price_types;
188 - const hasTravelerPricing =
189 - Array.isArray(rawPriceTypes) ? rawPriceTypes.length > 0 : false;
213 + const hasTravelerPricing = Array.isArray(rawPriceTypes)
214 + ? rawPriceTypes.length > 0
215 + : false;
190 216 const effectivePricingType = hasTravelerPricing
191 217 ? "traveler_based"
192 - : (tripData.pricing_type || "regular");
218 + : tripData.pricing_type || "regular";
193 219
194 220 setFormData((prev) => ({
195 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),
196 227 from_location: tripData.starting_location || "",
197 228 to_location: tripData.ending_location || "",
198 229 from_latitude: coordFromApi(tripData.starting_latitude),
199 230 from_longitude: coordFromApi(tripData.starting_longitude),
@@ -199,14 +230,12 @@
199 230 from_longitude: coordFromApi(tripData.starting_longitude),
200 231 to_latitude: coordFromApi(tripData.ending_latitude),
201 232 to_longitude: coordFromApi(tripData.ending_longitude),
202 233 // Default pricing type based on trip's pricing type
203 - pricing_type: effectivePricingType as
204 - | "regular"
205 - | "traveler_based",
234 + pricing_type: effectivePricingType as "regular" | "traveler_based",
206 235 }));
207 236 }
208 - }, [tripData, isEditMode]);
237 + }, [tripData, isEditMode, tripMaxTravelers]);
209 238
210 239 useEffect(() => {
211 240 if (availabilityData) {
212 241 const totalSeats =
@@ -219,13 +248,14 @@
219 248
220 249 // ALWAYS use trip's pricing type, not the old availability pricing type
221 250 // This ensures availability dates match the current trip pricing configuration
222 251 const rawTripPriceTypes = tripData?.price_types;
223 - const tripHasTravelerPricing =
224 - Array.isArray(rawTripPriceTypes) ? rawTripPriceTypes.length > 0 : false;
252 + const tripHasTravelerPricing = Array.isArray(rawTripPriceTypes)
253 + ? rawTripPriceTypes.length > 0
254 + : false;
225 255 const pricingType = tripHasTravelerPricing
226 256 ? "traveler_based"
227 - : (tripData?.pricing_type || availabilityData.pricing_type || "regular");
257 + : tripData?.pricing_type || availabilityData.pricing_type || "regular";
228 258
229 259 setFormData({
230 260 departure_date: availabilityData.departure_date || "",
231 261 departure_time: availabilityData.departure_time || "",
@@ -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 &&
@@ -537,8 +564,13 @@
537 564 to_latitude: data.to_latitude?.trim() || null,
538 565 to_longitude: data.to_longitude?.trim() || null,
539 566 special_notes: null,
540 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,
541 573 };
542 574
543 575 if (isEditMode && availabilityId) {
544 576 const response = await apiClient.put(
@@ -695,9 +727,15 @@
695 727 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
696 728 <div>
697 729 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
698 730 {__("Departure Time", "yatra")}{" "}
699 - <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 + )}
700 738 </label>
701 739 <TimePicker
702 740 value={formData.departure_time}
703 741 onChange={(value: string) =>
@@ -714,9 +752,11 @@
714 752 </div>
715 753 <div>
716 754 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
717 755 {__("Arrival Time", "yatra")}{" "}
718 - <span className="text-red-500">*</span>
756 + <span className="text-gray-400 font-normal">
757 + {__("(optional)", "yatra")}
758 + </span>
719 759 </label>
720 760 <TimePicker
721 761 value={formData.arrival_time}
722 762 onChange={(value: string) =>
@@ -767,9 +807,9 @@
767 807 handleFieldChange("arrival_date", value)
768 808 }
769 809 minDate={
770 810 formData.departure_date
771 - ? new Date(formData.departure_date)
811 + ? toDateValue(formData.departure_date)
772 812 : undefined
773 813 }
774 814 placeholder={__("Select arrival date", "yatra")}
775 815 error={!!errors.arrival_date}
@@ -783,14 +823,27 @@
783 823 formData.departure_date &&
784 824 formData.arrival_date && (
785 825 <div className="mt-1.5">
786 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.)
787 833 const departure = new Date(formData.departure_date);
788 834 const arrival = new Date(formData.arrival_date);
789 - const selectedDays = Math.ceil(
790 - (arrival.getTime() - departure.getTime()) /
791 - (1000 * 60 * 60 * 24),
792 - );
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;
793 846 const expectedDays = tripData.duration_days;
794 847 const diff = selectedDays - expectedDays;
795 848
796 849 if (diff === 0) {
@@ -1501,12 +1554,23 @@
1501 1554 {errors.total_seats}
1502 1555 </p>
1503 1556 )}
1504 1557 <HelpText
1505 - text={__(
1506 - "Maximum number of seats available for this date",
1507 - "yatra",
1508 - )}
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 + }
1509 1573 className="mt-1"
1510 1574 />
1511 1575 </div>
1512 1576 {isEditMode && (
@@ -1582,8 +1646,9 @@
1582 1646 {__("Block Reason", "yatra")}
1583 1647 </label>
1584 1648 <Input
1585 1649 type="text"
1650 + maxLength={255}
1586 1651 value={formData.block_reason}
1587 1652 onChange={(e) =>
1588 1653 handleFieldChange("block_reason", e.target.value)
1589 1654 }