PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.17 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 All 85 releases
yatra / resources / js / components / itinerary / ActivityAccordion.tsx

ActivityAccordion.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.13, at resources/js/components/itinerary/ActivityAccordion.tsx

281 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Activity Accordion Component
3 * Displays multiple activities in an accordion format for day mode.
4 *
5 * Reorder: each Card carries a drag-handle (GripVertical) that flips the parent
6 * <Card> into draggable=true. Drag/drop uses HTML5's native dataTransfer API —
7 * no extra library — and bubbles a fromIndex→toIndex change up via
8 * onReorderActivities. The persistence is handled in the save flow by writing
9 * the array index into the `order` smallint column on
10 * yatra_new_trip_itinerary_day_entry; no new DB column is added.
11 */
12
13 import React, { useState } from "react";
14 import { ChevronUp, ChevronDown, X, Plus, GripVertical } from "lucide-react";
15 import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
16 import { Button } from "../ui/button";
17 import { ItineraryEntryFields } from "../trip-form/shared/ItineraryEntryFields";
18 import { __ } from "../../lib/i18n";
19 import { ActivityForm } from "../../hooks/useItineraryFormValidation";
20
21 interface ActivityAccordionProps {
22 activityForms: ActivityForm[];
23 errors: Record<string, string>;
24 itemTypes: any[];
25 items: any[];
26 activityIncludedItems: Record<string, string>;
27 activityExcludedItems: Record<string, string>;
28 onToggleExpand: (activityId: string) => void;
29 onRemoveActivity: (activityId: string) => void;
30 onAddActivity: () => void;
31 /**
32 * Reorder activities. Optional so existing call sites that don't yet wire it
33 * compile cleanly; without it the drag handle is hidden.
34 */
35 onReorderActivities?: (fromIndex: number, toIndex: number) => void;
36 onFieldChange: (activityId: string, field: string, value: any) => void;
37 onIncludedItemChange: (activityId: string, value: string) => void;
38 onExcludedItemChange: (activityId: string, value: string) => void;
39 onAddIncludedItem: (activityId: string) => void;
40 onAddExcludedItem: (activityId: string) => void;
41 onRemoveIncludedItem: (activityId: string, index: number) => void;
42 onRemoveExcludedItem: (activityId: string, index: number) => void;
43 calculateDuration: (
44 startTime?: string,
45 endTime?: string,
46 timeType?: string,
47 ) => string;
48 onRefreshData?: () => void;
49 }
50
51 export const ActivityAccordion: React.FC<ActivityAccordionProps> = ({
52 activityForms,
53 errors,
54 itemTypes,
55 items,
56 activityIncludedItems,
57 activityExcludedItems,
58 onToggleExpand,
59 onRemoveActivity,
60 onAddActivity,
61 onReorderActivities,
62 onFieldChange,
63 onIncludedItemChange,
64 onExcludedItemChange,
65 onAddIncludedItem,
66 onAddExcludedItem,
67 onRemoveIncludedItem,
68 onRemoveExcludedItem,
69 calculateDuration,
70 onRefreshData,
71 }) => {
72 const reorderEnabled = typeof onReorderActivities === "function";
73
74 // Indices used while a drag is in flight. Reset on drop / dragend / dragleave-of-list.
75 const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
76 const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
77
78 const handleDragStart = (index: number) => (e: React.DragEvent) => {
79 if (!reorderEnabled) return;
80 setDraggingIndex(index);
81 e.dataTransfer.effectAllowed = "move";
82 // Some browsers ignore drag events without dataTransfer.setData
83 try {
84 e.dataTransfer.setData("text/plain", String(index));
85 } catch {
86 // older Safari quirks — safe to ignore
87 }
88 };
89
90 const handleDragOver = (index: number) => (e: React.DragEvent) => {
91 if (!reorderEnabled || draggingIndex === null) return;
92 e.preventDefault(); // required to allow drop
93 e.dataTransfer.dropEffect = "move";
94 if (dragOverIndex !== index) {
95 setDragOverIndex(index);
96 }
97 };
98
99 const handleDrop = (index: number) => (e: React.DragEvent) => {
100 if (!reorderEnabled || draggingIndex === null) return;
101 e.preventDefault();
102 if (draggingIndex !== index) {
103 onReorderActivities?.(draggingIndex, index);
104 }
105 setDraggingIndex(null);
106 setDragOverIndex(null);
107 };
108
109 const handleDragEnd = () => {
110 setDraggingIndex(null);
111 setDragOverIndex(null);
112 };
113
114 return (
115 <>
116 {activityForms.length > 0 && (
117 <div className="space-y-3">
118 {activityForms.map((activityForm, index) => {
119 const isDragging = draggingIndex === index;
120 const isDropTarget =
121 dragOverIndex === index && draggingIndex !== index;
122 return (
123 <Card
124 key={activityForm.id}
125 className={`mt-3 transition-shadow ${
126 isDragging ? "opacity-60" : ""
127 } ${
128 isDropTarget ? "ring-2 ring-blue-400 dark:ring-blue-500" : ""
129 }`}
130 draggable={reorderEnabled}
131 onDragStart={handleDragStart(index)}
132 onDragOver={handleDragOver(index)}
133 onDrop={handleDrop(index)}
134 onDragEnd={handleDragEnd}
135 >
136 <CardHeader className="pb-2">
137 <div className="flex items-center justify-between gap-2">
138 {/*
139 Card header reads the activity's own title. Falls back to
140 "Activity #N" when the title is still blank (e.g. a
141 newly-added card). Truncated with min-w-0 + truncate so
142 long titles don't push the chevron / delete buttons off
143 the right edge of the card.
144 */}
145 <CardTitle className="text-base flex items-center gap-2 min-w-0 flex-1">
146 {reorderEnabled && (
147 // The drag-handle shows the row is draggable AND gives
148 // keyboard / mouse users an obvious target. The whole
149 // card is the drop zone; this is just the affordance.
150 <span
151 className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 shrink-0"
152 title={__("Drag to reorder this activity", "yatra")}
153 aria-label={__(
154 "Drag to reorder this activity",
155 "yatra",
156 )}
157 >
158 <GripVertical className="w-4 h-4" />
159 </span>
160 )}
161 <span
162 className="truncate"
163 title={(activityForm.data.title || "").trim()}
164 >
165 {(activityForm.data.title || "").trim() ||
166 `${__("Activity", "yatra")} #${index + 1}`}
167 </span>
168 </CardTitle>
169 <div className="flex items-center gap-2">
170 <Button
171 type="button"
172 variant="ghost"
173 size="icon"
174 onClick={() => onToggleExpand(activityForm.id)}
175 className="h-8 w-8"
176 >
177 {activityForm.isExpanded ? (
178 <ChevronUp className="w-4 h-4" />
179 ) : (
180 <ChevronDown className="w-4 h-4" />
181 )}
182 </Button>
183 <Button
184 type="button"
185 variant="ghost"
186 size="icon"
187 onClick={() => onRemoveActivity(activityForm.id)}
188 className="h-8 w-8 text-red-600 hover:text-red-700 dark:text-red-400"
189 title={__("Remove Activity", "yatra")}
190 >
191 <X className="w-4 h-4" />
192 </Button>
193 </div>
194 </div>
195 </CardHeader>
196 {activityForm.isExpanded && (
197 <CardContent>
198 <ItineraryEntryFields
199 entry={{
200 item_type_id: activityForm.data.item_type_id || "",
201 item_id: activityForm.data.item_id || "",
202 title: activityForm.data.title || "",
203 description: activityForm.data.description || "",
204 location: activityForm.data.location || "",
205 location_latitude:
206 activityForm.data.location_latitude || "",
207 location_longitude:
208 activityForm.data.location_longitude || "",
209 duration: activityForm.data.duration || "",
210 start_time: activityForm.data.start_time || "",
211 end_time: activityForm.data.end_time || "",
212 time_type: (activityForm.data.time_type || "exact") as
213 | "exact"
214 | "duration"
215 | "flexible",
216 cost: activityForm.data.cost || "",
217 cost_per_person:
218 activityForm.data.cost_per_person !== false,
219 notes: activityForm.data.notes || "",
220 included_items: activityForm.data.included_items || [],
221 excluded_items: activityForm.data.excluded_items || [],
222 }}
223 errors={errors}
224 itemTypes={itemTypes}
225 items={items}
226 newIncludedItem={
227 activityIncludedItems[activityForm.id] || ""
228 }
229 newExcludedItem={
230 activityExcludedItems[activityForm.id] || ""
231 }
232 onFieldChange={(field: string, value: any) =>
233 onFieldChange(activityForm.id, field, value)
234 }
235 onIncludedItemChange={(value: string) =>
236 onIncludedItemChange(activityForm.id, value)
237 }
238 onExcludedItemChange={(value: string) =>
239 onExcludedItemChange(activityForm.id, value)
240 }
241 onAddIncludedItem={() =>
242 onAddIncludedItem(activityForm.id)
243 }
244 onAddExcludedItem={() =>
245 onAddExcludedItem(activityForm.id)
246 }
247 onRemoveIncludedItem={(idx: number) =>
248 onRemoveIncludedItem(activityForm.id, idx)
249 }
250 onRemoveExcludedItem={(idx: number) =>
251 onRemoveExcludedItem(activityForm.id, idx)
252 }
253 calculateDuration={calculateDuration}
254 size="default"
255 showCardWrapper={false}
256 onRefreshData={onRefreshData}
257 />
258 </CardContent>
259 )}
260 </Card>
261 );
262 })}
263 </div>
264 )}
265
266 {/* Add Activity Button - Below all activities */}
267 <div className="mt-3">
268 <Button
269 type="button"
270 variant="outline"
271 onClick={onAddActivity}
272 className="w-full flex items-center justify-center gap-2 border-dashed"
273 >
274 <Plus className="w-4 h-4" />
275 {__("Add Activity", "yatra")}
276 </Button>
277 </div>
278 </>
279 );
280 };
281