PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 / DepartureForm.tsx

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

428 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Departure Form
3 * Add or edit a departure for a trip
4 */
5
6 import React, { useState, useEffect } from "react";
7 import { useQuery, useMutation, 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 { Input } from "../components/ui/input";
12 import { PageHeader } from "../components/common/PageHeader";
13 import { Card, CardContent } from "../components/ui/card";
14 import { HelpText } from "../components/ui/help-text";
15 import { DatePicker } from "../components/ui/date-picker";
16 import { TimePicker } from "../components/ui/time-picker";
17 import { apiClient } from "../lib/api-client";
18 import { useToast } from "../components/ui/toast";
19
20 interface DepartureFormData {
21 date: string;
22 time?: string;
23 max_capacity: string;
24 price_override?: string;
25 notes?: string;
26 trip_id: string;
27 }
28
29 interface TripOption {
30 id: number;
31 title?: string;
32 name?: string;
33 }
34
35 // Trips endpoint returns either data[] or data.data[] depending on the caller;
36 // normalise both to a plain array (mirrors Departures.tsx).
37 function normalizeTripsForDropdown(raw: unknown): TripOption[] {
38 if (Array.isArray(raw)) return raw as TripOption[];
39 if (raw && typeof raw === "object") {
40 const r = raw as Record<string, unknown>;
41 if (Array.isArray(r.data)) return r.data as TripOption[];
42 const inner = r.data;
43 if (
44 inner &&
45 typeof inner === "object" &&
46 Array.isArray((inner as Record<string, unknown>).data)
47 ) {
48 return (inner as Record<string, unknown>).data as TripOption[];
49 }
50 }
51 return [];
52 }
53
54 const DepartureForm: React.FC = () => {
55 const queryClient = useQueryClient();
56 const { showToast } = useToast();
57
58 // Get trip_id and id from URL
59 const urlParams = new URLSearchParams(window.location.search);
60 const tripId = urlParams.get("trip_id")
61 ? parseInt(urlParams.get("trip_id")!)
62 : null;
63 const departureId = urlParams.get("id") || null;
64 const isEditMode = !!departureId;
65
66 const [formData, setFormData] = useState<DepartureFormData>({
67 date: "",
68 time: "",
69 max_capacity: "",
70 price_override: "",
71 notes: "",
72 trip_id: tripId ? String(tripId) : "",
73 });
74
75 const [errors, setErrors] = useState<
76 Partial<Record<keyof DepartureFormData, string>>
77 >({});
78
79 // Tours for the "Tour" selector so an existing departure can be reassigned
80 // from one tour to another (e.g. to correct a wrong assignment).
81 const { data: tripsData } = useQuery({
82 queryKey: ["trips", "departure-form-tour-select", "v1"],
83 queryFn: async () => {
84 const response = await apiClient.get("/trips", {
85 params: { per_page: 1000 },
86 });
87 return normalizeTripsForDropdown(response);
88 },
89 });
90 const tripsList = normalizeTripsForDropdown(tripsData);
91
92 // Fetch existing departure data if editing
93 const { data: departureData, isLoading: isLoadingDeparture } = useQuery({
94 queryKey: ["departure", departureId],
95 queryFn: async () => {
96 if (!departureId || !tripId) return null;
97 const response = await apiClient.get(
98 `/trips/${tripId}/departures/${departureId}`,
99 );
100 return response?.data || response;
101 },
102 enabled: isEditMode && !!departureId && !!tripId,
103 });
104
105 // Load form data when departure data is available
106 useEffect(() => {
107 if (departureData) {
108 setFormData({
109 date: departureData.date || departureData.start_date || "",
110 time: departureData.time || "",
111 max_capacity: departureData.max_capacity?.toString() || "",
112 price_override: departureData.price_override?.toString() || "",
113 notes: departureData.notes || "",
114 trip_id:
115 departureData.trip_id?.toString() || (tripId ? String(tripId) : ""),
116 });
117 }
118 }, [departureData, tripId]);
119
120 // Create/Update mutation
121 const saveMutation = useMutation({
122 mutationFn: async (data: DepartureFormData) => {
123 if (!tripId) throw new Error("Trip ID is required");
124
125 // In edit mode the operator may reassign the departure to a different
126 // tour via the selector; fall back to the URL trip otherwise.
127 const targetTripId =
128 isEditMode && data.trip_id ? parseInt(data.trip_id) : tripId;
129
130 const payload: any = {
131 trip_id: targetTripId,
132 date: data.date,
133 max_capacity: parseInt(data.max_capacity),
134 source: "manual",
135 };
136
137 if (data.time) payload.time = data.time;
138 if (data.price_override)
139 payload.price_override = parseFloat(data.price_override);
140 if (data.notes) payload.notes = data.notes;
141
142 if (isEditMode && departureId) {
143 return await apiClient.put(
144 `/trips/${tripId}/departures/${departureId}`,
145 payload,
146 );
147 } else {
148 return await apiClient.post(`/trips/${tripId}/departures`, payload);
149 }
150 },
151 onSuccess: () => {
152 // The departure may have been reassigned to another tour, so refresh
153 // both the old and new tour's lists and land on the new tour.
154 const destTripId =
155 isEditMode && formData.trip_id ? parseInt(formData.trip_id) : tripId;
156 queryClient.invalidateQueries({ queryKey: ["departures", tripId] });
157 if (destTripId !== tripId) {
158 queryClient.invalidateQueries({ queryKey: ["departures", destTripId] });
159 }
160 showToast(
161 isEditMode
162 ? __("Departure updated successfully", "yatra")
163 : __("Departure created successfully", "yatra"),
164 "success",
165 );
166 // Return to the standalone Departures page (where the user came
167 // from). Previously this sent operators to the Trips page with a
168 // departures-tab query string, which surprised anyone who reached
169 // the form via the Departures sidebar entry — the table they
170 // expected to see post-save was nowhere on the trip page.
171 window.location.href = `?page=yatra&subpage=departures${destTripId ? `&trip_id=${destTripId}` : ""}`;
172 },
173 onError: (error: any) => {
174 showToast(
175 error?.message || __("Failed to save departure", "yatra"),
176 "error",
177 );
178 },
179 });
180
181 const validateForm = (): boolean => {
182 const newErrors: Partial<Record<keyof DepartureFormData, string>> = {};
183
184 if (!formData.date) {
185 newErrors.date = __("Date is required", "yatra");
186 }
187
188 if (!formData.max_capacity) {
189 newErrors.max_capacity = __("Max capacity is required", "yatra");
190 } else if (parseInt(formData.max_capacity) < 1) {
191 newErrors.max_capacity = __("Max capacity must be at least 1", "yatra");
192 }
193
194 setErrors(newErrors);
195 return Object.keys(newErrors).length === 0;
196 };
197
198 const handleSubmit = (e: React.FormEvent) => {
199 e.preventDefault();
200 if (validateForm()) {
201 saveMutation.mutate(formData);
202 }
203 };
204
205 const handleBack = () => {
206 // Mirror the save redirect — return to the Departures page rather
207 // than the Trips page so back-from-form lands where the user was.
208 window.location.href = `?page=yatra&subpage=departures${tripId ? `&trip_id=${tripId}` : ""}`;
209 };
210
211 if (isLoadingDeparture) {
212 return (
213 <div className="space-y-6">
214 <div className="animate-pulse">
215 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-4"></div>
216 <div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
217 </div>
218 </div>
219 );
220 }
221
222 return (
223 <div className="space-y-6">
224 <PageHeader
225 title={
226 isEditMode
227 ? __("Edit Departure", "yatra")
228 : __("Add Departure", "yatra")
229 }
230 description={__(
231 "Create or update a departure date for this trip",
232 "yatra",
233 )}
234 actions={
235 <Button variant="ghost" onClick={handleBack}>
236 <ArrowLeft className="w-4 h-4 mr-2" />
237 {__("Back", "yatra")}
238 </Button>
239 }
240 />
241
242 <form onSubmit={handleSubmit}>
243 <Card>
244 <CardContent className="pt-6 space-y-6">
245 {/* Tour (edit mode only): allows reassigning an existing departure
246 from one tour to another. In create mode the tour comes from the
247 page the operator started on. */}
248 {isEditMode && (
249 <div>
250 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
251 {__("Tour", "yatra")}{" "}
252 <span className="text-red-500">*</span>
253 </label>
254 <select
255 value={formData.trip_id}
256 onChange={(e) =>
257 setFormData({ ...formData, trip_id: e.target.value })
258 }
259 className="w-full rounded-md border px-3 py-2 text-sm bg-white dark:bg-gray-800 dark:text-white border-gray-300 dark:border-gray-600"
260 >
261 {tripsList.length === 0 && formData.trip_id && (
262 <option value={formData.trip_id}>
263 {__("Loading tours…", "yatra")}
264 </option>
265 )}
266 {tripsList.map((t) => (
267 <option key={t.id} value={String(t.id)}>
268 {t.title || t.name || `#${t.id}`}
269 </option>
270 ))}
271 </select>
272 <HelpText
273 text={__(
274 "Move this departure to a different tour. Any bookings on it move with it.",
275 "yatra",
276 )}
277 />
278 </div>
279 )}
280
281 {/* Date */}
282 <div>
283 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
284 {__("Departure Date", "yatra")}{" "}
285 <span className="text-red-500">*</span>
286 </label>
287 <DatePicker
288 value={formData.date}
289 onChange={(value) => setFormData({ ...formData, date: value })}
290 minDate={new Date()}
291 />
292 {errors.date && (
293 <p className="mt-1 text-sm text-red-600 dark:text-red-400">
294 {errors.date}
295 </p>
296 )}
297 <HelpText
298 text={__("Select the departure date for this trip", "yatra")}
299 />
300 </div>
301
302 {/* Time (Optional) */}
303 <div>
304 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
305 {__("Departure Time", "yatra")}{" "}
306 <span className="text-gray-400">
307 ({__("Optional", "yatra")})
308 </span>
309 </label>
310 <TimePicker
311 value={formData.time || ""}
312 onChange={(value) => setFormData({ ...formData, time: value })}
313 />
314 <HelpText
315 text={__("Optional departure time (e.g., 09:00 AM)", "yatra")}
316 />
317 </div>
318
319 {/* Max Capacity */}
320 <div>
321 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
322 {__("Max Capacity", "yatra")}{" "}
323 <span className="text-red-500">*</span>
324 </label>
325 <Input
326 type="number"
327 min="1"
328 value={formData.max_capacity}
329 onChange={(e) =>
330 setFormData({ ...formData, max_capacity: e.target.value })
331 }
332 placeholder={__("e.g., 20", "yatra")}
333 />
334 {errors.max_capacity && (
335 <p className="mt-1 text-sm text-red-600 dark:text-red-400">
336 {errors.max_capacity}
337 </p>
338 )}
339 <HelpText
340 text={__(
341 "Maximum number of travelers for this departure",
342 "yatra",
343 )}
344 />
345 </div>
346
347 {/* Price Override (Optional) */}
348 <div>
349 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
350 {__("Price Override", "yatra")}{" "}
351 <span className="text-gray-400">
352 ({__("Optional", "yatra")})
353 </span>
354 </label>
355 <Input
356 type="number"
357 step="0.01"
358 min="0"
359 value={formData.price_override || ""}
360 onChange={(e) =>
361 setFormData({ ...formData, price_override: e.target.value })
362 }
363 placeholder={__("e.g., 150.00", "yatra")}
364 />
365 <HelpText
366 text={__(
367 "Override the default trip price for this specific departure",
368 "yatra",
369 )}
370 />
371 </div>
372
373 {/* Notes (Optional) */}
374 <div>
375 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
376 {__("Notes", "yatra")}{" "}
377 <span className="text-gray-400">
378 ({__("Optional", "yatra")})
379 </span>
380 </label>
381 <textarea
382 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white"
383 rows={4}
384 value={formData.notes || ""}
385 onChange={(e) =>
386 setFormData({ ...formData, notes: e.target.value })
387 }
388 placeholder={__(
389 "Additional notes about this departure...",
390 "yatra",
391 )}
392 />
393 <HelpText
394 text={__(
395 "Internal notes about this departure (not visible to customers)",
396 "yatra",
397 )}
398 />
399 </div>
400
401 {/* Submit Button */}
402 <div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
403 <Button type="button" variant="outline" onClick={handleBack}>
404 {__("Cancel", "yatra")}
405 </Button>
406 <Button type="submit" disabled={saveMutation.isPending}>
407 {saveMutation.isPending ? (
408 <>
409 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
410 {__("Saving...", "yatra")}
411 </>
412 ) : (
413 <>
414 <Save className="w-4 h-4 mr-2" />
415 {__("Save Departure", "yatra")}
416 </>
417 )}
418 </Button>
419 </div>
420 </CardContent>
421 </Card>
422 </form>
423 </div>
424 );
425 };
426
427 export default DepartureForm;
428