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

1,136 lines 37.6 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: "08:00",
93 end_time: "17:00",
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 start_time: entryData.start_time || "08:00",
315 end_time: entryData.end_time || "17:00",
316 time_type: entryData.time_type || "exact",
317 cost: entryData.cost || "",
318 cost_per_person: entryData.cost_per_person !== false,
319 notes: entryData.notes || "",
320 included_items: entryData.included_items || [],
321 excluded_items: entryData.excluded_items || [],
322 gallery: entryData.gallery || [],
323 video_url: entryData.video_url || "",
324 status: entryData.status || "publish",
325 });
326 } else if (isEditDayMode) {
327 // For day editing, entryData should now be the day entry (thanks to effectiveEntryData in useItineraryFormData)
328 // Load all day info into formData
329 const dayNumber = entryData.day
330 ? parseInt(entryData.day.toString())
331 : null;
332 setOriginalDayNumber(dayNumber);
333
334 setFormData((prev) => ({
335 ...prev,
336 trip_id: entryData.trip_id?.toString() || tripIdParam || prev.trip_id,
337 day: entryData.day?.toString() || dayParam || prev.day,
338 day_title: entryData.day_title || prev.day_title,
339 day_description: entryData.day_description || prev.day_description,
340 gallery: entryData.gallery || prev.gallery,
341 video_url: entryData.video_url || prev.video_url,
342 status: entryData.status || prev.status,
343 }));
344 }
345
346 // Clear loading flag after form data is set
347 setTimeout(() => {
348 isLoadingInitialDataRef.current = false;
349 }, 100);
350 }, [
351 entryId,
352 isEditMode,
353 isEditDayMode,
354 entryData,
355 tripIdParam,
356 dayParam,
357 dayTripData,
358 ]); // Include entryData to trigger when data arrives
359
360 // Load activities for day editing
361 useEffect(() => {
362 if (!isEditDayMode) return;
363 if (!entryTripId || !entryDay) return;
364 if (!dayTripData) return;
365
366 const dayKey = `${entryTripId}-${entryDay}`;
367 if (loadedDayActivitiesRef.current === dayKey) return;
368
369 loadedDayActivitiesRef.current = dayKey;
370
371 const dayNumber =
372 typeof entryDay === "string" ? parseInt(entryDay) : entryDay;
373 if (isNaN(dayNumber as number)) return;
374
375 const itineraryDays = dayTripData.itinerary_days || [];
376 const dayData = itineraryDays.find(
377 (d: any) => (d.day_number || d.day) === dayNumber,
378 );
379
380 if (!dayData || !dayData.entries) return;
381
382 const activities = dayData.entries
383 .filter((e: any) => e.item_type_id && e.item_id)
384 .map((entry: any, index: number) => ({
385 id: `activity-${entry.id || index}`,
386 entryId: entry.id ? parseInt(entry.id.toString()) : null, // Store actual entry ID for updates
387 data: {
388 item_type_id: entry.item_type_id?.toString() || "",
389 item_id: entry.item_id?.toString() || "",
390 title: entry.title || "",
391 description: entry.description || "",
392 location: entry.location || "",
393 duration: entry.duration || "",
394 start_time: entry.start_time || "08:00",
395 end_time: entry.end_time || "17:00",
396 time_type: entry.time_type || "exact",
397 cost: entry.cost || "",
398 cost_per_person: entry.cost_per_person !== false,
399 notes: entry.notes || "",
400 included_items: entry.included_items || [],
401 excluded_items: entry.excluded_items || [],
402 status: entry.status || "publish",
403 },
404 isExpanded: index === 0,
405 }));
406
407 setActivityForms(activities);
408
409 const includedItems: Record<string, string> = {};
410 const excludedItems: Record<string, string> = {};
411 activities.forEach((activity: { id: string }) => {
412 includedItems[activity.id] = "";
413 excludedItems[activity.id] = "";
414 });
415 setActivityIncludedItems(includedItems);
416 setActivityExcludedItems(excludedItems);
417 }, [isEditDayMode, entryTripId, entryDay, dayTripData]);
418
419 // Track if we're loading initial data to prevent item_id reset during load
420 const isLoadingInitialDataRef = useRef(false);
421
422 // Reset item_id when type changes (but not during initial data load)
423 useEffect(() => {
424 // Don't reset during initial data load
425 if (isLoadingInitialDataRef.current) return;
426
427 // Only reset if item_type_id changes and item_id is set
428 // This allows users to change item type and forces them to select a new item
429 if (formData.item_type_id && formData.item_id) {
430 // Check if we need to reset - this will be handled by user interaction
431 // We don't auto-reset here to allow the form to work properly
432 }
433 }, [formData.item_type_id]);
434
435 // Get day title from trip data
436 const dayTitle = useMemo(() => {
437 if (!effectiveTripData || !dayParam) return null;
438 const itineraryDays = effectiveTripData.itinerary_days || [];
439 const dayNumber = parseInt(dayParam);
440 const day = itineraryDays.find(
441 (d: any) => (d.day_number || d.day) === dayNumber,
442 );
443 return day?.title || day?.day_title || null;
444 }, [effectiveTripData, dayParam]);
445
446 // Get trip type (single_day or multi_day)
447 const selectedTripType = useMemo(() => {
448 if (!formData.trip_id) return null;
449 const trip = tripsData?.find(
450 (t: any) => t.id.toString() === formData.trip_id,
451 );
452 return trip?.trip_type || null;
453 }, [formData.trip_id, tripsData]);
454
455 const isSingleDayTrip = selectedTripType === "single_day";
456
457 const submitButtonLabel = useMemo(() => {
458 if (isEditMode) {
459 if (isAddDayMode) {
460 return isSingleDayTrip
461 ? __("Update Entry", "yatra")
462 : __("Update Day", "yatra");
463 }
464 return __("Update Itinerary Activity", "yatra");
465 }
466
467 if (isAddDayMode) {
468 return isSingleDayTrip
469 ? __("Create Entry", "yatra")
470 : __("Create Day", "yatra");
471 }
472
473 return __("Create Itinerary Activity", "yatra");
474 }, [isEditMode, isAddDayMode, isSingleDayTrip]);
475
476 // Get existing day numbers for the trip
477 const existingDayNumbers = useMemo(() => {
478 if (!effectiveTripData || !isAddDayMode) return [];
479 const itineraryDays = effectiveTripData.itinerary_days || [];
480 const days = itineraryDays
481 .map((d: any) => d.day_number || d.day)
482 .filter(
483 (day: any) => day !== null && day !== undefined && !isNaN(Number(day)),
484 )
485 .map((day: any) => Number(day))
486 .sort((a: number, b: number) => a - b);
487 return days;
488 }, [effectiveTripData, isAddDayMode]);
489
490 // Calculate next available day number
491 const nextAvailableDayNumber = useMemo(() => {
492 if (existingDayNumbers.length === 0) return 1;
493 return Math.max(...existingDayNumbers) + 1;
494 }, [existingDayNumbers]);
495
496 // Auto-fill day number when trip is selected or changes in day mode
497 const currentTripId = formData.trip_id || "";
498 const tripIdChanged = lastTripIdRef.current !== currentTripId;
499
500 useEffect(() => {
501 // Only auto-fill when creating a new day/entry, not when editing
502 if (!isAddDayMode || isEditMode) return;
503
504 if (currentTripId) {
505 if (tripIdChanged) {
506 lastTripIdRef.current = currentTripId;
507 dayManuallyEditedRef.current = false;
508 }
509
510 // For single-day trips, auto-fill entry number if empty
511 if (isSingleDayTrip) {
512 if (!formData.day || formData.day === "") {
513 // Auto-fill with next available entry number for single-day trips
514 const currentDay = parseInt(formData.day) || 0;
515 const shouldUpdate =
516 !formData.day ||
517 formData.day === "" ||
518 existingDayNumbers.includes(currentDay);
519
520 if (shouldUpdate && nextAvailableDayNumber) {
521 setFormData((prev) => ({
522 ...prev,
523 day: nextAvailableDayNumber.toString(),
524 }));
525 } else if (!formData.day || formData.day === "") {
526 // Default to 1 if no existing entries
527 setFormData((prev) => ({ ...prev, day: "1" }));
528 }
529 }
530 // Allow manual editing for single-day trips, so don't return early
531 }
532
533 // For multi-day trips, auto-fill day number when trip data is loaded
534 if (effectiveTripData !== undefined && effectiveTripData !== null) {
535 if (!dayManuallyEditedRef.current) {
536 const currentDay = parseInt(formData.day) || 0;
537 const shouldUpdate =
538 !formData.day ||
539 formData.day === "" ||
540 existingDayNumbers.includes(currentDay);
541
542 if (shouldUpdate && nextAvailableDayNumber) {
543 setFormData((prev) => ({
544 ...prev,
545 day: nextAvailableDayNumber.toString(),
546 }));
547 }
548 }
549 }
550 } else {
551 setFormData((prev) => ({ ...prev, day: "" }));
552 dayManuallyEditedRef.current = false;
553 lastTripIdRef.current = null;
554 }
555 }, [
556 isAddDayMode,
557 isEditMode,
558 currentTripId,
559 effectiveTripData,
560 nextAvailableDayNumber,
561 existingDayNumbers.length,
562 formData.day,
563 isSingleDayTrip,
564 ]);
565
566 // Handlers
567 const handleFieldChange = (field: keyof ItineraryFormData, value: any) => {
568 setFormData((prev) => ({ ...prev, [field]: value }));
569 if (errors[field]) {
570 setErrors((prev) => ({ ...prev, [field]: "" }));
571 }
572
573 if (field === "day" && isAddDayMode) {
574 dayManuallyEditedRef.current = true;
575 }
576
577 if (field === "trip_id" && isAddDayMode) {
578 setFormData((prev) => ({ ...prev, day: "" }));
579 dayManuallyEditedRef.current = false;
580 lastTripIdRef.current = value;
581 }
582 };
583
584 const handleAddIncludedItem = () => {
585 if (newIncludedItem.trim()) {
586 setFormData((prev) => ({
587 ...prev,
588 included_items: [...prev.included_items, newIncludedItem.trim()],
589 }));
590 setNewIncludedItem("");
591 }
592 };
593
594 const handleRemoveIncludedItem = (index: number) => {
595 setFormData((prev) => ({
596 ...prev,
597 included_items: prev.included_items.filter((_, i) => i !== index),
598 }));
599 };
600
601 const handleAddExcludedItem = () => {
602 if (newExcludedItem.trim()) {
603 setFormData((prev) => ({
604 ...prev,
605 excluded_items: [...prev.excluded_items, newExcludedItem.trim()],
606 }));
607 setNewExcludedItem("");
608 }
609 };
610
611 const handleRemoveExcludedItem = (index: number) => {
612 setFormData((prev) => ({
613 ...prev,
614 excluded_items: prev.excluded_items.filter((_, i) => i !== index),
615 }));
616 };
617
618 const handleSubmit = async (e: React.FormEvent) => {
619 e.preventDefault();
620
621 // Check for day conflict in day mode before validation
622 if (isAddDayMode && formData.trip_id && formData.day) {
623 const dayNumber = parseInt(formData.day);
624
625 if (!isNaN(dayNumber)) {
626 // In edit mode, only check conflict if day number has changed
627 if (isEditMode && originalDayNumber !== null) {
628 // If day number hasn't changed, no conflict check needed
629 if (dayNumber === originalDayNumber) {
630 // Day number unchanged, proceed with validation
631 } else if (existingDayNumbers.includes(dayNumber)) {
632 // Day number changed to an existing day - show replace confirmation
633 setConflictDayNumber(dayNumber);
634 setSuggestedDayNumber(null); // No suggestion for replace mode
635 setShowDayConflictDialog(true);
636 return;
637 }
638 } else if (!isEditMode && existingDayNumbers.includes(dayNumber)) {
639 // In create mode, show conflict with suggestion
640 setConflictDayNumber(dayNumber);
641 setSuggestedDayNumber(nextAvailableDayNumber);
642 setShowDayConflictDialog(true);
643 return;
644 }
645 }
646 }
647
648 const { isValid, errors: validationErrors } = validateForm(
649 formData,
650 isAddDayMode,
651 activityForms,
652 );
653 if (!isValid) {
654 setErrors(validationErrors);
655 return;
656 }
657
658 setErrors({});
659 saveMutation.mutate(formData);
660 };
661
662 const handleCancel = () => {
663 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=itinerary&tab=itinerary`;
664 };
665
666 // Activity form handlers
667 const handleToggleExpand = (activityId: string) => {
668 setActivityForms((prev) =>
669 prev.map((af) =>
670 af.id === activityId ? { ...af, isExpanded: !af.isExpanded } : af,
671 ),
672 );
673 };
674
675 const handleRemoveActivity = (activityId: string) => {
676 setActivityForms((prev) => prev.filter((af) => af.id !== activityId));
677 setActivityIncludedItems((prev) => {
678 const updated = { ...prev };
679 delete updated[activityId];
680 return updated;
681 });
682 setActivityExcludedItems((prev) => {
683 const updated = { ...prev };
684 delete updated[activityId];
685 return updated;
686 });
687 };
688
689 const handleAddActivity = () => {
690 const newActivityId = `activity_${Date.now()}`;
691 setActivityForms((prev) => [
692 ...prev,
693 {
694 id: newActivityId,
695 data: {
696 item_type_id: "",
697 item_id: "",
698 title: "",
699 description: "",
700 location: "",
701 duration: "",
702 start_time: "08:00",
703 end_time: "17:00",
704 time_type: "exact",
705 cost: "",
706 cost_per_person: true,
707 notes: "",
708 included_items: [],
709 excluded_items: [],
710 },
711 isExpanded: true,
712 },
713 ]);
714 };
715
716 const handleActivityFieldChange = (
717 activityId: string,
718 field: string,
719 value: any,
720 ) => {
721 setActivityForms((prev) =>
722 prev.map((af) =>
723 af.id === activityId
724 ? { ...af, data: { ...af.data, [field]: value } }
725 : af,
726 ),
727 );
728 };
729
730 const handleActivityIncludedItemChange = (
731 activityId: string,
732 value: string,
733 ) => {
734 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: value }));
735 };
736
737 const handleActivityExcludedItemChange = (
738 activityId: string,
739 value: string,
740 ) => {
741 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: value }));
742 };
743
744 const handleActivityAddIncludedItem = (activityId: string) => {
745 const item = activityIncludedItems[activityId]?.trim();
746 if (item) {
747 setActivityForms((prev) =>
748 prev.map((af) =>
749 af.id === activityId
750 ? {
751 ...af,
752 data: {
753 ...af.data,
754 included_items: [...(af.data.included_items || []), item],
755 },
756 }
757 : af,
758 ),
759 );
760 setActivityIncludedItems((prev) => ({ ...prev, [activityId]: "" }));
761 }
762 };
763
764 const handleActivityAddExcludedItem = (activityId: string) => {
765 const item = activityExcludedItems[activityId]?.trim();
766 if (item) {
767 setActivityForms((prev) =>
768 prev.map((af) =>
769 af.id === activityId
770 ? {
771 ...af,
772 data: {
773 ...af.data,
774 excluded_items: [...(af.data.excluded_items || []), item],
775 },
776 }
777 : af,
778 ),
779 );
780 setActivityExcludedItems((prev) => ({ ...prev, [activityId]: "" }));
781 }
782 };
783
784 const handleActivityRemoveIncludedItem = (
785 activityId: string,
786 index: number,
787 ) => {
788 setActivityForms((prev) =>
789 prev.map((af) =>
790 af.id === activityId
791 ? {
792 ...af,
793 data: {
794 ...af.data,
795 included_items: (af.data.included_items || []).filter(
796 (_: any, i: number) => i !== index,
797 ),
798 },
799 }
800 : af,
801 ),
802 );
803 };
804
805 const handleActivityRemoveExcludedItem = (
806 activityId: string,
807 index: number,
808 ) => {
809 setActivityForms((prev) =>
810 prev.map((af) =>
811 af.id === activityId
812 ? {
813 ...af,
814 data: {
815 ...af.data,
816 excluded_items: (af.data.excluded_items || []).filter(
817 (_: any, i: number) => i !== index,
818 ),
819 },
820 }
821 : af,
822 ),
823 );
824 };
825
826 const handleDayConflictConfirm = () => {
827 if (suggestedDayNumber !== null) {
828 const updatedFormData = {
829 ...formData,
830 day: suggestedDayNumber.toString(),
831 };
832
833 setShowDayConflictDialog(false);
834 setConflictDayNumber(null);
835 setSuggestedDayNumber(null);
836
837 setFormData(updatedFormData);
838
839 const { isValid, errors: validationErrors } = validateForm(
840 updatedFormData,
841 isAddDayMode,
842 activityForms,
843 );
844
845 if (isValid) {
846 setErrors({});
847 saveMutation.mutate(updatedFormData);
848 } else {
849 setErrors(validationErrors);
850 }
851 } else {
852 setShowDayConflictDialog(false);
853 setConflictDayNumber(null);
854 setSuggestedDayNumber(null);
855 }
856 };
857
858 if (isEditMode && isLoadingEntry) {
859 return <ItineraryFormSkeleton />;
860 }
861
862 return (
863 <div className="space-y-3">
864 <PageHeader
865 title={
866 isEditMode
867 ? __("Edit Itinerary Entry", "yatra")
868 : isAddDayMode
869 ? isSingleDayTrip
870 ? __("Add New Entry", "yatra")
871 : __("Add New Day", "yatra")
872 : __("Add New Activity", "yatra")
873 }
874 description={
875 isEditMode
876 ? __("Update itinerary entry information", "yatra")
877 : isAddDayMode
878 ? isSingleDayTrip
879 ? __(
880 "Create a new entry for your single-day trip itinerary.",
881 "yatra",
882 )
883 : __(
884 "Create a new day entry for your trip itinerary. This will be the first activity of the new day.",
885 "yatra",
886 )
887 : __(
888 "Add a new activity, meal, or accommodation to this day of the trip itinerary.",
889 "yatra",
890 )
891 }
892 actions={
893 <Button
894 variant="outline"
895 onClick={handleCancel}
896 className="flex items-center gap-2"
897 >
898 <ArrowLeft className="w-4 h-4" />
899 {__("Back", "yatra")}
900 </Button>
901 }
902 />
903
904 <ConditionalRender capability="yatra_edit_trips">
905 <form onSubmit={handleSubmit}>
906 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
907 <div className="lg:col-span-2 space-y-3">
908 {/* Day Form - Show in day mode */}
909 {isAddDayMode && (
910 <>
911 <DayFormFields
912 formData={{
913 trip_id: formData.trip_id,
914 day: formData.day,
915 day_title: formData.day_title || "",
916 day_description: formData.day_description || "",
917 }}
918 errors={errors}
919 tripsData={tripsData}
920 isLoadingTrips={isLoadingTrips}
921 isEditMode={isEditMode && isAddDayMode}
922 isSingleDayTrip={isSingleDayTrip}
923 onFieldChange={handleFieldChange}
924 />
925
926 <ActivityAccordion
927 activityForms={activityForms}
928 errors={errors}
929 itemTypes={typesData}
930 items={itemsData}
931 activityIncludedItems={activityIncludedItems}
932 activityExcludedItems={activityExcludedItems}
933 onToggleExpand={handleToggleExpand}
934 onRemoveActivity={handleRemoveActivity}
935 onAddActivity={handleAddActivity}
936 onFieldChange={handleActivityFieldChange}
937 onIncludedItemChange={handleActivityIncludedItemChange}
938 onExcludedItemChange={handleActivityExcludedItemChange}
939 onAddIncludedItem={handleActivityAddIncludedItem}
940 onAddExcludedItem={handleActivityAddExcludedItem}
941 onRemoveIncludedItem={handleActivityRemoveIncludedItem}
942 onRemoveExcludedItem={handleActivityRemoveExcludedItem}
943 calculateDuration={calculateDuration}
944 onRefreshData={refreshData}
945 />
946 </>
947 )}
948
949 {/* Activity Form - Show in activity mode */}
950 {!isAddDayMode && (
951 <>
952 {/* Show trip/day info as read-only for activity mode */}
953 {tripIdParam && dayParam && (
954 <Card>
955 <CardContent className="p-3">
956 <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
957 <div>
958 <span className="text-gray-500 dark:text-gray-400">
959 {__("Trip:", "yatra")}
960 </span>
961 <span className="ml-2 font-medium">
962 {tripsData?.find(
963 (t: any) => t.id.toString() === tripIdParam,
964 )?.title || tripIdParam}
965 </span>
966 </div>
967 <div>
968 <span className="text-gray-500 dark:text-gray-400">
969 {__("Day:", "yatra")}
970 </span>
971 <span className="ml-2 font-medium">{dayParam}</span>
972 </div>
973 {dayTitle && (
974 <div>
975 <span className="text-gray-500 dark:text-gray-400">
976 {__("Day Title:", "yatra")}
977 </span>
978 <span className="ml-2 font-medium">
979 {dayTitle}
980 </span>
981 </div>
982 )}
983 </div>
984 </CardContent>
985 </Card>
986 )}
987
988 <ItineraryEntryFields
989 entry={{
990 item_type_id: formData.item_type_id,
991 item_id: formData.item_id,
992 title: formData.title,
993 description: formData.description,
994 location: formData.location,
995 location_latitude: formData.location_latitude,
996 location_longitude: formData.location_longitude,
997 duration: formData.duration,
998 start_time: formData.start_time,
999 end_time: formData.end_time,
1000 time_type: formData.time_type as
1001 | "exact"
1002 | "approximate"
1003 | "all_day"
1004 | "flexible",
1005 cost: formData.cost,
1006 cost_per_person: formData.cost_per_person,
1007 notes: formData.notes,
1008 included_items: formData.included_items,
1009 excluded_items: formData.excluded_items,
1010 gallery: formData.gallery,
1011 video_url: formData.video_url,
1012 }}
1013 errors={errors}
1014 itemTypes={typesData}
1015 items={itemsData}
1016 newIncludedItem={newIncludedItem}
1017 newExcludedItem={newExcludedItem}
1018 onFieldChange={(field, value) =>
1019 handleFieldChange(field as keyof ItineraryFormData, value)
1020 }
1021 onIncludedItemChange={setNewIncludedItem}
1022 onExcludedItemChange={setNewExcludedItem}
1023 onAddIncludedItem={handleAddIncludedItem}
1024 onAddExcludedItem={handleAddExcludedItem}
1025 onRemoveIncludedItem={handleRemoveIncludedItem}
1026 onRemoveExcludedItem={handleRemoveExcludedItem}
1027 calculateDuration={calculateDuration}
1028 size="default"
1029 showCardWrapper={true}
1030 onRefreshData={refreshData}
1031 />
1032 </>
1033 )}
1034 </div>
1035
1036 <div className="sticky top-6 space-y-3 z-10 h-fit">
1037 <Card>
1038 <CardHeader className="pb-2">
1039 <CardTitle className="text-base">
1040 {__("Status", "yatra")}
1041 </CardTitle>
1042 </CardHeader>
1043 <CardContent className="space-y-3">
1044 <div>
1045 <label
1046 htmlFor="status"
1047 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1048 >
1049 {__("Entry Status", "yatra")}
1050 </label>
1051 <HelpText
1052 text={__(
1053 "Published entries are visible in trip schedules. Draft entries are saved but not visible. Trashed entries are deleted.",
1054 "yatra",
1055 )}
1056 className="mb-2"
1057 />
1058 <Select
1059 id="status"
1060 value={formData.status}
1061 onChange={(e) =>
1062 handleFieldChange("status", e.target.value)
1063 }
1064 className="w-full"
1065 >
1066 <option value="publish">{__("Publish", "yatra")}</option>
1067 <option value="draft">{__("Draft", "yatra")}</option>
1068 <option value="trash">{__("Trash", "yatra")}</option>
1069 </Select>
1070 </div>
1071 </CardContent>
1072 </Card>
1073
1074 <Card>
1075 <CardContent className="p-3">
1076 <div className="space-y-2">
1077 {errors.submit && (
1078 <Alert variant="error" title={__("Error", "yatra")}>
1079 {errors.submit}
1080 </Alert>
1081 )}
1082 <div className="flex gap-2">
1083 <Button
1084 type="submit"
1085 disabled={saveMutation.isPending}
1086 className="flex-1 flex items-center justify-center gap-2"
1087 >
1088 {saveMutation.isPending ? (
1089 <>
1090 <Loader2 className="w-4 h-4 animate-spin" />
1091 {__("Saving...", "yatra")}
1092 </>
1093 ) : (
1094 <>
1095 <Save className="w-4 h-4" />
1096 {submitButtonLabel}
1097 </>
1098 )}
1099 </Button>
1100 <Button
1101 type="button"
1102 variant="outline"
1103 onClick={handleCancel}
1104 disabled={saveMutation.isPending}
1105 >
1106 {__("Cancel", "yatra")}
1107 </Button>
1108 </div>
1109 </div>
1110 </CardContent>
1111 </Card>
1112 </div>
1113 </div>
1114 </form>
1115 </ConditionalRender>
1116
1117 {/* Day Conflict Confirmation Dialog */}
1118 <DayConflictDialog
1119 isOpen={showDayConflictDialog}
1120 conflictDayNumber={conflictDayNumber}
1121 suggestedDayNumber={suggestedDayNumber}
1122 existingDayNumbers={existingDayNumbers}
1123 isUpdateMode={isEditMode && isAddDayMode}
1124 onClose={() => {
1125 setShowDayConflictDialog(false);
1126 setConflictDayNumber(null);
1127 setSuggestedDayNumber(null);
1128 }}
1129 onConfirm={handleDayConflictConfirm}
1130 />
1131 </div>
1132 );
1133 };
1134
1135 export default ItineraryForm;
1136