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/hooks/useItineraryFormSave.ts +148 -108 3.0.3 → 3.0.16 View file →
@@ -157,52 +157,93 @@
157 157 // Days always use mode=day
158 158 const endpoint = API_ENDPOINTS.ITINERARY_GET(dayEntryId, "day");
159 159 const dayResponse = await apiClient.put(endpoint, dayPayload);
160 160
161 + // Map each activityForm.id → its index in the parent activityForms array.
162 + // The index is what becomes the persisted `order` value (the column on
163 + // yatra_new_trip_itinerary_day_entry, written by the existing repo
164 + // setter we just extended). Drag-and-drop reorders activityForms in the
165 + // parent component, so reading the index here naturally captures the
166 + // user's intended ordering. Using a Map (instead of filtered-array
167 + // index) avoids the off-by-one when existing+new activities are split
168 + // into separate loops below.
169 + const orderById = new Map<string, number>(
170 + activityForms.map((af, idx) => [af.id, idx]),
171 + );
172 +
161 173 const buildActivityPayload = (
162 174 activityData: ActivityForm["data"],
163 - ) => ({
164 - trip_id: tripId,
165 - day: day,
166 - day_title: dayTitle,
167 - item_type_id: parseInt(activityData.item_type_id!),
168 - item_id: parseInt(activityData.item_id!),
169 - title: activityData.title!.trim(),
170 - description: (activityData.description || "").trim(),
171 - location: (activityData.location || "").trim(),
172 - duration:
173 - (activityData.duration || "").trim() ||
174 - (activityData.start_time &&
175 - activityData.end_time &&
176 - activityData.time_type === "exact"
177 - ? calculateDuration(
178 - activityData.start_time,
179 - activityData.end_time,
180 - activityData.time_type,
181 - )
182 - : null),
183 - start_time: activityData.start_time || "08:00",
184 - end_time: activityData.end_time || "17:00",
185 - time_type: activityData.time_type || "exact",
186 - cost: activityData.cost ? parseFloat(activityData.cost) : null,
187 - cost_per_person: activityData.cost_per_person !== false,
188 - notes: (activityData.notes || "").trim(),
189 - included_items: Array.isArray(activityData.included_items)
190 - ? activityData.included_items
191 - : [],
192 - excluded_items: Array.isArray(activityData.excluded_items)
193 - ? activityData.excluded_items
194 - : [],
195 - gallery: activityData.gallery || [],
196 - video_url: activityData.video_url || "",
197 - status: activityData.status || "draft",
198 - });
175 + order: number,
176 + ) => {
177 + // Time fields only apply when time_type is 'exact'. For 'duration'
178 + // the user expresses time only via the duration text; for 'flexible'
179 + // there is no specific time at all. Sending defaults like 08:00
180 + // would silently overwrite the user's intent and pollute every row
181 + // with the same fake clock range.
182 + const timeType = activityData.time_type || "exact";
183 + const startTime =
184 + timeType === "exact" && activityData.start_time
185 + ? activityData.start_time
186 + : null;
187 + const endTime =
188 + timeType === "exact" && activityData.end_time
189 + ? activityData.end_time
190 + : null;
191 + return {
192 + trip_id: tripId,
193 + day: day,
194 + day_title: dayTitle,
195 + item_type_id: parseInt(activityData.item_type_id!),
196 + item_id: parseInt(activityData.item_id!),
197 + title: activityData.title!.trim(),
198 + description: (activityData.description || "").trim(),
199 + location: (activityData.location || "").trim(),
200 + // Latitude / longitude were saved by the form but never sent in the
201 + // payload before — saving silently dropped them. Send as floats; let
202 + // the repo coerce empty strings to null.
203 + location_latitude:
204 + activityData.location_latitude !== undefined &&
205 + activityData.location_latitude !== ""
206 + ? parseFloat(activityData.location_latitude as any)
207 + : null,
208 + location_longitude:
209 + activityData.location_longitude !== undefined &&
210 + activityData.location_longitude !== ""
211 + ? parseFloat(activityData.location_longitude as any)
212 + : null,
213 + duration:
214 + (activityData.duration || "").trim() ||
215 + (startTime && endTime
216 + ? calculateDuration(startTime, endTime, timeType)
217 + : null),
218 + start_time: startTime,
219 + end_time: endTime,
220 + time_type: timeType,
221 + cost: activityData.cost ? parseFloat(activityData.cost) : null,
222 + cost_per_person: activityData.cost_per_person === true,
223 + notes: (activityData.notes || "").trim(),
224 + included_items: Array.isArray(activityData.included_items)
225 + ? activityData.included_items
226 + : [],
227 + excluded_items: Array.isArray(activityData.excluded_items)
228 + ? activityData.excluded_items
229 + : [],
230 + gallery: activityData.gallery || [],
231 + video_url: activityData.video_url || "",
232 + status: activityData.status || "draft",
233 + order: Math.max(0, order),
234 + };
235 + };
199 236
200 - const existingActivities = activityForms.filter((af) => af.entryId);
201 - for (let index = 0; index < existingActivities.length; index++) {
202 - const activityForm = existingActivities[index];
203 - const activityData = activityForm.data;
204 -
237 + // BATCH SAVE — replaces the previous loop that fired one PUT per
238 + // activity (5 activities = 5 sequential round-trips). We collect every
239 + // activity into a single payload and POST to the bulk endpoint, which
240 + // dispatches each row to update / create server-side and invalidates
241 + // caches once at the end. Each row carries `id` for updates or omits
242 + // it for creates; `order` is the index in `activityForms` so the
243 + // user's drag-sort ordering is what gets persisted.
244 + const validatedRows = activityForms.map((af, index) => {
245 + const activityData = af.data;
205 246 if (
206 247 !activityData.item_type_id ||
207 248 !activityData.item_id ||
208 249 !activityData.title?.trim()
@@ -210,67 +251,53 @@
210 251 throw new Error(
211 252 `Activity ${index + 1} is missing required fields (item type, item, or title)`,
212 253 );
213 254 }
214 -
215 - const payload = buildActivityPayload(activityData);
216 - const activityEntryId =
217 - typeof activityForm.entryId === "string"
218 - ? parseInt(activityForm.entryId, 10)
219 - : activityForm.entryId;
220 -
221 - if (!activityEntryId) {
222 - throw new Error(
223 - `Activity ${index + 1} is missing an entry ID for update.`,
224 - );
255 + const order = orderById.get(af.id) ?? index;
256 + const payload = buildActivityPayload(activityData, order) as any;
257 + // Update vs create is determined by presence of `id`. For existing
258 + // activities we send the entryId; for newly added rows we omit it.
259 + if (af.entryId) {
260 + payload.id =
261 + typeof af.entryId === "string"
262 + ? parseInt(af.entryId, 10)
263 + : af.entryId;
225 264 }
265 + return payload;
266 + });
226 267
227 - try {
228 - // Activities always use mode=activity
229 - const endpoint = API_ENDPOINTS.ITINERARY_GET(
230 - activityEntryId,
231 - "activity",
268 + // dayEntryId here is the yatra_new_trip_itinerary_days.id row — same
269 + // value that the day-update PUT just used a few lines above. The bulk
270 + // endpoint scopes all writes to that day_id.
271 + const bulkEndpoint =
272 + API_ENDPOINTS.ITINERARY_DAY_ACTIVITIES_BULK(dayEntryId);
273 + try {
274 + const bulkResponse = await apiClient.put(bulkEndpoint, {
275 + trip_id: tripId,
276 + activities: validatedRows,
277 + });
278 + const bulkData =
279 + (bulkResponse as any)?.data || (bulkResponse as any) || {};
280 + // Surface a partial-failure error so the user knows something
281 + // didn't save instead of silently swallowing it.
282 + if (bulkData.failed && bulkData.failed > 0) {
283 + const firstError = (bulkData.results || []).find(
284 + (r: any) => r && r.ok === false,
232 285 );
233 - await apiClient.put(endpoint, payload);
234 - } catch (error: any) {
235 286 throw new Error(
236 - `Failed to update activity ${index + 1}: ${error?.message || "Unknown error"}`,
287 + `${bulkData.failed} activit${
288 + bulkData.failed === 1 ? "y" : "ies"
289 + } failed to save${
290 + firstError?.error ? `: ${firstError.error}` : ""
291 + }`,
237 292 );
238 293 }
294 + } catch (error: any) {
295 + throw new Error(
296 + `Failed to save activities: ${error?.message || "Unknown error"}`,
297 + );
239 298 }
240 299
241 - const newActivities = activityForms.filter((af) => !af.entryId);
242 -
243 - if (newActivities.length > 0) {
244 - const responses = [dayResponse.data || dayResponse];
245 - for (let index = 0; index < newActivities.length; index++) {
246 - const activityForm = newActivities[index];
247 - const activityData = activityForm.data;
248 -
249 - if (
250 - !activityData.item_type_id ||
251 - !activityData.item_id ||
252 - !activityData.title?.trim()
253 - ) {
254 - throw new Error(
255 - `Activity ${index + 1} is missing required fields (item type, item, or title)`,
256 - );
257 - }
258 -
259 - const payload = buildActivityPayload(activityData);
260 -
261 - try {
262 - const response = await apiClient.post("/itinerary", payload);
263 - responses.push(response.data || response);
264 - } catch (error: any) {
265 - throw new Error(
266 - `Failed to save activity ${index + 1}: ${error?.message || "Unknown error"}`,
267 - );
268 - }
269 - }
270 - return responses[0];
271 - }
272 -
273 300 return dayResponse.data || dayResponse;
274 301 }
275 302
276 303 // Create mode: create day and all activities
@@ -317,8 +344,18 @@
317 344 for (let index = 0; index < validActivities.length; index++) {
318 345 const activityForm = validActivities[index];
319 346 const activityData = activityForm.data;
320 347
348 + const timeType = activityData.time_type || "exact";
349 + const startTime =
350 + timeType === "exact" && activityData.start_time
351 + ? activityData.start_time
352 + : null;
353 + const endTime =
354 + timeType === "exact" && activityData.end_time
355 + ? activityData.end_time
356 + : null;
357 +
321 358 const payload = {
322 359 trip_id: tripId,
323 360 day: day,
324 361 day_title: dayTitle,
@@ -334,22 +371,16 @@
334 371 ? parseFloat(activityData.location_longitude)
335 372 : null,
336 373 duration:
337 374 (activityData.duration || "").trim() ||
338 - (activityData.start_time &&
339 - activityData.end_time &&
340 - activityData.time_type === "exact"
341 - ? calculateDuration(
342 - activityData.start_time,
343 - activityData.end_time,
344 - activityData.time_type,
345 - )
375 + (startTime && endTime
376 + ? calculateDuration(startTime, endTime, timeType)
346 377 : null),
347 - start_time: activityData.start_time || "08:00",
348 - end_time: activityData.end_time || "17:00",
349 - time_type: activityData.time_type || "exact",
378 + start_time: startTime,
379 + end_time: endTime,
380 + time_type: timeType,
350 381 cost: activityData.cost ? parseFloat(activityData.cost) : null,
351 - cost_per_person: activityData.cost_per_person !== false,
382 + cost_per_person: activityData.cost_per_person === true,
352 383 notes: (activityData.notes || "").trim(),
353 384 included_items: Array.isArray(activityData.included_items)
354 385 ? activityData.included_items
355 386 : [],
@@ -375,8 +406,13 @@
375 406 return responses[0] || responses;
376 407 }
377 408
378 409 // For activity mode, use all fields
410 + const timeType = data.time_type || "exact";
411 + const startTime =
412 + timeType === "exact" && data.start_time ? data.start_time : null;
413 + const endTime =
414 + timeType === "exact" && data.end_time ? data.end_time : null;
379 415 const payload = {
380 416 trip_id: parseInt(data.trip_id),
381 417 day: parseInt(data.day),
382 418 day_title: data.day_title?.trim() || null,
@@ -390,12 +426,16 @@
390 426 : null,
391 427 location_longitude: data.location_longitude
392 428 ? parseFloat(data.location_longitude)
393 429 : null,
394 - duration: data.duration.trim() || calculateDuration(),
395 - start_time: data.start_time,
396 - end_time: data.end_time,
397 - time_type: data.time_type,
430 + duration:
431 + data.duration.trim() ||
432 + (startTime && endTime
433 + ? calculateDuration(startTime, endTime, timeType)
434 + : null),
435 + start_time: startTime,
436 + end_time: endTime,
437 + time_type: timeType,
398 438 cost: data.cost ? parseFloat(data.cost) : null,
399 439 cost_per_person: data.cost_per_person,
400 440 notes: data.notes.trim(),
401 441 included_items: data.included_items,