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 +111 -31 3.0.3 → 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 () => {
@@ -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 }