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
yatra / resources / js / hooks / useItineraryFormSave.ts

useItineraryFormSave.ts in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/hooks/useItineraryFormSave.ts

616 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Custom hook for ItineraryForm save/mutation logic
3 */
4
5 import { useMutation, useQueryClient } from "@tanstack/react-query";
6 import { apiClient } from "../lib/api-client";
7 import { API_ENDPOINTS } from "../lib/api-endpoints";
8 import { useToast } from "../components/ui/toast";
9 import { __ } from "../lib/i18n";
10 import { ItineraryFormData, ActivityForm } from "./useItineraryFormValidation";
11
12 interface UseItineraryFormSaveParams {
13 isAddDayMode: boolean;
14 isEditMode: boolean;
15 entryId: number | null;
16 activityForms: ActivityForm[];
17 formData: ItineraryFormData;
18 calculateDuration: (
19 startTime?: string,
20 endTime?: string,
21 timeType?: string,
22 ) => string;
23 entryData?: any; // Entry data to check if it's a day entry
24 dayTripData?: any; // Trip data to find day entry
25 tripsData?: any[]; // Trips data to check trip type
26 }
27
28 export const useItineraryFormSave = ({
29 isAddDayMode,
30 isEditMode,
31 entryId,
32 activityForms,
33 formData,
34 calculateDuration,
35 entryData,
36 dayTripData,
37 tripsData,
38 }: UseItineraryFormSaveParams) => {
39 const queryClient = useQueryClient();
40 const { showToast } = useToast();
41
42 const saveMutation = useMutation({
43 mutationFn: async (data: ItineraryFormData) => {
44 // For day mode, create day and all activities
45 if (isAddDayMode) {
46 const tripId = parseInt(data.trip_id);
47 const day = parseInt(data.day);
48 const dayTitle = data.day_title?.trim() || null;
49
50 // In edit mode, update the day entry itself first (only day info, not activities)
51 if (isEditMode && entryId) {
52 // Only send day-specific fields to avoid updating activity titles
53 // The backend requires 'title' field, so we set it to day_title
54 const dayPayload: any = {
55 trip_id: tripId,
56 day: day,
57 day_title: dayTitle,
58 day_description: data.day_description?.trim() || null,
59 title: dayTitle || `Day ${day}`, // Backend requires title field
60 item_type_id: null,
61 item_id: null,
62 status: data.status || "draft",
63 };
64
65 // When editing, we MUST find the day entry ID - never use POST for updates
66 // Find the day entry ID from the data we already have
67 // IMPORTANT: Use the ORIGINAL day number from entryData, not the new day from formData
68 let dayEntryId: number | null = null;
69
70 // First, check if entryData is a day entry (has null item_type_id and item_id)
71 if (
72 entryData &&
73 (entryData.item_type_id === null ||
74 entryData.item_type_id === undefined) &&
75 (entryData.item_id === null || entryData.item_id === undefined)
76 ) {
77 dayEntryId = entryData.id
78 ? parseInt(entryData.id.toString())
79 : null;
80 }
81
82 // If entryData has day_id, query the backend to get the day entry ID
83 // This is the most reliable way when entryData is an activity
84 if (!dayEntryId && entryData && entryData.day_id) {
85 try {
86 const dayEntryResponse = await apiClient.get(
87 `/itinerary/day-entry-by-day-id/${entryData.day_id}`,
88 );
89 const dayEntryData = dayEntryResponse?.data || dayEntryResponse;
90 if (dayEntryData && dayEntryData.day_entry_id) {
91 dayEntryId = parseInt(dayEntryData.day_entry_id.toString());
92 }
93 } catch (error) {
94 // Silently fail and try other methods
95 // Error is logged by the API client if needed
96 }
97 }
98
99 // If still not found and we have dayTripData, try to find from dayTripData
100 if (!dayEntryId && entryData && entryData.day_id && dayTripData) {
101 const itineraryDays = dayTripData.itinerary_days || [];
102 // Find the day that matches entryData.day_id
103 for (const dayData of itineraryDays) {
104 // Check if this day's id matches entryData.day_id
105 if (
106 dayData.id === entryData.day_id ||
107 dayData.day_id === entryData.day_id
108 ) {
109 if (dayData.entries) {
110 // Find entry with null item_type_id/item_id (the day entry)
111 const dayEntry = dayData.entries.find(
112 (e: any) =>
113 (e.item_type_id === null ||
114 e.item_type_id === undefined) &&
115 (e.item_id === null || e.item_id === undefined),
116 );
117 if (dayEntry && dayEntry.id) {
118 dayEntryId = parseInt(dayEntry.id.toString());
119 break;
120 }
121 }
122 }
123 }
124 }
125
126 // If still not found, try to find from dayTripData using ORIGINAL day number
127 // Use entryData.day (original) to find the day entry, not formData.day (new)
128 const originalDay = entryData?.day
129 ? parseInt(entryData.day.toString())
130 : null;
131 if (!dayEntryId && dayTripData && originalDay) {
132 const itineraryDays = dayTripData.itinerary_days || [];
133 const dayData = itineraryDays.find(
134 (d: any) => (d.day_number || d.day) === originalDay,
135 );
136 if (dayData && dayData.entries) {
137 const dayEntry = dayData.entries.find(
138 (e: any) =>
139 (e.item_type_id === null || e.item_type_id === undefined) &&
140 (e.item_id === null || e.item_id === undefined),
141 );
142 if (dayEntry && dayEntry.id) {
143 dayEntryId = parseInt(dayEntry.id.toString());
144 }
145 }
146 }
147
148 // In edit mode, we MUST have a day entry ID - never use POST
149 // If day entry doesn't exist, it's an error (should have been created when day was created)
150 if (!dayEntryId) {
151 throw new Error(
152 "Day entry not found. The day entry should exist when editing. Please refresh the page and try again, or contact support if the issue persists.",
153 );
154 }
155
156 // In edit mode, ALWAYS use PUT - never POST
157 // Days always use mode=day
158 const endpoint = API_ENDPOINTS.ITINERARY_GET(dayEntryId, "day");
159 const dayResponse = await apiClient.put(endpoint, dayPayload);
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
173 const buildActivityPayload = (
174 activityData: ActivityForm["data"],
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 };
236
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;
246 if (
247 !activityData.item_type_id ||
248 !activityData.item_id ||
249 !activityData.title?.trim()
250 ) {
251 throw new Error(
252 `Activity ${index + 1} is missing required fields (item type, item, or title)`,
253 );
254 }
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;
264 }
265 return payload;
266 });
267
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,
285 );
286 throw new Error(
287 `${bulkData.failed} activit${
288 bulkData.failed === 1 ? "y" : "ies"
289 } failed to save${
290 firstError?.error ? `: ${firstError.error}` : ""
291 }`,
292 );
293 }
294 } catch (error: any) {
295 throw new Error(
296 `Failed to save activities: ${error?.message || "Unknown error"}`,
297 );
298 }
299
300 return dayResponse.data || dayResponse;
301 }
302
303 // Create mode: create day and all activities
304 // Filter out empty/invalid activities (those missing required fields)
305 const validActivities = activityForms.filter((af) => {
306 const activityData = af.data;
307 return (
308 activityData.item_type_id &&
309 activityData.item_id &&
310 activityData.title?.trim()
311 );
312 });
313
314 // If no valid activities, create only the day entry
315 if (validActivities.length === 0) {
316 const payload = {
317 trip_id: tripId,
318 day: day,
319 day_title: dayTitle,
320 day_description: data.day_description?.trim() || null,
321 item_type_id: null,
322 item_id: null,
323 title: dayTitle || `Day ${day}`,
324 description: "",
325 location: null,
326 duration: null,
327 start_time: null,
328 end_time: null,
329 time_type: "exact",
330 cost: null,
331 cost_per_person: false,
332 notes: null,
333 included_items: [],
334 excluded_items: [],
335 status: data.status || "draft",
336 };
337
338 const response = await apiClient.post("/itinerary", payload);
339 return response.data || response;
340 }
341
342 // Save all valid activities sequentially to ensure proper ordering and day creation
343 const responses = [];
344 for (let index = 0; index < validActivities.length; index++) {
345 const activityForm = validActivities[index];
346 const activityData = activityForm.data;
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
358 const payload = {
359 trip_id: tripId,
360 day: day,
361 day_title: dayTitle,
362 item_type_id: parseInt(activityData.item_type_id!),
363 item_id: parseInt(activityData.item_id!),
364 title: activityData.title!.trim(),
365 description: (activityData.description || "").trim(),
366 location: (activityData.location || "").trim(),
367 location_latitude: activityData.location_latitude
368 ? parseFloat(activityData.location_latitude)
369 : null,
370 location_longitude: activityData.location_longitude
371 ? parseFloat(activityData.location_longitude)
372 : null,
373 duration:
374 (activityData.duration || "").trim() ||
375 (startTime && endTime
376 ? calculateDuration(startTime, endTime, timeType)
377 : null),
378 start_time: startTime,
379 end_time: endTime,
380 time_type: timeType,
381 cost: activityData.cost ? parseFloat(activityData.cost) : null,
382 cost_per_person: activityData.cost_per_person === true,
383 notes: (activityData.notes || "").trim(),
384 included_items: Array.isArray(activityData.included_items)
385 ? activityData.included_items
386 : [],
387 excluded_items: Array.isArray(activityData.excluded_items)
388 ? activityData.excluded_items
389 : [],
390 gallery: activityData.gallery || [],
391 video_url: activityData.video_url || "",
392 status: activityData.status || "draft",
393 };
394
395 try {
396 const response = await apiClient.post("/itinerary", payload);
397 responses.push(response.data || response);
398 } catch (error: any) {
399 throw new Error(
400 `Failed to save activity ${index + 1}: ${error?.message || "Unknown error"}`,
401 );
402 }
403 }
404
405 // Return the first response (contains day info)
406 return responses[0] || responses;
407 }
408
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;
415 const payload = {
416 trip_id: parseInt(data.trip_id),
417 day: parseInt(data.day),
418 day_title: data.day_title?.trim() || null,
419 item_type_id: parseInt(data.item_type_id),
420 item_id: parseInt(data.item_id),
421 title: data.title.trim(),
422 description: data.description.trim(),
423 location: data.location.trim(),
424 location_latitude: data.location_latitude
425 ? parseFloat(data.location_latitude)
426 : null,
427 location_longitude: data.location_longitude
428 ? parseFloat(data.location_longitude)
429 : null,
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,
438 cost: data.cost ? parseFloat(data.cost) : null,
439 cost_per_person: data.cost_per_person,
440 notes: data.notes.trim(),
441 included_items: data.included_items,
442 excluded_items: data.excluded_items,
443 gallery: data.gallery || [],
444 video_url: data.video_url || "",
445 status: data.status || "draft", // Ensure status is always included
446 };
447
448 if (isEditMode && entryId) {
449 // Ensure status is explicitly included in update payload
450 const updatePayload = {
451 ...payload,
452 status: data.status || "draft", // Explicitly include status for updates
453 };
454 // Pass mode parameter to specify we're updating an activity entry
455 const endpoint = API_ENDPOINTS.ITINERARY_GET(entryId, "activity");
456 const response = await apiClient.put(endpoint, updatePayload);
457 return response.data || response;
458 } else {
459 const response = await apiClient.post("/itinerary", payload);
460 return response.data || response;
461 }
462 },
463 onSuccess: () => {
464 // For day mode, show success message and navigate
465 if (isAddDayMode) {
466 // Count only valid activities (those with required fields filled)
467 const validActivitiesCount = activityForms.filter((af) => {
468 const activityData = af.data;
469 return (
470 activityData.item_type_id &&
471 activityData.item_id &&
472 activityData.title?.trim()
473 );
474 }).length;
475
476 // Check if it's a single-day trip for dynamic messages
477 const selectedTrip = tripsData?.find(
478 (t: any) => t.id.toString() === formData.trip_id,
479 );
480 const isSingleDay = selectedTrip?.trip_type === "single_day";
481
482 if (isEditMode) {
483 // Edit mode messages - dynamic based on trip type
484 if (validActivitiesCount === 0) {
485 const message = isSingleDay
486 ? __("Entry updated successfully!", "yatra")
487 : __("Day updated successfully!", "yatra");
488 showToast(message, "success");
489 } else {
490 const message = isSingleDay
491 ? __(
492 "Entry and activities updated successfully!",
493 "yatra",
494 ).replace(
495 "activities",
496 `${validActivitiesCount} ${validActivitiesCount === 1 ? "activity" : "activities"}`,
497 )
498 : __("Day and activities updated successfully!", "yatra").replace(
499 "activities",
500 `${validActivitiesCount} ${validActivitiesCount === 1 ? "activity" : "activities"}`,
501 );
502 showToast(message, "success");
503 }
504 } else {
505 // Create mode messages - dynamic based on trip type
506 if (validActivitiesCount === 0) {
507 const message = isSingleDay
508 ? __("Entry created successfully!", "yatra")
509 : __("Day created successfully!", "yatra");
510 showToast(message, "success");
511 } else {
512 const message = isSingleDay
513 ? __(
514 "Entry and activities created successfully!",
515 "yatra",
516 ).replace(
517 "activities",
518 `${validActivitiesCount} ${validActivitiesCount === 1 ? "activity" : "activities"}`,
519 )
520 : __("Day and activities created successfully!", "yatra").replace(
521 "activities",
522 `${validActivitiesCount} ${validActivitiesCount === 1 ? "activity" : "activities"}`,
523 );
524 showToast(message, "success");
525 }
526 }
527
528 // Invalidate all itinerary-related queries
529 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
530 queryClient.invalidateQueries({ queryKey: ["itinerary-entries"] });
531 queryClient.invalidateQueries({ queryKey: ["trips"] });
532 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
533
534 // Also invalidate the specific trip query if we have trip_id
535 if (formData.trip_id) {
536 queryClient.invalidateQueries({
537 queryKey: ["trips", parseInt(formData.trip_id)],
538 });
539 queryClient.invalidateQueries({
540 queryKey: ["trip", parseInt(formData.trip_id)],
541 });
542 }
543
544 // Only redirect for new days, not for editing existing days
545 if (!isEditMode) {
546 const day = parseInt(formData.day);
547 const tripId = formData.trip_id;
548 setTimeout(() => {
549 const params = new URLSearchParams({
550 trip_id: tripId,
551 day: day.toString(),
552 });
553 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary&${params.toString()}`;
554 }, 1500);
555 }
556 // For editing, stay on the same page - no redirect
557 return;
558 }
559
560 // For activity mode or edit mode
561 showToast(
562 isEditMode
563 ? __("Itinerary entry updated successfully!", "yatra")
564 : __("Itinerary entry created successfully!", "yatra"),
565 "success",
566 );
567
568 // Invalidate all itinerary-related queries
569 queryClient.invalidateQueries({ queryKey: ["itinerary"] });
570 queryClient.invalidateQueries({ queryKey: ["itinerary-entries"] });
571 queryClient.invalidateQueries({ queryKey: ["trips"] });
572 queryClient.invalidateQueries({ queryKey: ["trips-simple"] });
573
574 // Also invalidate the specific trip query if we have trip_id
575 if (formData.trip_id) {
576 queryClient.invalidateQueries({
577 queryKey: ["trips", parseInt(formData.trip_id)],
578 });
579 queryClient.invalidateQueries({
580 queryKey: ["trip", parseInt(formData.trip_id)],
581 });
582 // Force refetch the trip data
583 queryClient.refetchQueries({
584 queryKey: ["trips", parseInt(formData.trip_id)],
585 });
586 }
587
588 // Force refetch all trips to ensure fresh data
589 queryClient.refetchQueries({ queryKey: ["trips"] });
590 queryClient.refetchQueries({ queryKey: ["trips-simple"] });
591
592 // Only redirect for new entries, not for editing existing entries
593 if (!isEditMode) {
594 setTimeout(() => {
595 const tripIdParam = formData.trip_id
596 ? `&trip_id=${formData.trip_id}`
597 : "";
598 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary${tripIdParam}`;
599 }, 1000);
600 }
601 // For editing, stay on the same page - no redirect
602 },
603 onError: (error: any) => {
604 const errorMessage =
605 error?.message || __("An error occurred while saving", "yatra");
606 showToast(errorMessage, "error");
607 },
608 onSettled: () => {
609 // Reset submitting state when mutation completes (success or error)
610 // This ensures the loading state is properly cleared
611 },
612 });
613
614 return { saveMutation };
615 };
616