PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at resources/js/pages/ItineraryForm.tsx

1,187 lines 39.9 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 }, [formData.item_type_id]);
463
464 // Get day title from trip data
465 const dayTitle = useMemo(() => {
466 if (!effectiveTripData || !dayParam) return null;
467 const itineraryDays = effectiveTripData.itinerary_days || [];
468 const dayNumber = parseInt(dayParam);
469 const day = itineraryDays.find(
470 (d: any) => (d.day_number || d.day) === dayNumber,
471 );
472 return day?.title || day?.day_title || null;
473 }, [effectiveTripData, dayParam]);
474
475 // Get trip type (single_day or multi_day)
476 const selectedTripType = useMemo(() => {
477 if (!formData.trip_id) return null;
478 const trip = tripsData?.find(
479 (t: any) => t.id.toString() === formData.trip_id,
480 );
481 return trip?.trip_type || null;
482 }, [formData.trip_id, tripsData]);
483
484 const isSingleDayTrip = selectedTripType === "single_day";
485
486 const submitButtonLabel = useMemo(() => {
487 if (isEditMode) {
488 if (isAddDayMode) {
489 return isSingleDayTrip
490 ? __("Update Entry", "yatra")
491 : __("Update Day", "yatra");
492 }
493 return __("Update Itinerary Activity", "yatra");
494 }
495
496 if (isAddDayMode) {
497 return isSingleDayTrip
498 ? __("Create Entry", "yatra")
499 : __("Create Day", "yatra");
500 }
501
502 return __("Create Itinerary Activity", "yatra");
503 }, [isEditMode, isAddDayMode, isSingleDayTrip]);
504
505 // Get existing day numbers for the trip
506 const existingDayNumbers = useMemo(() => {
507 if (!effectiveTripData || !isAddDayMode) return [];
508 const itineraryDays = effectiveTripData.itinerary_days || [];
509 const days = itineraryDays
510 .map((d: any) => d.day_number || d.day)
511 .filter(
512 (day: any) => day !== null && day !== undefined && !isNaN(Number(day)),
513 )
514 .map((day: any) => Number(day))
515 .sort((a: number, b: number) => a - b);
516 return days;
517 }, [effectiveTripData, isAddDayMode]);
518
519 // Calculate next available day number
520 const nextAvailableDayNumber = useMemo(() => {
521 if (existingDayNumbers.length === 0) return 1;
522 return Math.max(...existingDayNumbers) + 1;
523 }, [existingDayNumbers]);
524
525 // Auto-fill day number when trip is selected or changes in day mode
526 const currentTripId = formData.trip_id || "";
527 const tripIdChanged = lastTripIdRef.current !== currentTripId;
528
529 useEffect(() => {
530 // Only auto-fill when creating a new day/entry, not when editing
531 if (!isAddDayMode || isEditMode) return;
532
533 if (currentTripId) {
534 if (tripIdChanged) {
535 lastTripIdRef.current = currentTripId;
536 dayManuallyEditedRef.current = false;
537 }
538
539 // For single-day trips, auto-fill entry number if empty
540 if (isSingleDayTrip) {
541 if (!formData.day || formData.day === "") {
542 // Auto-fill with next available entry number for single-day trips
543 const currentDay = parseInt(formData.day) || 0;
544 const shouldUpdate =
545 !formData.day ||
546 formData.day === "" ||
547 existingDayNumbers.includes(currentDay);
548
549 if (shouldUpdate && nextAvailableDayNumber) {
550 setFormData((prev) => ({
551 ...prev,
552 day: nextAvailableDayNumber.toString(),
553 }));
554 } else if (!formData.day || formData.day === "") {
555 // Default to 1 if no existing entries
556 setFormData((prev) => ({ ...prev, day: "1" }));
557 }
558 }
559 // Allow manual editing for single-day trips, so don't return early
560 }
561
562 // For multi-day trips, auto-fill day number when trip data is loaded
563 if (effectiveTripData !== undefined && effectiveTripData !== null) {
564 if (!dayManuallyEditedRef.current) {
565 const currentDay = parseInt(formData.day) || 0;
566 const shouldUpdate =
567 !formData.day ||
568 formData.day === "" ||
569 existingDayNumbers.includes(currentDay);
570
571 if (shouldUpdate && nextAvailableDayNumber) {
572 setFormData((prev) => ({
573 ...prev,
574 day: nextAvailableDayNumber.toString(),
575 }));
576 }
577 }
578 }
579 } else {
580 setFormData((prev) => ({ ...prev, day: "" }));
581 dayManuallyEditedRef.current = false;
582 lastTripIdRef.current = null;
583 }
584 }, [
585 isAddDayMode,
586 isEditMode,
587 currentTripId,
588 effectiveTripData,
589 nextAvailableDayNumber,
590 existingDayNumbers.length,
591 formData.day,
592 isSingleDayTrip,
593 ]);
594
595 // Handlers
596 const handleFieldChange = (field: keyof ItineraryFormData, value: any) => {
597 setFormData((prev) => ({ ...prev, [field]: value }));
598 if (errors[field]) {
599 setErrors((prev) => ({ ...prev, [field]: "" }));
600 }
601
602 if (field === "day" && isAddDayMode) {
603 dayManuallyEditedRef.current = true;
604 }
605
606 if (field === "trip_id" && isAddDayMode) {
607 setFormData((prev) => ({ ...prev, day: "" }));
608 dayManuallyEditedRef.current = false;
609 lastTripIdRef.current = value;
610 }
611 };
612
613 const handleAddIncludedItem = () => {
614 if (newIncludedItem.trim()) {
615 setFormData((prev) => ({
616 ...prev,
617 included_items: [...prev.included_items, newIncludedItem.trim()],
618 }));
619 setNewIncludedItem("");
620 }
621 };
622
623 const handleRemoveIncludedItem = (index: number) => {
624 setFormData((prev) => ({
625 ...prev,
626 included_items: prev.included_items.filter((_, i) => i !== index),
627 }));
628 };
629
630 const handleAddExcludedItem = () => {
631 if (newExcludedItem.trim()) {
632 setFormData((prev) => ({
633 ...prev,
634 excluded_items: [...prev.excluded_items, newExcludedItem.trim()],
635 }));
636 setNewExcludedItem("");
637 }
638 };
639
640 const handleRemoveExcludedItem = (index: number) => {
641 setFormData((prev) => ({
642 ...prev,
643 excluded_items: prev.excluded_items.filter((_, i) => i !== index),
644 }));
645 };
646
647 const handleSubmit = async (e: React.FormEvent) => {
648 e.preventDefault();
649
650 // Check for day conflict in day mode before validation
651 if (isAddDayMode && formData.trip_id && formData.day) {
652 const dayNumber = parseInt(formData.day);
653
654 if (!isNaN(dayNumber)) {
655 // In edit mode, only check conflict if day number has changed
656 if (isEditMode && originalDayNumber !== null) {
657 // If day number hasn't changed, no conflict check needed
658 if (dayNumber === originalDayNumber) {
659 // Day number unchanged, proceed with validation
660 } else if (existingDayNumbers.includes(dayNumber)) {
661 // Day number changed to an existing day - show replace confirmation
662 setConflictDayNumber(dayNumber);
663 setSuggestedDayNumber(null); // No suggestion for replace mode
664 setShowDayConflictDialog(true);
665 return;
666 }
667 } else if (!isEditMode && existingDayNumbers.includes(dayNumber)) {
668 // In create mode, show conflict with suggestion
669 setConflictDayNumber(dayNumber);
670 setSuggestedDayNumber(nextAvailableDayNumber);
671 setShowDayConflictDialog(true);
672 return;
673 }
674 }
675 }
676
677 const { isValid, errors: validationErrors } = validateForm(
678 formData,
679 isAddDayMode,
680 activityForms,
681 );
682 if (!isValid) {
683 setErrors(validationErrors);
684 return;
685 }
686
687 setErrors({});
688 saveMutation.mutate(formData);
689 };
690
691 const handleCancel = () => {
692 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary`;
693 };
694
695 // Activity form handlers
696 const handleToggleExpand = (activityId: string) => {
697 setActivityForms((prev) =>
698 prev.map((af) =>
699 af.id === activityId ? { ...af, isExpanded: !af.isExpanded } : af,
700 ),
701 );
702 };
703
704 // Drag-and-drop reorder. Pure UI-state mutation — the array index becomes the
705 // `order` value sent in buildActivityPayload at save time, which writes into the
706 // existing `order` smallint column on yatra_new_trip_itinerary_day_entry.
707 // No new schema, no new column on the table view, no new endpoint.
708 const handleReorderActivities = (fromIndex: number, toIndex: number) => {
709 if (fromIndex === toIndex) return;
710 setActivityForms((prev) => {
711 if (
712 fromIndex < 0 ||
713 toIndex < 0 ||
714 fromIndex >= prev.length ||
715 toIndex >= prev.length
716 ) {
717 return prev;
718 }
719 const next = prev.slice();
720 const [moved] = next.splice(fromIndex, 1);
721 next.splice(toIndex, 0, moved);
722 return next;
723 });
724 };
725
726 const handleRemoveActivity = (activityId: string) => {
727 setActivityForms((prev) => prev.filter((af) => af.id !== activityId));
728 setActivityIncludedItems((prev) => {
729 const updated = { ...prev };
730 delete updated[activityId];
731 return updated;
732 });
733 setActivityExcludedItems((prev) => {
734 const updated = { ...prev };
735 delete updated[activityId];
736 return updated;
737 });
738 };
739
740 const handleAddActivity = () => {
741 const newActivityId = `activity_${Date.now()}`;
742 setActivityForms((prev) => [
743 ...prev,
744 {
745 id: newActivityId,
746 data: {
747 item_type_id: "",
748 item_id: "",
749 title: "",
750 description: "",
751 location: "",
752 duration: "",
753 start_time: "",
754 end_time: "",
755 time_type: "exact",
756 cost: "",
757 cost_per_person: true,
758 notes: "",
759 included_items: [],
760 excluded_items: [],
761 },
762 isExpanded: true,
763 },
764 ]);
765 };
766
767 const handleActivityFieldChange = (
768 activityId: string,
769 field: string,
770 value: any,
771 ) => {
772 setActivityForms((prev) =>
773 prev.map((af) =>
774 af.id === activityId
775 ? { ...af, data: { ...af.data, [field]: value } }
776 : af,
777 ),
778 );
779 };
780
781 const handleActivityIncludedItemChange = (
782 activityId: string,
783 value: string,
784 ) => {
785 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: value }));
786 };
787
788 const handleActivityExcludedItemChange = (
789 activityId: string,
790 value: string,
791 ) => {
792 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: value }));
793 };
794
795 const handleActivityAddIncludedItem = (activityId: string) => {
796 const item = activityIncludedItems[activityId]?.trim();
797 if (item) {
798 setActivityForms((prev) =>
799 prev.map((af) =>
800 af.id === activityId
801 ? {
802 ...af,
803 data: {
804 ...af.data,
805 included_items: [...(af.data.included_items || []), item],
806 },
807 }
808 : af,
809 ),
810 );
811 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: "" }));
812 }
813 };
814
815 const handleActivityAddExcludedItem = (activityId: string) => {
816 const item = activityExcludedItems[activityId]?.trim();
817 if (item) {
818 setActivityForms((prev) =>
819 prev.map((af) =>
820 af.id === activityId
821 ? {
822 ...af,
823 data: {
824 ...af.data,
825 excluded_items: [...(af.data.excluded_items || []), item],
826 },
827 }
828 : af,
829 ),
830 );
831 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: "" }));
832 }
833 };
834
835 const handleActivityRemoveIncludedItem = (
836 activityId: string,
837 index: number,
838 ) => {
839 setActivityForms((prev) =>
840 prev.map((af) =>
841 af.id === activityId
842 ? {
843 ...af,
844 data: {
845 ...af.data,
846 included_items: (af.data.included_items || []).filter(
847 (_: any, i: number) => i !== index,
848 ),
849 },
850 }
851 : af,
852 ),
853 );
854 };
855
856 const handleActivityRemoveExcludedItem = (
857 activityId: string,
858 index: number,
859 ) => {
860 setActivityForms((prev) =>
861 prev.map((af) =>
862 af.id === activityId
863 ? {
864 ...af,
865 data: {
866 ...af.data,
867 excluded_items: (af.data.excluded_items || []).filter(
868 (_: any, i: number) => i !== index,
869 ),
870 },
871 }
872 : af,
873 ),
874 );
875 };
876
877 const handleDayConflictConfirm = () => {
878 if (suggestedDayNumber !== null) {
879 const updatedFormData = {
880 ...formData,
881 day: suggestedDayNumber.toString(),
882 };
883
884 setShowDayConflictDialog(false);
885 setConflictDayNumber(null);
886 setSuggestedDayNumber(null);
887
888 setFormData(updatedFormData);
889
890 const { isValid, errors: validationErrors } = validateForm(
891 updatedFormData,
892 isAddDayMode,
893 activityForms,
894 );
895
896 if (isValid) {
897 setErrors({});
898 saveMutation.mutate(updatedFormData);
899 } else {
900 setErrors(validationErrors);
901 }
902 } else {
903 setShowDayConflictDialog(false);
904 setConflictDayNumber(null);
905 setSuggestedDayNumber(null);
906 }
907 };
908
909 if (isEditMode && isLoadingEntry) {
910 return <ItineraryFormSkeleton />;
911 }
912
913 return (
914 <div className="space-y-3">
915 <PageHeader
916 title={
917 isEditMode
918 ? __("Edit Itinerary Entry", "yatra")
919 : isAddDayMode
920 ? isSingleDayTrip
921 ? __("Add New Entry", "yatra")
922 : __("Add New Day", "yatra")
923 : __("Add New Activity", "yatra")
924 }
925 description={
926 isEditMode
927 ? __("Update itinerary entry information", "yatra")
928 : isAddDayMode
929 ? isSingleDayTrip
930 ? __(
931 "Create a new entry for your single-day trip itinerary.",
932 "yatra",
933 )
934 : __(
935 "Create a new day entry for your trip itinerary. This will be the first activity of the new day.",
936 "yatra",
937 )
938 : __(
939 "Add a new activity, meal, or accommodation to this day of the trip itinerary.",
940 "yatra",
941 )
942 }
943 actions={
944 <Button
945 variant="outline"
946 onClick={handleCancel}
947 className="flex items-center gap-2"
948 >
949 <ArrowLeft className="w-4 h-4" />
950 {__("Back", "yatra")}
951 </Button>
952 }
953 />
954
955 <ConditionalRender capability="yatra_edit_trips">
956 <form onSubmit={handleSubmit}>
957 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
958 <div className="lg:col-span-2 space-y-3">
959 {/* Day Form - Show in day mode */}
960 {isAddDayMode && (
961 <>
962 <DayFormFields
963 formData={{
964 trip_id: formData.trip_id,
965 day: formData.day,
966 day_title: formData.day_title || "",
967 day_description: formData.day_description || "",
968 }}
969 errors={errors}
970 tripsData={tripsData}
971 isLoadingTrips={isLoadingTrips}
972 isEditMode={isEditMode && isAddDayMode}
973 isSingleDayTrip={isSingleDayTrip}
974 onFieldChange={handleFieldChange}
975 />
976
977 <ActivityAccordion
978 activityForms={activityForms}
979 errors={errors}
980 itemTypes={typesData}
981 items={itemsData}
982 activityIncludedItems={activityIncludedItems}
983 activityExcludedItems={activityExcludedItems}
984 onToggleExpand={handleToggleExpand}
985 onRemoveActivity={handleRemoveActivity}
986 onAddActivity={handleAddActivity}
987 onReorderActivities={handleReorderActivities}
988 onFieldChange={handleActivityFieldChange}
989 onIncludedItemChange={handleActivityIncludedItemChange}
990 onExcludedItemChange={handleActivityExcludedItemChange}
991 onAddIncludedItem={handleActivityAddIncludedItem}
992 onAddExcludedItem={handleActivityAddExcludedItem}
993 onRemoveIncludedItem={handleActivityRemoveIncludedItem}
994 onRemoveExcludedItem={handleActivityRemoveExcludedItem}
995 calculateDuration={calculateDuration}
996 onRefreshData={refreshData}
997 />
998 </>
999 )}
1000
1001 {/* Activity Form - Show in activity mode */}
1002 {!isAddDayMode && (
1003 <>
1004 {/* Show trip/day info as read-only for activity mode */}
1005 {tripIdParam && dayParam && (
1006 <Card>
1007 <CardContent className="p-3">
1008 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
1009 <div>
1010 <span className="text-gray-500 dark:text-gray-400">
1011 {__("Trip:", "yatra")}
1012 </span>
1013 <span className="ml-2 font-medium">
1014 {tripsData?.find(
1015 (t: any) => t.id.toString() === tripIdParam,
1016 )?.title || tripIdParam}
1017 </span>
1018 </div>
1019 <div>
1020 <span className="text-gray-500 dark:text-gray-400">
1021 {__("Day:", "yatra")}
1022 </span>
1023 <span className="ml-2 font-medium">{dayParam}</span>
1024 </div>
1025 {dayTitle && (
1026 <div>
1027 <span className="text-gray-500 dark:text-gray-400">
1028 {__("Day Title:", "yatra")}
1029 </span>
1030 <span className="ml-2 font-medium">
1031 {dayTitle}
1032 </span>
1033 </div>
1034 )}
1035 </div>
1036 </CardContent>
1037 </Card>
1038 )}
1039
1040 <ItineraryEntryFields
1041 entry={{
1042 item_type_id: formData.item_type_id,
1043 item_id: formData.item_id,
1044 title: formData.title,
1045 description: formData.description,
1046 location: formData.location,
1047 location_latitude: formData.location_latitude,
1048 location_longitude: formData.location_longitude,
1049 duration: formData.duration,
1050 start_time: formData.start_time,
1051 end_time: formData.end_time,
1052 time_type: formData.time_type as
1053 | "exact"
1054 | "duration"
1055 | "flexible",
1056 cost: formData.cost,
1057 cost_per_person: formData.cost_per_person,
1058 notes: formData.notes,
1059 included_items: formData.included_items,
1060 excluded_items: formData.excluded_items,
1061 gallery: formData.gallery,
1062 video_url: formData.video_url,
1063 }}
1064 errors={errors}
1065 itemTypes={typesData}
1066 items={itemsData}
1067 newIncludedItem={newIncludedItem}
1068 newExcludedItem={newExcludedItem}
1069 onFieldChange={(field, value) =>
1070 handleFieldChange(field as keyof ItineraryFormData, value)
1071 }
1072 onIncludedItemChange={setNewIncludedItem}
1073 onExcludedItemChange={setNewExcludedItem}
1074 onAddIncludedItem={handleAddIncludedItem}
1075 onAddExcludedItem={handleAddExcludedItem}
1076 onRemoveIncludedItem={handleRemoveIncludedItem}
1077 onRemoveExcludedItem={handleRemoveExcludedItem}
1078 calculateDuration={calculateDuration}
1079 size="default"
1080 showCardWrapper={true}
1081 onRefreshData={refreshData}
1082 />
1083 </>
1084 )}
1085 </div>
1086
1087 <div className="sticky top-6 space-y-3 z-10 h-fit">
1088 <Card>
1089 <CardHeader className="pb-2">
1090 <CardTitle className="text-base">
1091 {__("Status", "yatra")}
1092 </CardTitle>
1093 </CardHeader>
1094 <CardContent className="space-y-3">
1095 <div>
1096 <label
1097 htmlFor="status"
1098 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1099 >
1100 {__("Entry Status", "yatra")}
1101 </label>
1102 <HelpText
1103 text={__(
1104 "Published entries are visible in trip schedules. Draft entries are saved but not visible. Trashed entries are deleted.",
1105 "yatra",
1106 )}
1107 className="mb-2"
1108 />
1109 <Select
1110 id="status"
1111 value={formData.status}
1112 onChange={(e) =>
1113 handleFieldChange("status", e.target.value)
1114 }
1115 className="w-full"
1116 >
1117 <option value="publish">{__("Publish", "yatra")}</option>
1118 <option value="draft">{__("Draft", "yatra")}</option>
1119 <option value="trash">{__("Trash", "yatra")}</option>
1120 </Select>
1121 </div>
1122 </CardContent>
1123 </Card>
1124
1125 <Card>
1126 <CardContent className="p-3">
1127 <div className="space-y-2">
1128 {errors.submit && (
1129 <Alert variant="error" title={__("Error", "yatra")}>
1130 {errors.submit}
1131 </Alert>
1132 )}
1133 <div className="flex gap-2">
1134 <Button
1135 type="submit"
1136 disabled={saveMutation.isPending}
1137 className="flex-1 flex items-center justify-center gap-2"
1138 >
1139 {saveMutation.isPending ? (
1140 <>
1141 <Loader2 className="w-4 h-4 animate-spin" />
1142 {__("Saving...", "yatra")}
1143 </>
1144 ) : (
1145 <>
1146 <Save className="w-4 h-4" />
1147 {submitButtonLabel}
1148 </>
1149 )}
1150 </Button>
1151 <Button
1152 type="button"
1153 variant="outline"
1154 onClick={handleCancel}
1155 disabled={saveMutation.isPending}
1156 >
1157 {__("Cancel", "yatra")}
1158 </Button>
1159 </div>
1160 </div>
1161 </CardContent>
1162 </Card>
1163 </div>
1164 </div>
1165 </form>
1166 </ConditionalRender>
1167
1168 {/* Day Conflict Confirmation Dialog */}
1169 <DayConflictDialog
1170 isOpen={showDayConflictDialog}
1171 conflictDayNumber={conflictDayNumber}
1172 suggestedDayNumber={suggestedDayNumber}
1173 existingDayNumbers={existingDayNumbers}
1174 isUpdateMode={isEditMode && isAddDayMode}
1175 onClose={() => {
1176 setShowDayConflictDialog(false);
1177 setConflictDayNumber(null);
1178 setSuggestedDayNumber(null);
1179 }}
1180 onConfirm={handleDayConflictConfirm}
1181 />
1182 </div>
1183 );
1184 };
1185
1186 export default ItineraryForm;
1187