| @@ -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, |