PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 / pages / ItineraryForm.tsx

ItineraryForm.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/ItineraryForm.tsx

1,189 lines 40.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Itinerary Form Page - Refactored with separated concerns
3 * Add/Edit Itinerary Entry form with location, duration, cost, notes, etc.
4 */
5
6 import React, { useState, useEffect, useMemo, useRef } from "react";
7 import { useQueryClient } from "@tanstack/react-query";
8 import { ArrowLeft, Save, Loader2 } from "lucide-react";
9 import { __ } from "../lib/i18n";
10 import { Button } from "../components/ui/button";
11 import { Select } from "../components/ui/select";
12 import { PageHeader } from "../components/common/PageHeader";
13 import {
14 Card,
15 CardContent,
16 CardHeader,
17 CardTitle,
18 } from "../components/ui/card";
19 import { ConditionalRender } from "../components/ui/conditional-render";
20 import { HelpText } from "../components/ui/help-text";
21 import { Alert } from "../components/ui/alert";
22 import { ItineraryEntryFields } from "../components/trip-form/shared/ItineraryEntryFields";
23 import { useItineraryFormData } from "../hooks/useItineraryFormData";
24 import {
25 useItineraryFormValidation,
26 ItineraryFormData,
27 ActivityForm,
28 } from "../hooks/useItineraryFormValidation";
29 import { useItineraryFormSave } from "../hooks/useItineraryFormSave";
30 import { DayFormFields } from "../components/itinerary/DayFormFields";
31 import { ActivityAccordion } from "../components/itinerary/ActivityAccordion";
32 import { DayConflictDialog } from "../components/itinerary/DayConflictDialog";
33 import { ItineraryFormSkeleton } from "../components/itinerary/ItineraryFormSkeleton";
34
35 const ItineraryForm: React.FC = () => {
36 const queryClient = useQueryClient();
37
38 // URL Parameters
39 const action = useMemo(() => {
40 const params = new URLSearchParams(window.location.search);
41 return params.get("action") || "create";
42 }, []);
43
44 const entryId = useMemo(() => {
45 const params = new URLSearchParams(window.location.search);
46 return params.get("id") ? parseInt(params.get("id") || "0") : null;
47 }, []);
48
49 const tripIdParam = useMemo(() => {
50 const params = new URLSearchParams(window.location.search);
51 return params.get("trip_id");
52 }, []);
53
54 const dayParam = useMemo(() => {
55 const params = new URLSearchParams(window.location.search);
56 return params.get("day");
57 }, []);
58
59 const typeIdParam = useMemo(() => {
60 const params = new URLSearchParams(window.location.search);
61 return params.get("type_id");
62 }, []);
63
64 const itemParam = useMemo(() => {
65 const params = new URLSearchParams(window.location.search);
66 return params.get("item");
67 }, []);
68
69 const modeParam = useMemo(() => {
70 const params = new URLSearchParams(window.location.search);
71 return params.get("mode") || "activity";
72 }, []);
73
74 const isEditMode = action === "edit" && entryId !== null;
75 const isAddDayMode = modeParam === "day";
76 const isEditDayMode = isEditMode && modeParam === "day";
77
78 // Form State
79 const [formData, setFormData] = useState<ItineraryFormData>({
80 trip_id: "",
81 day: "",
82 day_title: "",
83 day_description: "",
84 item_type_id: "",
85 item_id: "",
86 title: "",
87 description: "",
88 location: "",
89 location_latitude: "",
90 location_longitude: "",
91 duration: "",
92 start_time: "",
93 end_time: "",
94 time_type: "exact",
95 cost: "",
96 cost_per_person: true,
97 notes: "",
98 included_items: [],
99 excluded_items: [],
100 gallery: [],
101 video_url: "",
102 status: "publish",
103 });
104
105 const [errors, setErrors] = useState<Record<string, string>>({});
106 const [newIncludedItem, setNewIncludedItem] = useState("");
107 const [newExcludedItem, setNewExcludedItem] = useState("");
108 const [activityForms, setActivityForms] = useState<ActivityForm[]>([]);
109 const [activityIncludedItems, setActivityIncludedItems] = useState<
110 Record<string, string>
111 >({});
112 const [activityExcludedItems, setActivityExcludedItems] = useState<
113 Record<string, string>
114 >({});
115 const [showDayConflictDialog, setShowDayConflictDialog] = useState(false);
116 const [conflictDayNumber, setConflictDayNumber] = useState<number | null>(
117 null,
118 );
119 const [suggestedDayNumber, setSuggestedDayNumber] = useState<number | null>(
120 null,
121 );
122 const [originalDayNumber, setOriginalDayNumber] = useState<number | null>(
123 null,
124 ); // Track original day number when editing
125
126 // Refs for preventing infinite loops
127 const dayManuallyEditedRef = useRef(false);
128 const lastTripIdRef = useRef<string | null>(null);
129 const loadedDayActivitiesRef = useRef<string | null>(null);
130 const initializedEntryIdRef = useRef<number | null>(null);
131
132 // Fetch all data using custom hook
133 const {
134 tripsData,
135 isLoadingTrips,
136 typesData,
137 itemsData,
138 entryData,
139 isLoadingEntry,
140 dayTripData,
141 effectiveTripData,
142 entryTripId,
143 entryDay,
144 } = useItineraryFormData({
145 entryId,
146 isEditMode,
147 isEditDayMode,
148 isAddDayMode,
149 tripIdParam,
150 dayParam,
151 formDataItemTypeId: formData.item_type_id || "",
152 formDataTripId: formData.trip_id,
153 });
154
155 // Function to refresh dropdown data after creating new item types/items
156 const refreshData = () => {
157 // Invalidate item types query
158 queryClient.invalidateQueries({ queryKey: ["item-types-published"] });
159
160 // Invalidate items queries
161 queryClient.invalidateQueries({ queryKey: ["items-all-published"] });
162 queryClient.invalidateQueries({ queryKey: ["items-by-type"] });
163 };
164
165 // Auto-select item from URL param
166 useEffect(() => {
167 if (itemParam && itemsData && itemsData.length > 0) {
168 const item = itemsData.find(
169 (i: any) => i.name.toLowerCase() === itemParam.toLowerCase(),
170 );
171 if (item) {
172 setFormData((prev) => ({ ...prev, item_id: item.id.toString() }));
173 }
174 }
175 }, [itemParam, itemsData]);
176
177 // Validation hook
178 const { validateForm } = useItineraryFormValidation();
179
180 // Calculate duration helper
181 const calculateDuration = (
182 startTime?: string,
183 endTime?: string,
184 timeType?: string,
185 ) => {
186 const st = startTime || formData.start_time;
187 const et = endTime || formData.end_time;
188 const tt = timeType || formData.time_type;
189 if (st && et && tt === "exact") {
190 const [startHour, startMin] = st.split(":").map(Number);
191 const [endHour, endMin] = et.split(":").map(Number);
192 const startMinutes = startHour * 60 + startMin;
193 const endMinutes = endHour * 60 + endMin;
194 let diffMinutes = endMinutes - startMinutes;
195 if (diffMinutes < 0) diffMinutes += 24 * 60;
196 const hours = Math.floor(diffMinutes / 60);
197 const minutes = diffMinutes % 60;
198 if (hours > 0 && minutes > 0) {
199 return `${hours} hour${hours > 1 ? "s" : ""} ${minutes} minute${minutes > 1 ? "s" : ""}`;
200 } else if (hours > 0) {
201 return `${hours} hour${hours > 1 ? "s" : ""}`;
202 } else {
203 return `${minutes} minute${minutes > 1 ? "s" : ""}`;
204 }
205 }
206 return "";
207 };
208
209 // Save hook
210 const { saveMutation } = useItineraryFormSave({
211 isAddDayMode,
212 isEditMode,
213 entryId,
214 activityForms,
215 formData,
216 calculateDuration,
217 entryData,
218 dayTripData,
219 tripsData,
220 });
221
222 // Reset refs when entry ID changes
223 useEffect(() => {
224 if (entryId !== initializedEntryIdRef.current) {
225 initializedEntryIdRef.current = null;
226 loadedDayActivitiesRef.current = null;
227 isLoadingInitialDataRef.current = false;
228 }
229 }, [entryId]);
230
231 // Initialize from URL params
232 useEffect(() => {
233 if (tripIdParam) {
234 setFormData((prev) => ({ ...prev, trip_id: tripIdParam }));
235 lastTripIdRef.current = tripIdParam;
236 }
237 if (dayParam) {
238 setFormData((prev) => ({ ...prev, day: dayParam }));
239 dayManuallyEditedRef.current = true;
240 }
241 if (typeIdParam) {
242 setFormData((prev) => ({ ...prev, item_type_id: typeIdParam }));
243 }
244 }, [tripIdParam, dayParam, typeIdParam]); // Remove isAddDayMode dependency
245
246 // Auto-select item from URL param
247 useEffect(() => {
248 if (itemParam && itemsData && itemsData.length > 0) {
249 const item = itemsData.find(
250 (i: any) => i.name.toLowerCase() === itemParam.toLowerCase(),
251 );
252 if (item) {
253 setFormData((prev) => ({ ...prev, item_id: item.id.toString() }));
254 }
255 }
256 }, [itemParam, itemsData]);
257
258 // Load entry data when editing
259 useEffect(() => {
260 if (!entryData || !isEditMode || isLoadingInitialDataRef.current) return;
261
262 // Debug logging to see what data we received
263
264 if (!entryData || !entryData.id) {
265 return;
266 }
267
268 // In edit day mode, if entryData is an activity (has item_type_id and item_id),
269 // we should wait for the day entry to be loaded (effectiveEntryData will be the day entry)
270 if (isEditDayMode) {
271 // Check if this is an activity entry (has non-zero item_type_id and item_id)
272 // Day entries have item_type_id: 0 and item_id: 0
273 const isActivityEntry =
274 entryData.item_type_id !== null &&
275 entryData.item_type_id !== undefined &&
276 entryData.item_type_id !== 0 &&
277 entryData.item_id !== null &&
278 entryData.item_id !== undefined &&
279 entryData.item_id !== 0;
280 // If it's an activity, wait - the hook will fetch the day entry and update entryData
281 if (isActivityEntry) {
282 // Check if this is the same activity we've already processed
283 if (initializedEntryIdRef.current === entryData.id) return;
284 // Otherwise, wait for day entry to load
285 return;
286 }
287 }
288
289 // Prevent re-loading the same entry
290 if (initializedEntryIdRef.current === entryData.id) return;
291
292 // Mark as loading initial data
293 isLoadingInitialDataRef.current = true;
294
295 // Mark as initialized immediately (before any state updates)
296 initializedEntryIdRef.current = entryData.id;
297
298 if (!isEditDayMode) {
299 // For activity editing, load directly into formData
300
301 setFormData({
302 trip_id: entryData.trip_id?.toString() || "",
303 day: entryData.day?.toString() || "",
304 day_title: entryData.day_title || "",
305 day_description: entryData.day_description || "",
306 item_type_id: entryData.item_type_id?.toString() || "",
307 item_id: entryData.item_id?.toString() || "",
308 title: entryData.title || "",
309 description: entryData.description || "",
310 location: entryData.location || "",
311 location_latitude: entryData.location_latitude || "",
312 location_longitude: entryData.location_longitude || "",
313 duration: entryData.duration || "",
314 // Don't fake an "08:00" default for entries that were saved without a
315 // time — empty inputs let the user see "no time set" via the picker
316 // placeholder, and prevents the next save from baking the fake default
317 // back into the row.
318 start_time: entryData.start_time || "",
319 end_time: entryData.end_time || "",
320 time_type: entryData.time_type || "exact",
321 cost: entryData.cost || "",
322 cost_per_person: entryData.cost_per_person !== false,
323 notes: entryData.notes || "",
324 included_items: entryData.included_items || [],
325 excluded_items: entryData.excluded_items || [],
326 gallery: entryData.gallery || [],
327 video_url: entryData.video_url || "",
328 status: entryData.status || "publish",
329 });
330 } else if (isEditDayMode) {
331 // For day editing, entryData should now be the day entry (thanks to effectiveEntryData in useItineraryFormData)
332 // Load all day info into formData
333 const dayNumber = entryData.day
334 ? parseInt(entryData.day.toString())
335 : null;
336 setOriginalDayNumber(dayNumber);
337
338 setFormData((prev) => ({
339 ...prev,
340 trip_id: entryData.trip_id?.toString() || tripIdParam || prev.trip_id,
341 day: entryData.day?.toString() || dayParam || prev.day,
342 day_title: entryData.day_title || prev.day_title,
343 day_description: entryData.day_description || prev.day_description,
344 gallery: entryData.gallery || prev.gallery,
345 video_url: entryData.video_url || prev.video_url,
346 status: entryData.status || prev.status,
347 }));
348 }
349
350 // Clear loading flag after form data is set
351 setTimeout(() => {
352 isLoadingInitialDataRef.current = false;
353 }, 100);
354 }, [
355 entryId,
356 isEditMode,
357 isEditDayMode,
358 entryData,
359 tripIdParam,
360 dayParam,
361 dayTripData,
362 ]); // Include entryData to trigger when data arrives
363
364 // Load activities for day editing
365 useEffect(() => {
366 if (!isEditDayMode) return;
367 if (!entryTripId || !entryDay) return;
368 if (!dayTripData) return;
369
370 const dayKey = `${entryTripId}-${entryDay}`;
371 if (loadedDayActivitiesRef.current === dayKey) return;
372
373 loadedDayActivitiesRef.current = dayKey;
374
375 const dayNumber =
376 typeof entryDay === "string" ? parseInt(entryDay) : entryDay;
377 if (isNaN(dayNumber as number)) return;
378
379 const itineraryDays = dayTripData.itinerary_days || [];
380 const dayData = itineraryDays.find(
381 (d: any) => (d.day_number || d.day) === dayNumber,
382 );
383
384 if (!dayData || !dayData.entries) return;
385
386 // Sort by the persisted `order` column first (drag-sort writes here). Fall back
387 // to existing array order so activities created before the order column was
388 // honoured don't get shuffled. Tie-break on entry id so the order is stable.
389 const activities = dayData.entries
390 .filter((e: any) => e.item_type_id && e.item_id)
391 .slice()
392 .sort((a: any, b: any) => {
393 const ao = Number(a.order ?? 0);
394 const bo = Number(b.order ?? 0);
395 if (ao !== bo) return ao - bo;
396 return Number(a.id ?? 0) - Number(b.id ?? 0);
397 })
398 .map((entry: any, index: number) => ({
399 id: `activity-${entry.id || index}`,
400 entryId: entry.id ? parseInt(entry.id.toString()) : null, // Store actual entry ID for updates
401 data: {
402 item_type_id: entry.item_type_id?.toString() || "",
403 item_id: entry.item_id?.toString() || "",
404 title: entry.title || "",
405 description: entry.description || "",
406 location: entry.location || "",
407 // The form has lat/lng inputs and the DB column accepts them — without
408 // mapping here, opening an existing activity in edit mode would show
409 // empty coordinate fields and silently overwrite saved values.
410 location_latitude:
411 entry.location_latitude != null
412 ? entry.location_latitude.toString()
413 : "",
414 location_longitude:
415 entry.location_longitude != null
416 ? entry.location_longitude.toString()
417 : "",
418 duration: entry.duration || "",
419 start_time: entry.start_time || "",
420 end_time: entry.end_time || "",
421 time_type: entry.time_type || "exact",
422 cost: entry.cost || "",
423 cost_per_person: entry.cost_per_person !== false,
424 notes: entry.notes || "",
425 included_items: entry.included_items || [],
426 excluded_items: entry.excluded_items || [],
427 // Gallery + video_url are saved by buildActivityPayload but were never
428 // re-loaded — admin would lose them after a single re-save.
429 gallery: Array.isArray(entry.gallery) ? entry.gallery : [],
430 video_url: entry.video_url || "",
431 status: entry.status || "publish",
432 },
433 isExpanded: index === 0,
434 }));
435
436 setActivityForms(activities);
437
438 const includedItems: Record<string, string> = {};
439 const excludedItems: Record<string, string> = {};
440 activities.forEach((activity: { id: string }) => {
441 includedItems[activity.id] = "";
442 excludedItems[activity.id] = "";
443 });
444 setActivityIncludedItems(includedItems);
445 setActivityExcludedItems(excludedItems);
446 }, [isEditDayMode, entryTripId, entryDay, dayTripData]);
447
448 // Track if we're loading initial data to prevent item_id reset during load
449 const isLoadingInitialDataRef = useRef(false);
450
451 // Reset item_id when type changes (but not during initial data load)
452 useEffect(() => {
453 // Don't reset during initial data load
454 if (isLoadingInitialDataRef.current) return;
455
456 // Only reset if item_type_id changes and item_id is set
457 // This allows users to change item type and forces them to select a new item
458 if (formData.item_type_id && formData.item_id) {
459 // Check if we need to reset - this will be handled by user interaction
460 // We don't auto-reset here to allow the form to work properly
461 }
462 // eslint-disable-next-line react-hooks/exhaustive-deps
463 }, [formData.item_type_id]);
464
465 // Get day title from trip data
466 const dayTitle = useMemo(() => {
467 if (!effectiveTripData || !dayParam) return null;
468 const itineraryDays = effectiveTripData.itinerary_days || [];
469 const dayNumber = parseInt(dayParam);
470 const day = itineraryDays.find(
471 (d: any) => (d.day_number || d.day) === dayNumber,
472 );
473 return day?.title || day?.day_title || null;
474 }, [effectiveTripData, dayParam]);
475
476 // Get trip type (single_day or multi_day)
477 const selectedTripType = useMemo(() => {
478 if (!formData.trip_id) return null;
479 const trip = tripsData?.find(
480 (t: any) => t.id.toString() === formData.trip_id,
481 );
482 return trip?.trip_type || null;
483 }, [formData.trip_id, tripsData]);
484
485 const isSingleDayTrip = selectedTripType === "single_day";
486
487 const submitButtonLabel = useMemo(() => {
488 if (isEditMode) {
489 if (isAddDayMode) {
490 return isSingleDayTrip
491 ? __("Update Entry", "yatra")
492 : __("Update Day", "yatra");
493 }
494 return __("Update Itinerary Activity", "yatra");
495 }
496
497 if (isAddDayMode) {
498 return isSingleDayTrip
499 ? __("Create Entry", "yatra")
500 : __("Create Day", "yatra");
501 }
502
503 return __("Create Itinerary Activity", "yatra");
504 }, [isEditMode, isAddDayMode, isSingleDayTrip]);
505
506 // Get existing day numbers for the trip
507 const existingDayNumbers = useMemo(() => {
508 if (!effectiveTripData || !isAddDayMode) return [];
509 const itineraryDays = effectiveTripData.itinerary_days || [];
510 const days = itineraryDays
511 .map((d: any) => d.day_number || d.day)
512 .filter(
513 (day: any) => day !== null && day !== undefined && !isNaN(Number(day)),
514 )
515 .map((day: any) => Number(day))
516 .sort((a: number, b: number) => a - b);
517 return days;
518 }, [effectiveTripData, isAddDayMode]);
519
520 // Calculate next available day number
521 const nextAvailableDayNumber = useMemo(() => {
522 if (existingDayNumbers.length === 0) return 1;
523 return Math.max(...existingDayNumbers) + 1;
524 }, [existingDayNumbers]);
525
526 // Auto-fill day number when trip is selected or changes in day mode
527 const currentTripId = formData.trip_id || "";
528 const tripIdChanged = lastTripIdRef.current !== currentTripId;
529
530 useEffect(() => {
531 // Only auto-fill when creating a new day/entry, not when editing
532 if (!isAddDayMode || isEditMode) return;
533
534 if (currentTripId) {
535 if (tripIdChanged) {
536 lastTripIdRef.current = currentTripId;
537 dayManuallyEditedRef.current = false;
538 }
539
540 // For single-day trips, auto-fill entry number if empty
541 if (isSingleDayTrip) {
542 if (!formData.day || formData.day === "") {
543 // Auto-fill with next available entry number for single-day trips
544 const currentDay = parseInt(formData.day) || 0;
545 const shouldUpdate =
546 !formData.day ||
547 formData.day === "" ||
548 existingDayNumbers.includes(currentDay);
549
550 if (shouldUpdate && nextAvailableDayNumber) {
551 setFormData((prev) => ({
552 ...prev,
553 day: nextAvailableDayNumber.toString(),
554 }));
555 } else if (!formData.day || formData.day === "") {
556 // Default to 1 if no existing entries
557 setFormData((prev) => ({ ...prev, day: "1" }));
558 }
559 }
560 // Allow manual editing for single-day trips, so don't return early
561 }
562
563 // For multi-day trips, auto-fill day number when trip data is loaded
564 if (effectiveTripData !== undefined && effectiveTripData !== null) {
565 if (!dayManuallyEditedRef.current) {
566 const currentDay = parseInt(formData.day) || 0;
567 const shouldUpdate =
568 !formData.day ||
569 formData.day === "" ||
570 existingDayNumbers.includes(currentDay);
571
572 if (shouldUpdate && nextAvailableDayNumber) {
573 setFormData((prev) => ({
574 ...prev,
575 day: nextAvailableDayNumber.toString(),
576 }));
577 }
578 }
579 }
580 } else {
581 setFormData((prev) => ({ ...prev, day: "" }));
582 dayManuallyEditedRef.current = false;
583 lastTripIdRef.current = null;
584 }
585 // eslint-disable-next-line react-hooks/exhaustive-deps
586 }, [
587 isAddDayMode,
588 isEditMode,
589 currentTripId,
590 effectiveTripData,
591 nextAvailableDayNumber,
592 existingDayNumbers.length,
593 formData.day,
594 isSingleDayTrip,
595 ]);
596
597 // Handlers
598 const handleFieldChange = (field: keyof ItineraryFormData, value: any) => {
599 setFormData((prev) => ({ ...prev, [field]: value }));
600 if (errors[field]) {
601 setErrors((prev) => ({ ...prev, [field]: "" }));
602 }
603
604 if (field === "day" && isAddDayMode) {
605 dayManuallyEditedRef.current = true;
606 }
607
608 if (field === "trip_id" && isAddDayMode) {
609 setFormData((prev) => ({ ...prev, day: "" }));
610 dayManuallyEditedRef.current = false;
611 lastTripIdRef.current = value;
612 }
613 };
614
615 const handleAddIncludedItem = () => {
616 if (newIncludedItem.trim()) {
617 setFormData((prev) => ({
618 ...prev,
619 included_items: [...prev.included_items, newIncludedItem.trim()],
620 }));
621 setNewIncludedItem("");
622 }
623 };
624
625 const handleRemoveIncludedItem = (index: number) => {
626 setFormData((prev) => ({
627 ...prev,
628 included_items: prev.included_items.filter((_, i) => i !== index),
629 }));
630 };
631
632 const handleAddExcludedItem = () => {
633 if (newExcludedItem.trim()) {
634 setFormData((prev) => ({
635 ...prev,
636 excluded_items: [...prev.excluded_items, newExcludedItem.trim()],
637 }));
638 setNewExcludedItem("");
639 }
640 };
641
642 const handleRemoveExcludedItem = (index: number) => {
643 setFormData((prev) => ({
644 ...prev,
645 excluded_items: prev.excluded_items.filter((_, i) => i !== index),
646 }));
647 };
648
649 const handleSubmit = async (e: React.FormEvent) => {
650 e.preventDefault();
651
652 // Check for day conflict in day mode before validation
653 if (isAddDayMode && formData.trip_id && formData.day) {
654 const dayNumber = parseInt(formData.day);
655
656 if (!isNaN(dayNumber)) {
657 // In edit mode, only check conflict if day number has changed
658 if (isEditMode && originalDayNumber !== null) {
659 // If day number hasn't changed, no conflict check needed
660 if (dayNumber === originalDayNumber) {
661 // Day number unchanged, proceed with validation
662 } else if (existingDayNumbers.includes(dayNumber)) {
663 // Day number changed to an existing day - show replace confirmation
664 setConflictDayNumber(dayNumber);
665 setSuggestedDayNumber(null); // No suggestion for replace mode
666 setShowDayConflictDialog(true);
667 return;
668 }
669 } else if (!isEditMode && existingDayNumbers.includes(dayNumber)) {
670 // In create mode, show conflict with suggestion
671 setConflictDayNumber(dayNumber);
672 setSuggestedDayNumber(nextAvailableDayNumber);
673 setShowDayConflictDialog(true);
674 return;
675 }
676 }
677 }
678
679 const { isValid, errors: validationErrors } = validateForm(
680 formData,
681 isAddDayMode,
682 activityForms,
683 );
684 if (!isValid) {
685 setErrors(validationErrors);
686 return;
687 }
688
689 setErrors({});
690 saveMutation.mutate(formData);
691 };
692
693 const handleCancel = () => {
694 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary`;
695 };
696
697 // Activity form handlers
698 const handleToggleExpand = (activityId: string) => {
699 setActivityForms((prev) =>
700 prev.map((af) =>
701 af.id === activityId ? { ...af, isExpanded: !af.isExpanded } : af,
702 ),
703 );
704 };
705
706 // Drag-and-drop reorder. Pure UI-state mutation — the array index becomes the
707 // `order` value sent in buildActivityPayload at save time, which writes into the
708 // existing `order` smallint column on yatra_new_trip_itinerary_day_entry.
709 // No new schema, no new column on the table view, no new endpoint.
710 const handleReorderActivities = (fromIndex: number, toIndex: number) => {
711 if (fromIndex === toIndex) return;
712 setActivityForms((prev) => {
713 if (
714 fromIndex < 0 ||
715 toIndex < 0 ||
716 fromIndex >= prev.length ||
717 toIndex >= prev.length
718 ) {
719 return prev;
720 }
721 const next = prev.slice();
722 const [moved] = next.splice(fromIndex, 1);
723 next.splice(toIndex, 0, moved);
724 return next;
725 });
726 };
727
728 const handleRemoveActivity = (activityId: string) => {
729 setActivityForms((prev) => prev.filter((af) => af.id !== activityId));
730 setActivityIncludedItems((prev) => {
731 const updated = { ...prev };
732 delete updated[activityId];
733 return updated;
734 });
735 setActivityExcludedItems((prev) => {
736 const updated = { ...prev };
737 delete updated[activityId];
738 return updated;
739 });
740 };
741
742 const handleAddActivity = () => {
743 const newActivityId = `activity_${Date.now()}`;
744 setActivityForms((prev) => [
745 ...prev,
746 {
747 id: newActivityId,
748 data: {
749 item_type_id: "",
750 item_id: "",
751 title: "",
752 description: "",
753 location: "",
754 duration: "",
755 start_time: "",
756 end_time: "",
757 time_type: "exact",
758 cost: "",
759 cost_per_person: true,
760 notes: "",
761 included_items: [],
762 excluded_items: [],
763 },
764 isExpanded: true,
765 },
766 ]);
767 };
768
769 const handleActivityFieldChange = (
770 activityId: string,
771 field: string,
772 value: any,
773 ) => {
774 setActivityForms((prev) =>
775 prev.map((af) =>
776 af.id === activityId
777 ? { ...af, data: { ...af.data, [field]: value } }
778 : af,
779 ),
780 );
781 };
782
783 const handleActivityIncludedItemChange = (
784 activityId: string,
785 value: string,
786 ) => {
787 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: value }));
788 };
789
790 const handleActivityExcludedItemChange = (
791 activityId: string,
792 value: string,
793 ) => {
794 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: value }));
795 };
796
797 const handleActivityAddIncludedItem = (activityId: string) => {
798 const item = activityIncludedItems[activityId]?.trim();
799 if (item) {
800 setActivityForms((prev) =>
801 prev.map((af) =>
802 af.id === activityId
803 ? {
804 ...af,
805 data: {
806 ...af.data,
807 included_items: [...(af.data.included_items || []), item],
808 },
809 }
810 : af,
811 ),
812 );
813 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: "" }));
814 }
815 };
816
817 const handleActivityAddExcludedItem = (activityId: string) => {
818 const item = activityExcludedItems[activityId]?.trim();
819 if (item) {
820 setActivityForms((prev) =>
821 prev.map((af) =>
822 af.id === activityId
823 ? {
824 ...af,
825 data: {
826 ...af.data,
827 excluded_items: [...(af.data.excluded_items || []), item],
828 },
829 }
830 : af,
831 ),
832 );
833 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: "" }));
834 }
835 };
836
837 const handleActivityRemoveIncludedItem = (
838 activityId: string,
839 index: number,
840 ) => {
841 setActivityForms((prev) =>
842 prev.map((af) =>
843 af.id === activityId
844 ? {
845 ...af,
846 data: {
847 ...af.data,
848 included_items: (af.data.included_items || []).filter(
849 (_: any, i: number) => i !== index,
850 ),
851 },
852 }
853 : af,
854 ),
855 );
856 };
857
858 const handleActivityRemoveExcludedItem = (
859 activityId: string,
860 index: number,
861 ) => {
862 setActivityForms((prev) =>
863 prev.map((af) =>
864 af.id === activityId
865 ? {
866 ...af,
867 data: {
868 ...af.data,
869 excluded_items: (af.data.excluded_items || []).filter(
870 (_: any, i: number) => i !== index,
871 ),
872 },
873 }
874 : af,
875 ),
876 );
877 };
878
879 const handleDayConflictConfirm = () => {
880 if (suggestedDayNumber !== null) {
881 const updatedFormData = {
882 ...formData,
883 day: suggestedDayNumber.toString(),
884 };
885
886 setShowDayConflictDialog(false);
887 setConflictDayNumber(null);
888 setSuggestedDayNumber(null);
889
890 setFormData(updatedFormData);
891
892 const { isValid, errors: validationErrors } = validateForm(
893 updatedFormData,
894 isAddDayMode,
895 activityForms,
896 );
897
898 if (isValid) {
899 setErrors({});
900 saveMutation.mutate(updatedFormData);
901 } else {
902 setErrors(validationErrors);
903 }
904 } else {
905 setShowDayConflictDialog(false);
906 setConflictDayNumber(null);
907 setSuggestedDayNumber(null);
908 }
909 };
910
911 if (isEditMode && isLoadingEntry) {
912 return <ItineraryFormSkeleton />;
913 }
914
915 return (
916 <div className="space-y-3">
917 <PageHeader
918 title={
919 isEditMode
920 ? __("Edit Itinerary Entry", "yatra")
921 : isAddDayMode
922 ? isSingleDayTrip
923 ? __("Add New Entry", "yatra")
924 : __("Add New Day", "yatra")
925 : __("Add New Activity", "yatra")
926 }
927 description={
928 isEditMode
929 ? __("Update itinerary entry information", "yatra")
930 : isAddDayMode
931 ? isSingleDayTrip
932 ? __(
933 "Create a new entry for your single-day trip itinerary.",
934 "yatra",
935 )
936 : __(
937 "Create a new day entry for your trip itinerary. This will be the first activity of the new day.",
938 "yatra",
939 )
940 : __(
941 "Add a new activity, meal, or accommodation to this day of the trip itinerary.",
942 "yatra",
943 )
944 }
945 actions={
946 <Button
947 variant="outline"
948 onClick={handleCancel}
949 className="flex items-center gap-2"
950 >
951 <ArrowLeft className="w-4 h-4" />
952 {__("Back", "yatra")}
953 </Button>
954 }
955 />
956
957 <ConditionalRender capability="yatra_edit_trips">
958 <form onSubmit={handleSubmit}>
959 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
960 <div className="lg:col-span-2 space-y-3">
961 {/* Day Form - Show in day mode */}
962 {isAddDayMode && (
963 <>
964 <DayFormFields
965 formData={{
966 trip_id: formData.trip_id,
967 day: formData.day,
968 day_title: formData.day_title || "",
969 day_description: formData.day_description || "",
970 }}
971 errors={errors}
972 tripsData={tripsData}
973 isLoadingTrips={isLoadingTrips}
974 isEditMode={isEditMode && isAddDayMode}
975 isSingleDayTrip={isSingleDayTrip}
976 onFieldChange={handleFieldChange}
977 />
978
979 <ActivityAccordion
980 activityForms={activityForms}
981 errors={errors}
982 itemTypes={typesData}
983 items={itemsData}
984 activityIncludedItems={activityIncludedItems}
985 activityExcludedItems={activityExcludedItems}
986 onToggleExpand={handleToggleExpand}
987 onRemoveActivity={handleRemoveActivity}
988 onAddActivity={handleAddActivity}
989 onReorderActivities={handleReorderActivities}
990 onFieldChange={handleActivityFieldChange}
991 onIncludedItemChange={handleActivityIncludedItemChange}
992 onExcludedItemChange={handleActivityExcludedItemChange}
993 onAddIncludedItem={handleActivityAddIncludedItem}
994 onAddExcludedItem={handleActivityAddExcludedItem}
995 onRemoveIncludedItem={handleActivityRemoveIncludedItem}
996 onRemoveExcludedItem={handleActivityRemoveExcludedItem}
997 calculateDuration={calculateDuration}
998 onRefreshData={refreshData}
999 />
1000 </>
1001 )}
1002
1003 {/* Activity Form - Show in activity mode */}
1004 {!isAddDayMode && (
1005 <>
1006 {/* Show trip/day info as read-only for activity mode */}
1007 {tripIdParam && dayParam && (
1008 <Card>
1009 <CardContent className="p-3">
1010 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
1011 <div>
1012 <span className="text-gray-500 dark:text-gray-400">
1013 {__("Trip:", "yatra")}
1014 </span>
1015 <span className="ml-2 font-medium">
1016 {tripsData?.find(
1017 (t: any) => t.id.toString() === tripIdParam,
1018 )?.title || tripIdParam}
1019 </span>
1020 </div>
1021 <div>
1022 <span className="text-gray-500 dark:text-gray-400">
1023 {__("Day:", "yatra")}
1024 </span>
1025 <span className="ml-2 font-medium">{dayParam}</span>
1026 </div>
1027 {dayTitle && (
1028 <div>
1029 <span className="text-gray-500 dark:text-gray-400">
1030 {__("Day Title:", "yatra")}
1031 </span>
1032 <span className="ml-2 font-medium">
1033 {dayTitle}
1034 </span>
1035 </div>
1036 )}
1037 </div>
1038 </CardContent>
1039 </Card>
1040 )}
1041
1042 <ItineraryEntryFields
1043 entry={{
1044 item_type_id: formData.item_type_id,
1045 item_id: formData.item_id,
1046 title: formData.title,
1047 description: formData.description,
1048 location: formData.location,
1049 location_latitude: formData.location_latitude,
1050 location_longitude: formData.location_longitude,
1051 duration: formData.duration,
1052 start_time: formData.start_time,
1053 end_time: formData.end_time,
1054 time_type: formData.time_type as
1055 | "exact"
1056 | "duration"
1057 | "flexible",
1058 cost: formData.cost,
1059 cost_per_person: formData.cost_per_person,
1060 notes: formData.notes,
1061 included_items: formData.included_items,
1062 excluded_items: formData.excluded_items,
1063 gallery: formData.gallery,
1064 video_url: formData.video_url,
1065 }}
1066 errors={errors}
1067 itemTypes={typesData}
1068 items={itemsData}
1069 newIncludedItem={newIncludedItem}
1070 newExcludedItem={newExcludedItem}
1071 onFieldChange={(field, value) =>
1072 handleFieldChange(field as keyof ItineraryFormData, value)
1073 }
1074 onIncludedItemChange={setNewIncludedItem}
1075 onExcludedItemChange={setNewExcludedItem}
1076 onAddIncludedItem={handleAddIncludedItem}
1077 onAddExcludedItem={handleAddExcludedItem}
1078 onRemoveIncludedItem={handleRemoveIncludedItem}
1079 onRemoveExcludedItem={handleRemoveExcludedItem}
1080 calculateDuration={calculateDuration}
1081 size="default"
1082 showCardWrapper={true}
1083 onRefreshData={refreshData}
1084 />
1085 </>
1086 )}
1087 </div>
1088
1089 <div className="sticky top-6 space-y-3 z-10 h-fit">
1090 <Card>
1091 <CardHeader className="pb-2">
1092 <CardTitle className="text-base">
1093 {__("Status", "yatra")}
1094 </CardTitle>
1095 </CardHeader>
1096 <CardContent className="space-y-3">
1097 <div>
1098 <label
1099 htmlFor="status"
1100 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1101 >
1102 {__("Entry Status", "yatra")}
1103 </label>
1104 <HelpText
1105 text={__(
1106 "Published entries are visible in trip schedules. Draft entries are saved but not visible. Trashed entries are deleted.",
1107 "yatra",
1108 )}
1109 className="mb-2"
1110 />
1111 <Select
1112 id="status"
1113 value={formData.status}
1114 onChange={(e) =>
1115 handleFieldChange("status", e.target.value)
1116 }
1117 className="w-full"
1118 >
1119 <option value="publish">{__("Publish", "yatra")}</option>
1120 <option value="draft">{__("Draft", "yatra")}</option>
1121 <option value="trash">{__("Trash", "yatra")}</option>
1122 </Select>
1123 </div>
1124 </CardContent>
1125 </Card>
1126
1127 <Card>
1128 <CardContent className="p-3">
1129 <div className="space-y-2">
1130 {errors.submit && (
1131 <Alert variant="error" title={__("Error", "yatra")}>
1132 {errors.submit}
1133 </Alert>
1134 )}
1135 <div className="flex gap-2">
1136 <Button
1137 type="submit"
1138 disabled={saveMutation.isPending}
1139 className="flex-1 flex items-center justify-center gap-2"
1140 >
1141 {saveMutation.isPending ? (
1142 <>
1143 <Loader2 className="w-4 h-4 animate-spin" />
1144 {__("Saving...", "yatra")}
1145 </>
1146 ) : (
1147 <>
1148 <Save className="w-4 h-4" />
1149 {submitButtonLabel}
1150 </>
1151 )}
1152 </Button>
1153 <Button
1154 type="button"
1155 variant="outline"
1156 onClick={handleCancel}
1157 disabled={saveMutation.isPending}
1158 >
1159 {__("Cancel", "yatra")}
1160 </Button>
1161 </div>
1162 </div>
1163 </CardContent>
1164 </Card>
1165 </div>
1166 </div>
1167 </form>
1168 </ConditionalRender>
1169
1170 {/* Day Conflict Confirmation Dialog */}
1171 <DayConflictDialog
1172 isOpen={showDayConflictDialog}
1173 conflictDayNumber={conflictDayNumber}
1174 suggestedDayNumber={suggestedDayNumber}
1175 existingDayNumbers={existingDayNumbers}
1176 isUpdateMode={isEditMode && isAddDayMode}
1177 onClose={() => {
1178 setShowDayConflictDialog(false);
1179 setConflictDayNumber(null);
1180 setSuggestedDayNumber(null);
1181 }}
1182 onConfirm={handleDayConflictConfirm}
1183 />
1184 </div>
1185 );
1186 };
1187
1188 export default ItineraryForm;
1189