| @@ -22,10 +22,36 @@ | ||
| 22 | 22 | time?: string; |
| 23 | 23 | max_capacity: string; |
| 24 | 24 | price_override?: string; |
| 25 | 25 | notes?: string; |
| 26 | + trip_id: string; | |
| 26 | 27 | } |
| 27 | 28 | |
| 29 | +interface TripOption { | |
| 30 | + id: number; | |
| 31 | + title?: string; | |
| 32 | + name?: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +// Trips endpoint returns either data[] or data.data[] depending on the caller; | |
| 36 | +// normalise both to a plain array (mirrors Departures.tsx). | |
| 37 | +function normalizeTripsForDropdown(raw: unknown): TripOption[] { | |
| 38 | + if (Array.isArray(raw)) return raw as TripOption[]; | |
| 39 | + if (raw && typeof raw === "object") { | |
| 40 | + const r = raw as Record<string, unknown>; | |
| 41 | + if (Array.isArray(r.data)) return r.data as TripOption[]; | |
| 42 | + const inner = r.data; | |
| 43 | + if ( | |
| 44 | + inner && | |
| 45 | + typeof inner === "object" && | |
| 46 | + Array.isArray((inner as Record<string, unknown>).data) | |
| 47 | + ) { | |
| 48 | + return (inner as Record<string, unknown>).data as TripOption[]; | |
| 49 | + } | |
| 50 | + } | |
| 51 | + return []; | |
| 52 | +} | |
| 53 | + | |
| 28 | 54 | const DepartureForm: React.FC = () => { |
| 29 | 55 | const queryClient = useQueryClient(); |
| 30 | 56 | const { showToast } = useToast(); |
| 31 | 57 | |
| @@ -42,8 +68,9 @@ | ||
| 42 | 68 | time: "", |
| 43 | 69 | max_capacity: "", |
| 44 | 70 | price_override: "", |
| 45 | 71 | notes: "", |
| 72 | + trip_id: tripId ? String(tripId) : "", | |
| 46 | 73 | }); |
| 47 | 74 | |
| 48 | 75 | const [errors, setErrors] = useState< |
| 49 | 76 | Partial<Record<keyof DepartureFormData, string>> |
| @@ -48,8 +75,21 @@ | ||
| 48 | 75 | const [errors, setErrors] = useState< |
| 49 | 76 | Partial<Record<keyof DepartureFormData, string>> |
| 50 | 77 | >({}); |
| 51 | 78 | |
| 79 | + // Tours for the "Tour" selector so an existing departure can be reassigned | |
| 80 | + // from one tour to another (e.g. to correct a wrong assignment). | |
| 81 | + const { data: tripsData } = useQuery({ | |
| 82 | + queryKey: ["trips", "departure-form-tour-select", "v1"], | |
| 83 | + queryFn: async () => { | |
| 84 | + const response = await apiClient.get("/trips", { | |
| 85 | + params: { per_page: 1000 }, | |
| 86 | + }); | |
| 87 | + return normalizeTripsForDropdown(response); | |
| 88 | + }, | |
| 89 | + }); | |
| 90 | + const tripsList = normalizeTripsForDropdown(tripsData); | |
| 91 | + | |
| 52 | 92 | // Fetch existing departure data if editing |
| 53 | 93 | const { data: departureData, isLoading: isLoadingDeparture } = useQuery({ |
| 54 | 94 | queryKey: ["departure", departureId], |
| 55 | 95 | queryFn: async () => { |
| @@ -70,11 +110,13 @@ | ||
| 70 | 110 | time: departureData.time || "", |
| 71 | 111 | max_capacity: departureData.max_capacity?.toString() || "", |
| 72 | 112 | price_override: departureData.price_override?.toString() || "", |
| 73 | 113 | notes: departureData.notes || "", |
| 114 | + trip_id: | |
| 115 | + departureData.trip_id?.toString() || (tripId ? String(tripId) : ""), | |
| 74 | 116 | }); |
| 75 | 117 | } |
| 76 | - }, [departureData]); | |
| 118 | + }, [departureData, tripId]); | |
| 77 | 119 | |
| 78 | 120 | // Create/Update mutation |
| 79 | 121 | const saveMutation = useMutation({ |
| 80 | 122 | mutationFn: async (data: DepartureFormData) => { |
| @@ -79,10 +121,15 @@ | ||
| 79 | 121 | const saveMutation = useMutation({ |
| 80 | 122 | mutationFn: async (data: DepartureFormData) => { |
| 81 | 123 | if (!tripId) throw new Error("Trip ID is required"); |
| 82 | 124 | |
| 125 | + // In edit mode the operator may reassign the departure to a different | |
| 126 | + // tour via the selector; fall back to the URL trip otherwise. | |
| 127 | + const targetTripId = | |
| 128 | + isEditMode && data.trip_id ? parseInt(data.trip_id) : tripId; | |
| 129 | + | |
| 83 | 130 | const payload: any = { |
| 84 | - trip_id: tripId, | |
| 131 | + trip_id: targetTripId, | |
| 85 | 132 | date: data.date, |
| 86 | 133 | max_capacity: parseInt(data.max_capacity), |
| 87 | 134 | source: "manual", |
| 88 | 135 | }; |
| @@ -101,9 +148,16 @@ | ||
| 101 | 148 | return await apiClient.post(`/trips/${tripId}/departures`, payload); |
| 102 | 149 | } |
| 103 | 150 | }, |
| 104 | 151 | onSuccess: () => { |
| 152 | + // The departure may have been reassigned to another tour, so refresh | |
| 153 | + // both the old and new tour's lists and land on the new tour. | |
| 154 | + const destTripId = | |
| 155 | + isEditMode && formData.trip_id ? parseInt(formData.trip_id) : tripId; | |
| 105 | 156 | queryClient.invalidateQueries({ queryKey: ["departures", tripId] }); |
| 157 | + if (destTripId !== tripId) { | |
| 158 | + queryClient.invalidateQueries({ queryKey: ["departures", destTripId] }); | |
| 159 | + } | |
| 106 | 160 | showToast( |
| 107 | 161 | isEditMode |
| 108 | 162 | ? __("Departure updated successfully", "yatra") |
| 109 | 163 | : __("Departure created successfully", "yatra"), |
| @@ -113,9 +167,9 @@ | ||
| 113 | 167 | // from). Previously this sent operators to the Trips page with a |
| 114 | 168 | // departures-tab query string, which surprised anyone who reached |
| 115 | 169 | // the form via the Departures sidebar entry — the table they |
| 116 | 170 | // expected to see post-save was nowhere on the trip page. |
| 117 | - window.location.href = `?page=yatra&subpage=departures${tripId ? `&trip_id=${tripId}` : ""}`; | |
| 171 | + window.location.href = `?page=yatra&subpage=departures${destTripId ? `&trip_id=${destTripId}` : ""}`; | |
| 118 | 172 | }, |
| 119 | 173 | onError: (error: any) => { |
| 120 | 174 | showToast( |
| 121 | 175 | error?.message || __("Failed to save departure", "yatra"), |
| @@ -187,8 +241,44 @@ | ||
| 187 | 241 | |
| 188 | 242 | <form onSubmit={handleSubmit}> |
| 189 | 243 | <Card> |
| 190 | 244 | <CardContent className="pt-6 space-y-6"> |
| 245 | + {/* Tour (edit mode only): allows reassigning an existing departure | |
| 246 | + from one tour to another. In create mode the tour comes from the | |
| 247 | + page the operator started on. */} | |
| 248 | + {isEditMode && ( | |
| 249 | + <div> | |
| 250 | + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| 251 | + {__("Tour", "yatra")}{" "} | |
| 252 | + <span className="text-red-500">*</span> | |
| 253 | + </label> | |
| 254 | + <select | |
| 255 | + value={formData.trip_id} | |
| 256 | + onChange={(e) => | |
| 257 | + setFormData({ ...formData, trip_id: e.target.value }) | |
| 258 | + } | |
| 259 | + className="w-full rounded-md border px-3 py-2 text-sm bg-white dark:bg-gray-800 dark:text-white border-gray-300 dark:border-gray-600" | |
| 260 | + > | |
| 261 | + {tripsList.length === 0 && formData.trip_id && ( | |
| 262 | + <option value={formData.trip_id}> | |
| 263 | + {__("Loading tours…", "yatra")} | |
| 264 | + </option> | |
| 265 | + )} | |
| 266 | + {tripsList.map((t) => ( | |
| 267 | + <option key={t.id} value={String(t.id)}> | |
| 268 | + {t.title || t.name || `#${t.id}`} | |
| 269 | + </option> | |
| 270 | + ))} | |
| 271 | + </select> | |
| 272 | + <HelpText | |
| 273 | + text={__( | |
| 274 | + "Move this departure to a different tour. Any bookings on it move with it.", | |
| 275 | + "yatra", | |
| 276 | + )} | |
| 277 | + /> | |
| 278 | + </div> | |
| 279 | + )} | |
| 280 | + | |
| 191 | 281 | {/* Date */} |
| 192 | 282 | <div> |
| 193 | 283 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 194 | 284 | {__("Departure Date", "yatra")}{" "} |