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 / RecurringRuleForm.tsx

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

2,772 lines 131.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Recurring Availability Rule Form
3 * Create and edit recurring availability patterns
4 */
5
6 import React, { useState, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Save,
11 Clock,
12 DollarSign,
13 RefreshCw,
14 Plus,
15 X,
16 AlertCircle,
17 Eye,
18 MapPin,
19 CheckCircle2,
20 } from "lucide-react";
21 import { __ } from "../lib/i18n";
22 import { toDateValue, todayYmd } from "../lib/dateFormat";
23 import { Button } from "../components/ui/button";
24 import { Input } from "../components/ui/input";
25 import { Select } from "../components/ui/select";
26 import { SearchableSelect } from "../components/ui/searchable-select";
27 import { DatePicker } from "../components/ui/date-picker";
28 import { TimePicker } from "../components/ui/time-picker";
29 import { PageHeader } from "../components/common/PageHeader";
30 import {
31 Card,
32 CardContent,
33 CardHeader,
34 CardTitle,
35 CardDescription,
36 } from "../components/ui/card";
37 import { Badge } from "../components/ui/badge";
38 import { Alert } from "../components/ui/alert";
39 import { useNavigate } from "../hooks/useNavigate";
40 import { apiClient } from "../lib/api-client";
41 import { useToast } from "../components/ui/toast";
42 import { RecurringRuleFormSkeleton } from "../components/availability/RecurringRuleFormSkeleton";
43 import { LocationPicker } from "../components/trip-form/LocationPicker";
44
45 function coordFromApi(v: unknown): string {
46 if (v == null || v === "") return "";
47 return String(v);
48 }
49
50 interface Trip {
51 id: number;
52 title: string;
53 trip_type?: "single_day" | "multi_day";
54 duration_days?: number;
55 starting_location?: string;
56 ending_location?: string;
57 starting_latitude?: string | number;
58 starting_longitude?: string | number;
59 ending_latitude?: string | number;
60 ending_longitude?: string | number;
61 pricing_type?: "regular" | "traveler_based";
62 }
63
64 interface TravelerCategory {
65 id: number;
66 name?: string;
67 label?: string;
68 description?: string;
69 min_age?: number;
70 max_age?: number;
71 age_min?: number;
72 age_max?: number;
73 status?: string;
74 }
75
76 interface TravelerPricing {
77 category_id: number;
78 original_price: number;
79 sale_price?: number;
80 }
81
82 interface TimeSlot {
83 departure_time: string;
84 arrival_time: string;
85 seats: number;
86 price: number;
87 sale_price?: number;
88 traveler_pricing?: TravelerPricing[];
89 }
90
91 /**
92 * One entry in a rule's exclusion list: either a single day ("2027-03-08", the
93 * original format) or an inclusive period. Both are stored in the same
94 * `excluded_dates` column, so rules saved before periods existed load as-is.
95 */
96 type ExcludedEntry = string | { start: string; end: string };
97
98 const isPeriod = (e: ExcludedEntry): e is { start: string; end: string } =>
99 typeof e === "object" && e !== null && "start" in e && "end" in e;
100
101 /** Sort key: a period sorts by its first day, alongside single dates. */
102 const excludedSortKey = (e: ExcludedEntry): string =>
103 isPeriod(e) ? e.start : e;
104
105 /** Stable identity for list keys and removal, for both shapes. */
106 const excludedId = (e: ExcludedEntry): string =>
107 isPeriod(e) ? `${e.start}..${e.end}` : e;
108
109 interface RecurringRule {
110 id?: number;
111 trip_id: number;
112 name: string;
113 rule_type: "weekly" | "monthly" | "interval";
114 days_of_week: number[];
115 week_of_month?: "first" | "second" | "third" | "fourth" | "last";
116 day_of_week?: number;
117 interval_days?: number;
118 start_date: string;
119 end_date?: string;
120 excluded_dates: ExcludedEntry[];
121 months: number[]; // Array of month numbers (1-12) to filter by
122 time_slots: TimeSlot[]; // For single-day trips with multiple slots
123 pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type
124 original_price?: number;
125 sale_price?: number;
126 traveler_pricing?: TravelerPricing[]; // For traveler-based pricing
127 seats_total: number;
128 departure_time?: string;
129 arrival_time?: string;
130 from_location?: string;
131 to_location?: string;
132 from_latitude?: string;
133 from_longitude?: string;
134 to_latitude?: string;
135 to_longitude?: string;
136 cutoff_hours: number;
137 alert_threshold: number;
138 status: "active" | "inactive";
139 }
140
141 const dayOptions = [
142 { value: 0, label: "Sunday" },
143 { value: 1, label: "Monday" },
144 { value: 2, label: "Tuesday" },
145 { value: 3, label: "Wednesday" },
146 { value: 4, label: "Thursday" },
147 { value: 5, label: "Friday" },
148 { value: 6, label: "Saturday" },
149 ];
150
151 const weekOptions = [
152 { value: "first", label: "First" },
153 { value: "second", label: "Second" },
154 { value: "third", label: "Third" },
155 { value: "fourth", label: "Fourth" },
156 { value: "last", label: "Last" },
157 ];
158
159 const RecurringRuleForm: React.FC = () => {
160 const { navigate } = useNavigate();
161 const queryClient = useQueryClient();
162 const { showToast } = useToast();
163
164 // Get parameters from URL
165 const params = new URLSearchParams(window.location.search);
166 const ruleId = params.get("id");
167 const tripIdFromUrl = params.get("trip_id");
168 const isEditing = !!ruleId;
169
170 // Form state
171 const [formData, setFormData] = useState<RecurringRule>({
172 trip_id: tripIdFromUrl ? parseInt(tripIdFromUrl) : 0,
173 name: "",
174 rule_type: "weekly",
175 days_of_week: [0], // Sunday by default
176 week_of_month: "first",
177 day_of_week: 0,
178 interval_days: 7,
179 start_date: todayYmd(),
180 end_date: "",
181 excluded_dates: [],
182 months: [], // Empty = all months, otherwise specific months (1-12)
183 time_slots: [], // For single-day trips with multiple slots
184 pricing_type: "regular", // Will be updated based on trip's pricing type
185 original_price: undefined,
186 sale_price: undefined,
187 traveler_pricing: [], // For traveler-based pricing
188 seats_total: 20,
189 departure_time: "",
190 arrival_time: "",
191 from_location: "",
192 to_location: "",
193 from_latitude: "",
194 from_longitude: "",
195 to_latitude: "",
196 to_longitude: "",
197 cutoff_hours: 24,
198 alert_threshold: 5,
199 status: "active",
200 });
201
202 const [newExcludedDate, setNewExcludedDate] = useState("");
203 const [newExcludedStart, setNewExcludedStart] = useState("");
204 const [newExcludedEnd, setNewExcludedEnd] = useState("");
205 const [excludedError, setExcludedError] = useState("");
206 const [previewData, setPreviewData] = useState<{
207 total: number;
208 dates: any[];
209 } | null>(null);
210 const [showCategorySelector, setShowCategorySelector] = useState(false);
211
212 // Fetch trips for dropdown
213 const { data: tripsData } = useQuery({
214 queryKey: ["trips", "all"],
215 queryFn: async () => {
216 const response = await apiClient.get("/trips", {
217 params: { per_page: 100, status: "publish" },
218 });
219 return {
220 trips: (response?.data || []).map((trip: any) => {
221 // Some endpoints return `pricing_type` as "regular" even when the trip is
222 // effectively traveler-based (price types configured). Infer the effective
223 // pricing type from `price_types` when present so the Rules UI reflects
224 // real trip configuration.
225 const rawPriceTypes = trip.price_types;
226 const hasTravelerPricing = Array.isArray(rawPriceTypes)
227 ? rawPriceTypes.length > 0
228 : false;
229 const effectivePricingType = hasTravelerPricing
230 ? "traveler_based"
231 : trip.pricing_type || "regular";
232
233 return {
234 id: Number(trip.id) || 0,
235 title: trip.title,
236 trip_type:
237 trip.trip_type ||
238 (trip.duration_days <= 1 ? "single_day" : "multi_day"),
239 duration_days: trip.duration_days || 1,
240 starting_location: trip.starting_location,
241 ending_location: trip.ending_location,
242 pricing_type: effectivePricingType,
243 };
244 }) as Trip[],
245 };
246 },
247 });
248
249 // Fetch traveler categories
250 const { data: travelerCategories = [] } = useQuery({
251 queryKey: ["traveler-categories"],
252 queryFn: async () => {
253 const response = await apiClient.get("/traveler-categories", {
254 params: { per_page: 100 },
255 });
256 return (response?.data || []) as TravelerCategory[];
257 },
258 });
259
260 // Get selected trip details
261 const selectedTrip = tripsData?.trips.find((t) => t.id === formData.trip_id);
262 const { data: tripForLocations } = useQuery({
263 queryKey: ["trip", formData.trip_id, "recurring-rule-locations"],
264 queryFn: async () => {
265 const response = await apiClient.get(`/trips/${formData.trip_id}`);
266 return response?.data || response || null;
267 },
268 enabled: formData.trip_id > 0,
269 staleTime: 5 * 60 * 1000,
270 });
271 const { data: fallbackTripData } = useQuery({
272 queryKey: ["trip", formData.trip_id, "recurring-rule-form"],
273 queryFn: async () => {
274 if (!formData.trip_id || selectedTrip) {
275 return null;
276 }
277 const response = await apiClient.get(`/trips/${formData.trip_id}`);
278 return response?.data || response || null;
279 },
280 enabled: !!formData.trip_id && !selectedTrip,
281 staleTime: 5 * 60 * 1000,
282 });
283 const effectiveTrip =
284 selectedTrip ||
285 (fallbackTripData
286 ? {
287 id: Number(fallbackTripData.id) || formData.trip_id,
288 title: fallbackTripData.title,
289 }
290 : null);
291 const tripNameLabel = effectiveTrip?.title || __("Unnamed Trip", "yatra");
292 let headerDescription = __(
293 "Set up automatic availability patterns for your trips",
294 "yatra",
295 );
296 if (effectiveTrip) {
297 headerDescription = `${isEditing ? __("Edit", "yatra") : __("Add", "yatra")} ${__("availability rule for", "yatra")} ${tripNameLabel} (Trip ID: ${effectiveTrip.id})`;
298 } else if (formData.trip_id) {
299 headerDescription = `${isEditing ? __("Edit", "yatra") : __("Add", "yatra")} ${__("availability rule for Trip ID:", "yatra")} ${formData.trip_id}`;
300 }
301 const isSingleDayTrip =
302 selectedTrip?.trip_type === "single_day" ||
303 (selectedTrip?.duration_days || 1) <= 1;
304 // Use form's pricing_type which defaults to trip's pricing type but can be overridden
305 const isTravelerBasedPricing = formData.pricing_type === "traveler_based";
306
307 // Fetch existing rule if editing
308 const { data: existingRule, isLoading: isLoadingRule } = useQuery({
309 queryKey: ["recurring-availability", ruleId],
310 queryFn: async () => {
311 if (!ruleId) return null;
312 const response = await apiClient.get(`/recurring-availability/${ruleId}`);
313 return response?.data || response; // Unwrap data property from API response
314 },
315 enabled: !!ruleId,
316 });
317
318 // Update form when existing rule is loaded
319 useEffect(() => {
320 if (existingRule && tripsData) {
321 // Parse days_of_week - handle both array and string formats
322 let daysOfWeek = [0]; // Default to Sunday
323 if (
324 existingRule.days_of_week_array &&
325 Array.isArray(existingRule.days_of_week_array)
326 ) {
327 daysOfWeek = existingRule.days_of_week_array;
328 } else if (existingRule.days_of_week) {
329 if (typeof existingRule.days_of_week === "string") {
330 daysOfWeek = existingRule.days_of_week
331 .split(",")
332 .map(Number)
333 .filter((n: number) => !isNaN(n));
334 } else if (Array.isArray(existingRule.days_of_week)) {
335 daysOfWeek = existingRule.days_of_week.map(Number);
336 }
337 }
338
339 // Ensure rule_type is properly typed
340 const ruleType = (existingRule.rule_type || "weekly") as
341 | "weekly"
342 | "monthly"
343 | "interval";
344
345 // Prefer the *trip's effective* pricing type over any stale rule.pricing_type.
346 // Trips can be traveler-based simply by having price_types configured, even if
347 // trip.pricing_type is still "regular".
348 const tripRow = tripsData.trips.find(
349 (t) => t.id === Number(existingRule.trip_id),
350 );
351 const effectivePricingType =
352 tripRow?.pricing_type ||
353 ((tripForLocations as any)?.price_types &&
354 Array.isArray((tripForLocations as any).price_types) &&
355 (tripForLocations as any).price_types.length > 0
356 ? "traveler_based"
357 : (tripForLocations as any)?.pricing_type) ||
358 "regular";
359
360 setFormData({
361 trip_id: existingRule.trip_id || 0,
362 name: existingRule.name || "",
363 rule_type: ruleType,
364 days_of_week: daysOfWeek.length > 0 ? daysOfWeek : [0],
365 week_of_month: existingRule.week_of_month || "first",
366 day_of_week: existingRule.day_of_week ?? 0,
367 interval_days: existingRule.interval_days || 7,
368 start_date: existingRule.start_date || todayYmd(),
369 end_date: existingRule.end_date || "",
370 excluded_dates: Array.isArray(existingRule.excluded_dates)
371 ? existingRule.excluded_dates
372 : [],
373 months: Array.isArray(existingRule.months) ? existingRule.months : [],
374 time_slots: Array.isArray(existingRule.time_slots)
375 ? existingRule.time_slots
376 : [],
377 pricing_type: effectivePricingType as "regular" | "traveler_based",
378 original_price: existingRule.original_price,
379 sale_price: existingRule.sale_price,
380 traveler_pricing: Array.isArray(existingRule.traveler_pricing)
381 ? existingRule.traveler_pricing
382 : [],
383 seats_total: existingRule.seats_total || 20,
384 departure_time: existingRule.departure_time || "",
385 arrival_time: existingRule.arrival_time || "",
386 from_location: existingRule.from_location || "",
387 to_location: existingRule.to_location || "",
388 from_latitude: coordFromApi(existingRule.from_latitude),
389 from_longitude: coordFromApi(existingRule.from_longitude),
390 to_latitude: coordFromApi(existingRule.to_latitude),
391 to_longitude: coordFromApi(existingRule.to_longitude),
392 cutoff_hours: existingRule.cutoff_hours || 24,
393 alert_threshold: existingRule.alert_threshold || 5,
394 status: existingRule.status || "active",
395 });
396 }
397 }, [existingRule, tripsData, tripForLocations]);
398
399 // Set pricing type based on selected trip when not editing
400 useEffect(() => {
401 if (!isEditing && !existingRule) {
402 const inferred = (() => {
403 const priceTypes = (tripForLocations as any)?.price_types;
404 const hasTravelerPricing =
405 Array.isArray(priceTypes) && priceTypes.length > 0;
406 return (
407 hasTravelerPricing
408 ? "traveler_based"
409 : selectedTrip?.pricing_type ||
410 (tripForLocations as Trip)?.pricing_type ||
411 "regular"
412 ) as "regular" | "traveler_based";
413 })();
414
415 setFormData((prev) => ({
416 ...prev,
417 ...(selectedTrip || tripForLocations
418 ? {
419 pricing_type: inferred,
420 }
421 : {}),
422 ...(tripForLocations
423 ? {
424 from_location:
425 prev.from_location ||
426 (tripForLocations as Trip).starting_location ||
427 "",
428 to_location:
429 prev.to_location ||
430 (tripForLocations as Trip).ending_location ||
431 "",
432 from_latitude:
433 prev.from_latitude ||
434 coordFromApi((tripForLocations as Trip).starting_latitude),
435 from_longitude:
436 prev.from_longitude ||
437 coordFromApi((tripForLocations as Trip).starting_longitude),
438 to_latitude:
439 prev.to_latitude ||
440 coordFromApi((tripForLocations as Trip).ending_latitude),
441 to_longitude:
442 prev.to_longitude ||
443 coordFromApi((tripForLocations as Trip).ending_longitude),
444 }
445 : {}),
446 }));
447 }
448 }, [isEditing, selectedTrip, tripForLocations, existingRule]);
449
450 // Create mutation
451 const createMutation = useMutation({
452 mutationFn: async (data: RecurringRule) => {
453 // `days_of_week` is a JSON column on the backend; send the array as-is
454 // and let the API JSON-encode it. Sending a CSV string (e.g. "0,1,2,3")
455 // is rejected by MySQL with "Invalid JSON text".
456 return await apiClient.post("/recurring-availability", {
457 ...data,
458 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
459 // Normalize month-week selector to backend contract.
460 week_of_month:
461 data.rule_type === "monthly"
462 ? ((data.week_of_month || "first") as string).toLowerCase()
463 : data.week_of_month,
464 // On create, omit empty arrays to keep payload small.
465 time_slots: data.time_slots.length > 0 ? data.time_slots : undefined,
466 traveler_pricing:
467 data.traveler_pricing && data.traveler_pricing.length > 0
468 ? data.traveler_pricing
469 : undefined,
470 });
471 },
472 onSuccess: () => {
473 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
474 showToast(__("Recurring rule created successfully", "yatra"), "success");
475 navigate({
476 subpage: "trips",
477 tab: "availability",
478 trip_id: formData.trip_id.toString(),
479 });
480 },
481 onError: (error: any) => {
482 showToast(
483 error?.message || __("Failed to create rule", "yatra"),
484 "error",
485 );
486 },
487 });
488
489 // Update mutation
490 const updateMutation = useMutation({
491 mutationFn: async (data: RecurringRule) => {
492 return await apiClient.put(`/recurring-availability/${ruleId}`, {
493 ...data,
494 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
495 week_of_month:
496 data.rule_type === "monthly"
497 ? ((data.week_of_month || "first") as string).toLowerCase()
498 : data.week_of_month,
499 // IMPORTANT: on update, send empty arrays explicitly to clear persisted
500 // JSON columns; omitting the key leaves the old value in DB.
501 time_slots: Array.isArray(data.time_slots) ? data.time_slots : [],
502 traveler_pricing: Array.isArray(data.traveler_pricing)
503 ? data.traveler_pricing
504 : [],
505 });
506 },
507 onSuccess: () => {
508 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
509 showToast(__("Recurring rule updated successfully", "yatra"), "success");
510 navigate({
511 subpage: "trips",
512 tab: "availability",
513 trip_id: formData.trip_id.toString(),
514 });
515 },
516 onError: (error: any) => {
517 showToast(
518 error?.message || __("Failed to update rule", "yatra"),
519 "error",
520 );
521 },
522 });
523
524 // Preview mutation
525 const previewMutation = useMutation({
526 mutationFn: async (data: RecurringRule) => {
527 return await apiClient.post("/recurring-availability/preview", {
528 ...data,
529 days_of_week: Array.isArray(data.days_of_week) ? data.days_of_week : [],
530 week_of_month:
531 data.rule_type === "monthly"
532 ? ((data.week_of_month || "first") as string).toLowerCase()
533 : data.week_of_month,
534 time_slots: data.time_slots.length > 0 ? data.time_slots : undefined,
535 traveler_pricing:
536 data.traveler_pricing && data.traveler_pricing.length > 0
537 ? data.traveler_pricing
538 : undefined,
539 preview_limit: 20,
540 });
541 },
542 onSuccess: (response) => {
543 // Unwrap the data property from API response
544 const previewResult = response?.data || response;
545 setPreviewData(previewResult);
546 },
547 onError: (error: any) => {
548 showToast(
549 error?.message || __("Failed to generate preview", "yatra"),
550 "error",
551 );
552 },
553 });
554
555 // Handle form submit
556 const handleSubmit = (e: React.FormEvent) => {
557 e.preventDefault();
558
559 if (!formData.trip_id) {
560 showToast(__("Please select a trip", "yatra"), "error");
561 return;
562 }
563
564 if (formData.rule_type === "weekly" && formData.days_of_week.length === 0) {
565 showToast(
566 __("Please select at least one day of the week", "yatra"),
567 "error",
568 );
569 return;
570 }
571
572 if (isEditing) {
573 updateMutation.mutate(formData);
574 } else {
575 createMutation.mutate(formData);
576 }
577 };
578
579 // Handle day toggle for weekly rules
580 const toggleDay = (day: number) => {
581 setFormData((prev) => ({
582 ...prev,
583 days_of_week: prev.days_of_week.includes(day)
584 ? prev.days_of_week.filter((d) => d !== day)
585 : [...prev.days_of_week, day].sort((a, b) => a - b),
586 }));
587 };
588
589 const sortExcluded = (list: ExcludedEntry[]): ExcludedEntry[] =>
590 [...list].sort((a, b) => excludedSortKey(a).localeCompare(excludedSortKey(b)));
591
592 // Add a single excluded day
593 const addExcludedDate = () => {
594 if (!newExcludedDate) {
595 return;
596 }
597 const exists = formData.excluded_dates.some(
598 (e) => excludedId(e) === newExcludedDate,
599 );
600 if (exists) {
601 setExcludedError(__("That date is already excluded.", "yatra"));
602 return;
603 }
604 setExcludedError("");
605 setFormData((prev) => ({
606 ...prev,
607 excluded_dates: sortExcluded([...prev.excluded_dates, newExcludedDate]),
608 }));
609 setNewExcludedDate("");
610 };
611
612 // Add an excluded period — one entry covering every day from start to end,
613 // so a shutdown doesn't have to be added a day at a time.
614 const addExcludedPeriod = () => {
615 if (!newExcludedStart || !newExcludedEnd) {
616 return;
617 }
618 // Accept the two dates in either order rather than rejecting the input.
619 const start =
620 newExcludedStart <= newExcludedEnd ? newExcludedStart : newExcludedEnd;
621 const end =
622 newExcludedStart <= newExcludedEnd ? newExcludedEnd : newExcludedStart;
623
624 if (start === end) {
625 // A one-day period is just a single date; keep the list in its simplest form.
626 if (formData.excluded_dates.some((e) => excludedId(e) === start)) {
627 setExcludedError(__("That date is already excluded.", "yatra"));
628 return;
629 }
630 setExcludedError("");
631 setFormData((prev) => ({
632 ...prev,
633 excluded_dates: sortExcluded([...prev.excluded_dates, start]),
634 }));
635 setNewExcludedStart("");
636 setNewExcludedEnd("");
637 return;
638 }
639
640 const id = `${start}..${end}`;
641 if (formData.excluded_dates.some((e) => excludedId(e) === id)) {
642 setExcludedError(__("That period is already excluded.", "yatra"));
643 return;
644 }
645 setExcludedError("");
646 setFormData((prev) => ({
647 ...prev,
648 excluded_dates: sortExcluded([...prev.excluded_dates, { start, end }]),
649 }));
650 setNewExcludedStart("");
651 setNewExcludedEnd("");
652 };
653
654 // Remove an excluded day or period
655 const removeExcluded = (id: string) => {
656 setExcludedError("");
657 setFormData((prev) => ({
658 ...prev,
659 excluded_dates: prev.excluded_dates.filter((e) => excludedId(e) !== id),
660 }));
661 };
662
663 const isLoading = createMutation.isPending || updateMutation.isPending;
664
665 if (isEditing && isLoadingRule) {
666 return <RecurringRuleFormSkeleton />;
667 }
668
669 return (
670 <div className="space-y-6">
671 <PageHeader
672 title={
673 isEditing
674 ? __("Edit Recurring Rule", "yatra")
675 : __("Create Recurring Rule", "yatra")
676 }
677 description={headerDescription}
678 actions={
679 <Button
680 variant="outline"
681 onClick={() =>
682 navigate({
683 subpage: "trips",
684 tab: "availability",
685 trip_id: formData.trip_id?.toString() || tripIdFromUrl || "",
686 })
687 }
688 >
689 <ArrowLeft className="w-4 h-4 mr-2" />
690 {__("Back to Availability", "yatra")}
691 </Button>
692 }
693 />
694
695 <form onSubmit={handleSubmit}>
696 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
697 {/* Main Form */}
698 <div className="lg:col-span-2 space-y-6">
699 {/* Basic Info */}
700 <Card>
701 <CardHeader>
702 <CardTitle>{__("Basic Information", "yatra")}</CardTitle>
703 </CardHeader>
704 <CardContent className="space-y-4">
705 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
706 <div>
707 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
708 {__("Trip", "yatra")}{" "}
709 <span className="text-red-500">*</span>
710 </label>
711 <SearchableSelect
712 value={formData.trip_id?.toString() || ""}
713 onChange={(value) =>
714 setFormData((prev) => ({
715 ...prev,
716 trip_id: parseInt(value) || 0,
717 // Reset pricing when trip changes and set pricing_type based on new trip
718 pricing_type: (tripsData?.trips.find(
719 (t) => t.id === parseInt(value),
720 )?.pricing_type || "regular") as
721 | "regular"
722 | "traveler_based",
723 traveler_pricing: [],
724 original_price: undefined,
725 sale_price: undefined,
726 }))
727 }
728 options={[
729 { value: "", label: __("-- Select Trip --", "yatra") },
730 ...(tripsData?.trips.map((trip) => ({
731 value: trip.id.toString(),
732 label: `${trip.title} (${trip.pricing_type === "traveler_based" ? "Traveler-Based" : "Regular"})`,
733 })) || []),
734 ]}
735 placeholder={__("Select a trip", "yatra")}
736 disabled={isEditing}
737 />
738 </div>
739 <div>
740 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
741 {__("Rule Name", "yatra")}
742 </label>
743 <Input
744 type="text"
745 value={formData.name}
746 onChange={(e) =>
747 setFormData((prev) => ({
748 ...prev,
749 name: e.target.value,
750 }))
751 }
752 placeholder={__("e.g., Weekend Departures", "yatra")}
753 />
754 </div>
755 </div>
756
757 <div>
758 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
759 {__("Rule Type", "yatra")}{" "}
760 <span className="text-red-500">*</span>
761 </label>
762 <Select
763 value={formData.rule_type}
764 onChange={(e) =>
765 setFormData((prev) => ({
766 ...prev,
767 rule_type: e.target.value as
768 | "weekly"
769 | "monthly"
770 | "interval",
771 }))
772 }
773 >
774 <option value="weekly">
775 {__("Weekly (specific days)", "yatra")}
776 </option>
777 <option value="monthly">
778 {__("Monthly (e.g., first Sunday)", "yatra")}
779 </option>
780 <option value="interval">
781 {__("Interval (every X days)", "yatra")}
782 </option>
783 </Select>
784 </div>
785 </CardContent>
786 </Card>
787
788 {/* Pattern Configuration */}
789 <Card>
790 <CardHeader>
791 <CardTitle className="flex items-center gap-2">
792 <RefreshCw className="w-5 h-5" />
793 {__("Recurrence Pattern", "yatra")}
794 </CardTitle>
795 </CardHeader>
796 <CardContent className="space-y-4">
797 {formData.rule_type === "weekly" && (
798 <div>
799 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
800 {__("Days of Week", "yatra")}{" "}
801 <span className="text-red-500">*</span>
802 </label>
803 <div className="flex flex-wrap gap-2">
804 {dayOptions.map((day) => (
805 <button
806 key={day.value}
807 type="button"
808 onClick={() => toggleDay(day.value)}
809 className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
810 formData.days_of_week.includes(day.value)
811 ? "bg-blue-600 text-white"
812 : "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
813 }`}
814 >
815 {day.label}
816 </button>
817 ))}
818 </div>
819 </div>
820 )}
821
822 {formData.rule_type === "monthly" && (
823 <div className="grid grid-cols-2 gap-4">
824 <div>
825 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
826 {__("Week of Month", "yatra")}
827 </label>
828 <Select
829 value={formData.week_of_month || "first"}
830 onChange={(e) =>
831 setFormData((prev) => ({
832 ...prev,
833 week_of_month: e.target.value as any,
834 }))
835 }
836 >
837 {weekOptions.map((week) => (
838 <option key={week.value} value={week.value}>
839 {week.label}
840 </option>
841 ))}
842 </Select>
843 </div>
844 <div>
845 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
846 {__("Day of Week", "yatra")}
847 </label>
848 <Select
849 value={formData.day_of_week?.toString() || "0"}
850 onChange={(e) =>
851 setFormData((prev) => ({
852 ...prev,
853 day_of_week: parseInt(e.target.value),
854 }))
855 }
856 >
857 {dayOptions.map((day) => (
858 <option key={day.value} value={day.value}>
859 {day.label}
860 </option>
861 ))}
862 </Select>
863 </div>
864 </div>
865 )}
866
867 {formData.rule_type === "interval" && (
868 <div>
869 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
870 {__("Every X Days", "yatra")}
871 </label>
872 <Input
873 type="number"
874 min={1}
875 max={365}
876 value={formData.interval_days || 7}
877 onChange={(e) =>
878 setFormData((prev) => ({
879 ...prev,
880 interval_days: parseInt(e.target.value) || 7,
881 }))
882 }
883 />
884 </div>
885 )}
886
887 {/* Date Range */}
888 <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
889 <div>
890 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
891 {__("Start Date", "yatra")}{" "}
892 <span className="text-red-500">*</span>
893 </label>
894 <DatePicker
895 value={formData.start_date}
896 onChange={(value: string) =>
897 setFormData((prev) => ({ ...prev, start_date: value }))
898 }
899 placeholder={__("Select start date", "yatra")}
900 />
901 </div>
902 <div>
903 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
904 {__("End Date", "yatra")}{" "}
905 <span className="text-gray-400">
906 ({__("optional", "yatra")})
907 </span>
908 </label>
909 <DatePicker
910 value={formData.end_date || ""}
911 onChange={(value: string) =>
912 setFormData((prev) => ({ ...prev, end_date: value }))
913 }
914 minDate={
915 formData.start_date
916 ? toDateValue(formData.start_date)
917 : undefined
918 }
919 placeholder={__("Select end date (optional)", "yatra")}
920 />
921 </div>
922 </div>
923
924 {/* Month Filter */}
925 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
926 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
927 {__("Specific Months", "yatra")}{" "}
928 <span className="text-gray-400">
929 ({__("optional - leave empty for all months", "yatra")})
930 </span>
931 </label>
932 <div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
933 {[
934 { value: 1, label: __("January", "yatra") },
935 { value: 2, label: __("February", "yatra") },
936 { value: 3, label: __("March", "yatra") },
937 { value: 4, label: __("April", "yatra") },
938 { value: 5, label: __("May", "yatra") },
939 { value: 6, label: __("June", "yatra") },
940 { value: 7, label: __("July", "yatra") },
941 { value: 8, label: __("August", "yatra") },
942 { value: 9, label: __("September", "yatra") },
943 { value: 10, label: __("October", "yatra") },
944 { value: 11, label: __("November", "yatra") },
945 { value: 12, label: __("December", "yatra") },
946 ].map((month) => (
947 <label
948 key={month.value}
949 className={`flex items-center justify-center px-3 py-2 rounded-md border cursor-pointer transition-colors ${
950 formData.months.includes(month.value)
951 ? "bg-blue-50 border-blue-500 text-blue-700 dark:bg-blue-900/20 dark:border-blue-600 dark:text-blue-300"
952 : "border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800"
953 }`}
954 >
955 <input
956 type="checkbox"
957 checked={formData.months.includes(month.value)}
958 onChange={(e) => {
959 const checked = e.target.checked;
960 setFormData((prev) => ({
961 ...prev,
962 months: checked
963 ? [...prev.months, month.value].sort(
964 (a, b) => a - b,
965 )
966 : prev.months.filter((m) => m !== month.value),
967 }));
968 }}
969 className="sr-only"
970 />
971 <span className="text-sm font-medium">
972 {month.label.slice(0, 3)}
973 </span>
974 </label>
975 ))}
976 </div>
977 {formData.months.length > 0 && (
978 <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
979 {__("Selected:", "yatra")} {formData.months.length}{" "}
980 {formData.months.length === 1
981 ? __("month", "yatra")
982 : __("months", "yatra")}
983 </p>
984 )}
985 </div>
986
987 {/* Excluded Dates & Periods */}
988 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
989 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
990 {__("Excluded Dates", "yatra")}{" "}
991 <span className="text-gray-400">
992 ({__("holidays, etc.", "yatra")})
993 </span>
994 </label>
995 <div className="flex gap-2 mb-2">
996 <div className="flex-1">
997 <DatePicker
998 value={newExcludedDate}
999 onChange={(value: string) => setNewExcludedDate(value)}
1000 placeholder={__("Select date to exclude", "yatra")}
1001 />
1002 </div>
1003 <Button
1004 type="button"
1005 variant="outline"
1006 onClick={addExcludedDate}
1007 disabled={!newExcludedDate}
1008 >
1009 <Plus className="w-4 h-4" />
1010 </Button>
1011 </div>
1012
1013 {/* A whole period in one go — a shutdown shouldn't have to be
1014 added a day at a time. Named "Periods" so it can't be
1015 confused with the rule's own Date Range above. */}
1016 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 mt-4">
1017 {__("Excluded Periods", "yatra")}{" "}
1018 <span className="text-gray-400">
1019 ({__("holiday closures, vacations", "yatra")})
1020 </span>
1021 </label>
1022 <div className="flex flex-col sm:flex-row gap-2 mb-1">
1023 <div className="flex-1">
1024 <DatePicker
1025 value={newExcludedStart}
1026 onChange={(value: string) => setNewExcludedStart(value)}
1027 placeholder={__("First day", "yatra")}
1028 />
1029 </div>
1030 <div className="flex-1">
1031 <DatePicker
1032 value={newExcludedEnd}
1033 onChange={(value: string) => setNewExcludedEnd(value)}
1034 placeholder={__("Last day", "yatra")}
1035 />
1036 </div>
1037 <Button
1038 type="button"
1039 variant="outline"
1040 onClick={addExcludedPeriod}
1041 disabled={!newExcludedStart || !newExcludedEnd}
1042 >
1043 <Plus className="w-4 h-4" />
1044 </Button>
1045 </div>
1046 <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
1047 {__(
1048 "Both days are included. Every date in between is skipped by this rule.",
1049 "yatra",
1050 )}
1051 </p>
1052
1053 {excludedError && (
1054 <p className="text-xs text-red-600 dark:text-red-400 mb-2">
1055 {excludedError}
1056 </p>
1057 )}
1058
1059 {formData.excluded_dates.length > 0 && (
1060 <div className="flex flex-wrap gap-2">
1061 {formData.excluded_dates.map((entry) => {
1062 const id = excludedId(entry);
1063 const fmt = (d: string) =>
1064 new Date(d + "T00:00:00").toLocaleDateString("en-US", {
1065 weekday: "short",
1066 month: "short",
1067 day: "numeric",
1068 year: "numeric",
1069 });
1070 return (
1071 <Badge
1072 key={id}
1073 variant="outline"
1074 className="flex items-center gap-1"
1075 >
1076 {isPeriod(entry)
1077 ? `${fmt(entry.start)} → ${fmt(entry.end)}`
1078 : fmt(entry)}
1079 <button
1080 type="button"
1081 onClick={() => removeExcluded(id)}
1082 className="ml-1 hover:text-red-500"
1083 aria-label={__("Remove exclusion", "yatra")}
1084 >
1085 <X className="w-3 h-3" />
1086 </button>
1087 </Badge>
1088 );
1089 })}
1090 </div>
1091 )}
1092 </div>
1093 </CardContent>
1094 </Card>
1095
1096 {/* Pricing & Capacity */}
1097 <Card>
1098 <CardHeader>
1099 <CardTitle className="flex items-center gap-2">
1100 <DollarSign className="w-5 h-5" />
1101 {__("Pricing & Availability", "yatra")}
1102 </CardTitle>
1103 <CardDescription>
1104 {__(
1105 "Set pricing for traveler categories and seat availability for this rule",
1106 "yatra",
1107 )}
1108 </CardDescription>
1109 </CardHeader>
1110 <CardContent className="space-y-6">
1111 {/* Pricing Override Info */}
1112 <div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-4">
1113 <div className="flex items-start gap-3">
1114 <div className="p-2 rounded-full bg-amber-100 dark:bg-amber-900/30">
1115 <svg
1116 className="w-5 h-5 text-amber-600 dark:text-amber-400"
1117 fill="none"
1118 stroke="currentColor"
1119 viewBox="0 0 24 24"
1120 >
1121 <path
1122 strokeLinecap="round"
1123 strokeLinejoin="round"
1124 strokeWidth={2}
1125 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1126 />
1127 </svg>
1128 </div>
1129 <div className="flex-1">
1130 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1131 {__("Pricing Override (Optional)", "yatra")}
1132 </p>
1133 <p className="text-xs text-gray-600 dark:text-gray-400">
1134 {__(
1135 "Leave pricing fields empty to use the trip's default pricing. Fill them in only if you want to override the default pricing for dates generated by this rule.",
1136 "yatra",
1137 )}
1138 </p>
1139 </div>
1140 </div>
1141 </div>
1142
1143 {/* Pricing Type Info - Inherited from Trip */}
1144 <div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
1145 <div className="flex items-center gap-3">
1146 <div
1147 className={`p-2 rounded-full ${isTravelerBasedPricing ? "bg-green-100 dark:bg-green-900/30" : "bg-blue-100 dark:bg-blue-900/30"}`}
1148 >
1149 {isTravelerBasedPricing ? (
1150 <svg
1151 className="w-5 h-5 text-green-600 dark:text-green-400"
1152 fill="none"
1153 stroke="currentColor"
1154 viewBox="0 0 24 24"
1155 >
1156 <path
1157 strokeLinecap="round"
1158 strokeLinejoin="round"
1159 strokeWidth={2}
1160 d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
1161 />
1162 </svg>
1163 ) : (
1164 <svg
1165 className="w-5 h-5 text-blue-600 dark:text-blue-400"
1166 fill="none"
1167 stroke="currentColor"
1168 viewBox="0 0 24 24"
1169 >
1170 <path
1171 strokeLinecap="round"
1172 strokeLinejoin="round"
1173 strokeWidth={2}
1174 d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1175 />
1176 </svg>
1177 )}
1178 </div>
1179 <div>
1180 <p className="text-sm font-medium text-gray-900 dark:text-white">
1181 {isTravelerBasedPricing
1182 ? __("Traveler-Based Pricing", "yatra")
1183 : __("Regular Pricing", "yatra")}
1184 </p>
1185 <p className="text-xs text-gray-600 dark:text-gray-400">
1186 {isTravelerBasedPricing
1187 ? __(
1188 "This trip uses traveler category pricing. Set prices for each category below.",
1189 "yatra",
1190 )
1191 : __(
1192 "This trip uses regular pricing. Set a single price for all travelers below.",
1193 "yatra",
1194 )}
1195 </p>
1196 </div>
1197 </div>
1198 </div>
1199
1200 {/* Regular Pricing Fields */}
1201 {!isTravelerBasedPricing && (
1202 <div className="space-y-4">
1203 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1204 <div>
1205 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1206 {__("Original Price", "yatra")}{" "}
1207 <span className="text-gray-400 text-xs">
1208 ({__("Optional", "yatra")})
1209 </span>
1210 </label>
1211 <Input
1212 type="number"
1213 min={0}
1214 step="0.01"
1215 value={formData.original_price || ""}
1216 onChange={(e) =>
1217 setFormData((prev) => ({
1218 ...prev,
1219 original_price:
1220 parseFloat(e.target.value) || undefined,
1221 }))
1222 }
1223 placeholder="0.00"
1224 />
1225 </div>
1226 <div>
1227 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1228 {__("Sale Price", "yatra")}
1229 </label>
1230 <Input
1231 type="number"
1232 min={0}
1233 step="0.01"
1234 value={formData.sale_price || ""}
1235 onChange={(e) =>
1236 setFormData((prev) => ({
1237 ...prev,
1238 sale_price:
1239 parseFloat(e.target.value) || undefined,
1240 }))
1241 }
1242 placeholder="0.00"
1243 />
1244 <p className="mt-1 text-xs text-gray-500">
1245 {__("Leave empty if no discount", "yatra")}
1246 </p>
1247 </div>
1248 </div>
1249 </div>
1250 )}
1251
1252 {/* Traveler-Based Pricing */}
1253 {isTravelerBasedPricing && (
1254 <div className="space-y-4">
1255 <div>
1256 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1257 {__("Traveler Category Pricing", "yatra")}{" "}
1258 <span className="text-red-500">*</span>
1259 </label>
1260 <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
1261 {__(
1262 "Add pricing for traveler categories. Categories are managed in Traveler Categories page.",
1263 "yatra",
1264 )}
1265 </p>
1266 </div>
1267
1268 {/* Active Categories Filter */}
1269 {(() => {
1270 const activeCategories = travelerCategories.filter(
1271 (cat: TravelerCategory) =>
1272 cat.status === "active" || cat.status === "publish",
1273 );
1274
1275 if (activeCategories.length === 0) {
1276 return (
1277 <div className="p-6 border border-gray-200 dark:border-gray-700 rounded-lg text-center">
1278 <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
1279 {__(
1280 "No active traveler categories found.",
1281 "yatra",
1282 )}
1283 </p>
1284 <Button
1285 type="button"
1286 variant="outline"
1287 onClick={() =>
1288 (window.location.href =
1289 "?page=yatra&subpage=traveler-categories&action=create")
1290 }
1291 className="flex items-center gap-2 mx-auto"
1292 >
1293 <Plus className="w-4 h-4" />
1294 {__("Create Category", "yatra")}
1295 </Button>
1296 </div>
1297 );
1298 }
1299
1300 return (
1301 <div className="space-y-4">
1302 {/* Add Pricing Button with Dropdown */}
1303 <div className="relative">
1304 <Button
1305 type="button"
1306 variant="outline"
1307 onClick={() =>
1308 setShowCategorySelector(!showCategorySelector)
1309 }
1310 className="flex items-center gap-2"
1311 disabled={
1312 activeCategories.filter(
1313 (cat) =>
1314 !formData.traveler_pricing?.some(
1315 (tp) => tp.category_id === cat.id,
1316 ),
1317 ).length === 0
1318 }
1319 >
1320 <Plus className="w-4 h-4" />
1321 {__("Add Pricing", "yatra")}
1322 </Button>
1323
1324 {/* Category Selection Dropdown */}
1325 {showCategorySelector && (
1326 <>
1327 <div
1328 className="fixed inset-0 z-10"
1329 onClick={() => setShowCategorySelector(false)}
1330 />
1331 <div className="absolute top-full left-0 mt-2 w-full max-w-md bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 max-h-96 overflow-y-auto">
1332 <div className="p-2">
1333 <div className="text-xs font-medium text-gray-700 dark:text-gray-300 px-3 py-2 mb-1">
1334 {__(
1335 "Select a category to add pricing",
1336 "yatra",
1337 )}
1338 </div>
1339 {activeCategories.filter(
1340 (cat) =>
1341 !formData.traveler_pricing?.some(
1342 (tp) => tp.category_id === cat.id,
1343 ),
1344 ).length === 0 ? (
1345 <div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center">
1346 {__(
1347 "All categories have pricing added",
1348 "yatra",
1349 )}
1350 </div>
1351 ) : (
1352 <div className="space-y-1">
1353 {activeCategories
1354 .filter(
1355 (cat) =>
1356 !formData.traveler_pricing?.some(
1357 (tp) =>
1358 tp.category_id === cat.id,
1359 ),
1360 )
1361 .map((category: TravelerCategory) => {
1362 const minAge =
1363 category.age_min ??
1364 category.min_age;
1365 const maxAge =
1366 category.age_max ??
1367 category.max_age;
1368 const ageRange =
1369 minAge !== undefined ||
1370 maxAge !== undefined
1371 ? minAge !== undefined &&
1372 maxAge !== undefined
1373 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1374 : minAge !== undefined
1375 ? `${minAge}+ ${__("years", "yatra")}`
1376 : maxAge !== undefined
1377 ? `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
1378 : ""
1379 : null;
1380 const categoryName =
1381 category.label ||
1382 category.name ||
1383 `Category ${category.id}`;
1384
1385 return (
1386 <button
1387 key={category.id}
1388 type="button"
1389 onClick={() => {
1390 setFormData((prev) => ({
1391 ...prev,
1392 traveler_pricing: [
1393 ...(prev.traveler_pricing ||
1394 []),
1395 {
1396 category_id:
1397 category.id,
1398 original_price: 0,
1399 sale_price: undefined,
1400 },
1401 ],
1402 }));
1403 setShowCategorySelector(
1404 false,
1405 );
1406 }}
1407 className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
1408 >
1409 <div className="font-medium text-sm text-gray-900 dark:text-white">
1410 {categoryName}
1411 {ageRange && (
1412 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1413 ({ageRange})
1414 </span>
1415 )}
1416 </div>
1417 {category.description && (
1418 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1419 {category.description}
1420 </div>
1421 )}
1422 </button>
1423 );
1424 })}
1425 </div>
1426 )}
1427 </div>
1428 </div>
1429 </>
1430 )}
1431 </div>
1432
1433 {/* Added Pricing List */}
1434 {formData.traveler_pricing &&
1435 formData.traveler_pricing.length > 0 && (
1436 <div className="space-y-3">
1437 {formData.traveler_pricing.map(
1438 (pricing, index) => {
1439 const category = activeCategories.find(
1440 (cat) => cat.id === pricing.category_id,
1441 );
1442 if (!category) return null;
1443
1444 const minAge =
1445 category.age_min ?? category.min_age;
1446 const maxAge =
1447 category.age_max ?? category.max_age;
1448 const categoryName =
1449 category.label ||
1450 category.name ||
1451 `Category ${pricing.category_id}`;
1452
1453 return (
1454 <div
1455 key={pricing.category_id}
1456 className="p-4 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
1457 >
1458 <div className="flex items-start justify-between mb-3">
1459 <div className="flex-1">
1460 <div className="flex items-center gap-2 mb-1">
1461 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
1462 {categoryName}
1463 {(minAge !== undefined ||
1464 maxAge !== undefined) && (
1465 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1466 (
1467 {minAge !== undefined &&
1468 maxAge !== undefined
1469 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1470 : minAge !== undefined
1471 ? `${minAge}+ ${__("years", "yatra")}`
1472 : maxAge !== undefined
1473 ? `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
1474 : ""}
1475 )
1476 </span>
1477 )}
1478 </h4>
1479 </div>
1480 {category.description && (
1481 <p className="text-xs text-gray-600 dark:text-gray-400">
1482 {category.description}
1483 </p>
1484 )}
1485 </div>
1486 <button
1487 type="button"
1488 onClick={() => {
1489 const categoryIdToRemove =
1490 pricing.category_id;
1491 setFormData((prev) => ({
1492 ...prev,
1493 traveler_pricing: (
1494 prev.traveler_pricing || []
1495 ).filter(
1496 (tp) =>
1497 tp.category_id !==
1498 categoryIdToRemove,
1499 ),
1500 }));
1501 }}
1502 className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
1503 title={__(
1504 "Remove Pricing",
1505 "yatra",
1506 )}
1507 >
1508 <X className="w-4 h-4" />
1509 </button>
1510 </div>
1511 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1512 <div>
1513 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1514 {__("Original Price", "yatra")}{" "}
1515 <span className="text-red-500">
1516 *
1517 </span>
1518 </label>
1519 <Input
1520 type="number"
1521 min={0}
1522 step="0.01"
1523 value={
1524 pricing.original_price || ""
1525 }
1526 onChange={(e) => {
1527 const newPricing = [
1528 ...(formData.traveler_pricing ||
1529 []),
1530 ];
1531 newPricing[
1532 index
1533 ].original_price =
1534 parseFloat(e.target.value) ||
1535 0;
1536 setFormData((prev) => ({
1537 ...prev,
1538 traveler_pricing: newPricing,
1539 }));
1540 }}
1541 placeholder="0.00"
1542 />
1543 </div>
1544 <div>
1545 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1546 {__("Sale Price", "yatra")} (
1547 {__("Optional", "Optional")})
1548 </label>
1549 <Input
1550 type="number"
1551 min={0}
1552 step="0.01"
1553 value={pricing.sale_price || ""}
1554 onChange={(e) => {
1555 const newPricing = [
1556 ...(formData.traveler_pricing ||
1557 []),
1558 ];
1559 newPricing[index].sale_price =
1560 parseFloat(e.target.value) ||
1561 undefined;
1562 setFormData((prev) => ({
1563 ...prev,
1564 traveler_pricing: newPricing,
1565 }));
1566 }}
1567 className="text-sm"
1568 placeholder="0.00"
1569 />
1570 </div>
1571 </div>
1572 </div>
1573 );
1574 },
1575 )}
1576 </div>
1577 )}
1578 </div>
1579 );
1580 })()}
1581 </div>
1582 )}
1583
1584 {/* Inventory Management - Common for both pricing types */}
1585 {selectedTrip && (
1586 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
1587 <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
1588 {__("Inventory Management", "yatra")}
1589 </h4>
1590 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1591 <div>
1592 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1593 {__("Total Capacity", "yatra")}{" "}
1594 <span className="text-red-500">*</span>
1595 </label>
1596 <Input
1597 type="number"
1598 min={1}
1599 value={formData.seats_total}
1600 onChange={(e) =>
1601 setFormData((prev) => ({
1602 ...prev,
1603 seats_total: parseInt(e.target.value) || 1,
1604 }))
1605 }
1606 />
1607 <p className="mt-1 text-xs text-gray-500">
1608 {__(
1609 "Maximum number of seats available for this rule",
1610 "yatra",
1611 )}
1612 </p>
1613 </div>
1614 <div>
1615 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1616 {__("Alert Threshold", "yatra")}
1617 </label>
1618 <Input
1619 type="number"
1620 min={0}
1621 value={formData.alert_threshold}
1622 onChange={(e) =>
1623 setFormData((prev) => ({
1624 ...prev,
1625 alert_threshold: parseInt(e.target.value) || 0,
1626 }))
1627 }
1628 />
1629 <p className="mt-1 text-xs text-gray-500">
1630 {__(
1631 "Alert when available seats drop below this number",
1632 "yatra",
1633 )}
1634 </p>
1635 </div>
1636 </div>
1637 </div>
1638 )}
1639
1640 {/* No trip selected message */}
1641 {!selectedTrip && (
1642 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
1643 <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
1644 <p className="text-sm text-gray-500 dark:text-gray-400">
1645 {__("Select a trip above to configure pricing", "yatra")}
1646 </p>
1647 </div>
1648 )}
1649 </CardContent>
1650 </Card>
1651
1652 {/* Time & Location */}
1653 <Card>
1654 <CardHeader>
1655 <CardTitle className="flex items-center gap-2">
1656 <Clock className="w-5 h-5" />
1657 {__("Time & Location", "yatra")}
1658 </CardTitle>
1659 {selectedTrip && (
1660 <CardDescription>
1661 <Badge
1662 variant={isSingleDayTrip ? "default" : "outline"}
1663 className="mr-2"
1664 >
1665 {isSingleDayTrip
1666 ? __("Single-Day Trip", "yatra")
1667 : __("Multi-Day Trip", "yatra")}
1668 </Badge>
1669 {!isSingleDayTrip && selectedTrip.duration_days && (
1670 <span className="text-gray-500">
1671 ({selectedTrip.duration_days} {__("days", "yatra")})
1672 </span>
1673 )}
1674 </CardDescription>
1675 )}
1676 </CardHeader>
1677 <CardContent className="space-y-4">
1678 {/* Single-Day Trip: Multiple Time Slots */}
1679 {isSingleDayTrip && formData.trip_id > 0 && (
1680 <div className="space-y-4">
1681 <div className="flex items-center justify-between">
1682 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
1683 {__("Time Slots", "yatra")}
1684 <span className="text-gray-400 ml-1">
1685 ({__("for each recurring day", "yatra")})
1686 </span>
1687 </label>
1688 <Button
1689 type="button"
1690 variant="outline"
1691 size="sm"
1692 onClick={() =>
1693 setFormData((prev) => ({
1694 ...prev,
1695 time_slots: [
1696 ...prev.time_slots,
1697 {
1698 departure_time: "09:00",
1699 arrival_time: "17:00",
1700 seats: 20,
1701 price: 0,
1702 traveler_pricing: isTravelerBasedPricing
1703 ? []
1704 : undefined,
1705 },
1706 ],
1707 }))
1708 }
1709 >
1710 <Plus className="w-4 h-4 mr-1" />
1711 {__("Add Slot", "yatra")}
1712 </Button>
1713 </div>
1714
1715 {formData.time_slots.length === 0 ? (
1716 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
1717 <Clock className="w-8 h-8 text-gray-400 mx-auto mb-2" />
1718 <p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
1719 {__("No time slots added yet", "yatra")}
1720 </p>
1721 <p className="text-xs text-gray-400 dark:text-gray-500">
1722 {__(
1723 "Add multiple time slots for tours throughout the day (e.g., morning, afternoon, evening)",
1724 "yatra",
1725 )}
1726 </p>
1727 </div>
1728 ) : (
1729 <div className="space-y-3">
1730 {formData.time_slots.map((slot, index) => (
1731 <div
1732 key={index}
1733 className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
1734 >
1735 <div className="flex items-center justify-between mb-3">
1736 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
1737 {__("Slot", "yatra")} {index + 1}
1738 </span>
1739 <Button
1740 type="button"
1741 variant="ghost"
1742 size="sm"
1743 onClick={() =>
1744 setFormData((prev) => ({
1745 ...prev,
1746 time_slots: prev.time_slots.filter(
1747 (_, i) => i !== index,
1748 ),
1749 }))
1750 }
1751 className="text-red-500 hover:text-red-600 h-8 w-8 p-0"
1752 >
1753 <X className="w-4 h-4" />
1754 </Button>
1755 </div>
1756 <div className="space-y-3">
1757 {/* Time fields on first line */}
1758 <div className="grid grid-cols-2 gap-3">
1759 <div>
1760 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1761 {__("Departure Time", "yatra")}
1762 </label>
1763 <TimePicker
1764 value={slot.departure_time || ""}
1765 onChange={(value: string) => {
1766 const newSlots = [...formData.time_slots];
1767 newSlots[index].departure_time = value;
1768 setFormData((prev) => ({
1769 ...prev,
1770 time_slots: newSlots,
1771 }));
1772 }}
1773 placeholder="09:00"
1774 />
1775 </div>
1776 <div>
1777 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1778 {__("Arrival Time", "yatra")}
1779 </label>
1780 <TimePicker
1781 value={slot.arrival_time || ""}
1782 onChange={(value: string) => {
1783 const newSlots = [...formData.time_slots];
1784 newSlots[index].arrival_time = value;
1785 setFormData((prev) => ({
1786 ...prev,
1787 time_slots: newSlots,
1788 }));
1789 }}
1790 placeholder="17:00"
1791 />
1792 </div>
1793 </div>
1794
1795 {/* Seats, Price and Sale Price on second line */}
1796 {!isTravelerBasedPricing && (
1797 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
1798 <div>
1799 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1800 {__("Seats", "yatra")}
1801 </label>
1802 <Input
1803 type="number"
1804 min={1}
1805 value={slot.seats}
1806 onChange={(e) => {
1807 const newSlots = [
1808 ...formData.time_slots,
1809 ];
1810 newSlots[index].seats =
1811 parseInt(e.target.value) || 1;
1812 setFormData((prev) => ({
1813 ...prev,
1814 time_slots: newSlots,
1815 }));
1816 }}
1817 className="text-sm"
1818 />
1819 </div>
1820 <div>
1821 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1822 {__("Price", "yatra")}
1823 </label>
1824 <Input
1825 type="number"
1826 min={0}
1827 step="0.01"
1828 value={slot.price || ""}
1829 onChange={(e) => {
1830 const newSlots = [
1831 ...formData.time_slots,
1832 ];
1833 newSlots[index].price =
1834 parseFloat(e.target.value) || 0;
1835 setFormData((prev) => ({
1836 ...prev,
1837 time_slots: newSlots,
1838 }));
1839 }}
1840 className="text-sm"
1841 placeholder="0.00"
1842 />
1843 </div>
1844 <div>
1845 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1846 {__("Sale Price", "yatra")}
1847 </label>
1848 <Input
1849 type="number"
1850 min={0}
1851 step="0.01"
1852 value={slot.sale_price || ""}
1853 onChange={(e) => {
1854 const newSlots = [
1855 ...formData.time_slots,
1856 ];
1857 newSlots[index].sale_price =
1858 parseFloat(e.target.value) ||
1859 undefined;
1860 setFormData((prev) => ({
1861 ...prev,
1862 time_slots: newSlots,
1863 }));
1864 }}
1865 className="text-sm"
1866 placeholder="0.00"
1867 />
1868 </div>
1869 </div>
1870 )}
1871
1872 {/* For traveler-based pricing, only show seats on second line */}
1873 {isTravelerBasedPricing && (
1874 <div>
1875 <label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">
1876 {__("Seats", "yatra")}
1877 </label>
1878 <Input
1879 type="number"
1880 min={1}
1881 value={slot.seats}
1882 onChange={(e) => {
1883 const newSlots = [...formData.time_slots];
1884 newSlots[index].seats =
1885 parseInt(e.target.value) || 1;
1886 setFormData((prev) => ({
1887 ...prev,
1888 time_slots: newSlots,
1889 }));
1890 }}
1891 className="text-sm"
1892 />
1893 </div>
1894 )}
1895 </div>
1896
1897 {/* Traveler Pricing for Time Slot - only show when traveler-based */}
1898 {isTravelerBasedPricing && (
1899 <div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
1900 <div className="flex items-center justify-between mb-3">
1901 <div>
1902 <span className="text-xs font-medium text-gray-700 dark:text-gray-300">
1903 {__("Traveler Category Pricing", "yatra")}
1904 </span>
1905 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1906 {__(
1907 "Set pricing for each traveler category for this time slot",
1908 "yatra",
1909 )}
1910 </p>
1911 </div>
1912 </div>
1913
1914 {(() => {
1915 const activeCategories =
1916 travelerCategories.filter(
1917 (cat: TravelerCategory) =>
1918 cat.status === "active" ||
1919 cat.status === "publish",
1920 );
1921 const slotTravelerPricing =
1922 slot.traveler_pricing || [];
1923 const availableCategories =
1924 activeCategories.filter(
1925 (cat) =>
1926 !slotTravelerPricing.some(
1927 (tp) => tp.category_id === cat.id,
1928 ),
1929 );
1930
1931 return (
1932 <div className="space-y-3">
1933 {/* Add Pricing Button with Dropdown */}
1934 <div className="relative">
1935 <Button
1936 type="button"
1937 variant="outline"
1938 size="sm"
1939 onClick={() => {
1940 // Toggle dropdown for this specific slot
1941 const dropdownId = `slot-${index}-category-dropdown`;
1942 const dropdown =
1943 document.getElementById(
1944 dropdownId,
1945 );
1946 if (dropdown) {
1947 dropdown.classList.toggle(
1948 "hidden",
1949 );
1950 }
1951 }}
1952 disabled={
1953 availableCategories.length === 0
1954 }
1955 >
1956 <Plus className="w-3 h-3 mr-1" />
1957 {__("Add Pricing", "yatra")}
1958 </Button>
1959
1960 {/* Category Selection Dropdown */}
1961 <div
1962 id={`slot-${index}-category-dropdown`}
1963 className="hidden absolute top-full left-0 mt-2 w-full max-w-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 max-h-64 overflow-y-auto"
1964 >
1965 <div className="p-2">
1966 <div className="text-xs font-medium text-gray-700 dark:text-gray-300 px-3 py-2 mb-1">
1967 {__(
1968 "Select a category to add pricing",
1969 "yatra",
1970 )}
1971 </div>
1972 {availableCategories.length ===
1973 0 ? (
1974 <div className="px-3 py-3 text-xs text-gray-500 dark:text-gray-400 text-center">
1975 {__(
1976 "All categories have pricing added",
1977 "yatra",
1978 )}
1979 </div>
1980 ) : (
1981 <div className="space-y-1">
1982 {availableCategories.map(
1983 (
1984 category: TravelerCategory,
1985 ) => {
1986 const minAge =
1987 category.age_min ??
1988 category.min_age;
1989 const maxAge =
1990 category.age_max ??
1991 category.max_age;
1992 const ageRange =
1993 minAge !== undefined ||
1994 maxAge !== undefined
1995 ? minAge !==
1996 undefined &&
1997 maxAge !== undefined
1998 ? `${minAge}-${maxAge} ${__("years", "yatra")}`
1999 : minAge !== undefined
2000 ? `${minAge}+ ${__("years", "yatra")}`
2001 : `${__("Under", "yatra")} ${maxAge} ${__("years", "yatra")}`
2002 : null;
2003 const categoryName =
2004 category.label ||
2005 category.name ||
2006 `Category ${category.id}`;
2007
2008 return (
2009 <button
2010 key={category.id}
2011 type="button"
2012 onClick={() => {
2013 const newSlots = [
2014 ...formData.time_slots,
2015 ];
2016 if (
2017 !newSlots[index]
2018 .traveler_pricing
2019 ) {
2020 newSlots[
2021 index
2022 ].traveler_pricing =
2023 [];
2024 }
2025 newSlots[
2026 index
2027 ].traveler_pricing = [
2028 ...newSlots[index]
2029 .traveler_pricing!,
2030 {
2031 category_id:
2032 category.id,
2033 original_price: 0,
2034 sale_price:
2035 undefined,
2036 },
2037 ];
2038 setFormData(
2039 (prev) => ({
2040 ...prev,
2041 time_slots:
2042 newSlots,
2043 }),
2044 );
2045 // Hide dropdown
2046 const dropdown =
2047 document.getElementById(
2048 `slot-${index}-category-dropdown`,
2049 );
2050 if (dropdown)
2051 dropdown.classList.add(
2052 "hidden",
2053 );
2054 }}
2055 className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
2056 >
2057 <div className="font-medium text-xs text-gray-900 dark:text-white">
2058 {categoryName}
2059 {ageRange && (
2060 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
2061 ({ageRange})
2062 </span>
2063 )}
2064 </div>
2065 {category.description && (
2066 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
2067 {
2068 category.description
2069 }
2070 </div>
2071 )}
2072 </button>
2073 );
2074 },
2075 )}
2076 </div>
2077 )}
2078 </div>
2079 </div>
2080 </div>
2081
2082 {/* Added Pricing List */}
2083 {slotTravelerPricing.length > 0 && (
2084 <div className="space-y-2">
2085 {slotTravelerPricing.map(
2086 (tp, tpIndex) => {
2087 const category =
2088 activeCategories.find(
2089 (cat) =>
2090 cat.id === tp.category_id,
2091 );
2092 if (!category) return null;
2093
2094 const minAge =
2095 category.age_min ??
2096 category.min_age;
2097 const maxAge =
2098 category.age_max ??
2099 category.max_age;
2100 const categoryName =
2101 category.label ||
2102 category.name ||
2103 `Category ${tp.category_id}`;
2104
2105 return (
2106 <div
2107 key={tp.category_id}
2108 className="p-3 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
2109 >
2110 <div className="flex items-start justify-between mb-2">
2111 <div className="flex-1">
2112 <span className="text-xs font-semibold text-gray-900 dark:text-white">
2113 {categoryName}
2114 {(minAge !==
2115 undefined ||
2116 maxAge !==
2117 undefined) && (
2118 <span className="ml-1 text-xs font-normal text-gray-500 dark:text-gray-400">
2119 (
2120 {minAge !==
2121 undefined &&
2122 maxAge !== undefined
2123 ? `${minAge}-${maxAge}`
2124 : minAge !==
2125 undefined
2126 ? `${minAge}+`
2127 : `<${maxAge}`}{" "}
2128 {__(
2129 "years",
2130 "yatra",
2131 )}
2132 )
2133 </span>
2134 )}
2135 </span>
2136 </div>
2137 <button
2138 type="button"
2139 onClick={() => {
2140 const categoryIdToRemove =
2141 tp.category_id;
2142 setFormData((prev) => {
2143 const newSlots = [
2144 ...prev.time_slots,
2145 ];
2146 if (
2147 !newSlots[index]
2148 ) {
2149 return prev;
2150 }
2151 newSlots[index] = {
2152 ...newSlots[index],
2153 traveler_pricing: (
2154 newSlots[index]
2155 .traveler_pricing ||
2156 []
2157 ).filter(
2158 (p) =>
2159 p.category_id !==
2160 categoryIdToRemove,
2161 ),
2162 };
2163 return {
2164 ...prev,
2165 time_slots:
2166 newSlots,
2167 };
2168 });
2169 }}
2170 className="p-0.5 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
2171 >
2172 <X className="w-3 h-3" />
2173 </button>
2174 </div>
2175 <div className="grid grid-cols-2 gap-2">
2176 <div>
2177 <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
2178 {__("Price", "yatra")}{" "}
2179 <span className="text-red-500">
2180 *
2181 </span>
2182 </label>
2183 <Input
2184 type="number"
2185 min={0}
2186 step="0.01"
2187 value={
2188 tp.original_price ||
2189 ""
2190 }
2191 onChange={(e) => {
2192 const newSlots = [
2193 ...formData.time_slots,
2194 ];
2195 if (
2196 newSlots[index] &&
2197 newSlots[index]
2198 .traveler_pricing
2199 ) {
2200 newSlots[
2201 index
2202 ].traveler_pricing![
2203 tpIndex
2204 ].original_price =
2205 parseFloat(
2206 e.target.value,
2207 ) || 0;
2208 setFormData(
2209 (prev) => ({
2210 ...prev,
2211 time_slots:
2212 newSlots,
2213 }),
2214 );
2215 }
2216 }}
2217 className="text-xs"
2218 placeholder="0.00"
2219 />
2220 </div>
2221 <div>
2222 <label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
2223 {__("Sale", "yatra")}
2224 </label>
2225 <Input
2226 type="number"
2227 min={0}
2228 step="0.01"
2229 value={
2230 tp.sale_price || ""
2231 }
2232 onChange={(e) => {
2233 const newSlots = [
2234 ...formData.time_slots,
2235 ];
2236 if (
2237 newSlots[index] &&
2238 newSlots[index]
2239 .traveler_pricing
2240 ) {
2241 newSlots[
2242 index
2243 ].traveler_pricing![
2244 tpIndex
2245 ].sale_price =
2246 parseFloat(
2247 e.target.value,
2248 ) || undefined;
2249 setFormData(
2250 (prev) => ({
2251 ...prev,
2252 time_slots:
2253 newSlots,
2254 }),
2255 );
2256 }
2257 }}
2258 className="text-xs"
2259 placeholder="0.00"
2260 />
2261 </div>
2262 </div>
2263 </div>
2264 );
2265 },
2266 )}
2267 </div>
2268 )}
2269
2270 {slotTravelerPricing.length === 0 && (
2271 <div className="text-center py-3 bg-gray-100 dark:bg-gray-800 rounded border border-dashed border-gray-300 dark:border-gray-600">
2272 <p className="text-xs text-gray-500">
2273 {__(
2274 'Click "Add Pricing" to set prices for traveler categories',
2275 "yatra",
2276 )}
2277 </p>
2278 </div>
2279 )}
2280 </div>
2281 );
2282 })()}
2283 </div>
2284 )}
2285 </div>
2286 ))}
2287 </div>
2288 )}
2289
2290 {/* If no time slots, show default fields as fallback */}
2291 {formData.time_slots.length === 0 && (
2292 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
2293 <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
2294 {__(
2295 "Or use default time (applies to all generated dates):",
2296 "yatra",
2297 )}
2298 </p>
2299 <div className="grid grid-cols-2 gap-4">
2300 <div>
2301 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2302 {__("Default Start Time", "yatra")}
2303 </label>
2304 <TimePicker
2305 value={formData.departure_time || ""}
2306 onChange={(value: string) =>
2307 setFormData((prev) => ({
2308 ...prev,
2309 departure_time: value,
2310 }))
2311 }
2312 placeholder="09:00"
2313 />
2314 </div>
2315 <div>
2316 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2317 {__("Default End Time", "yatra")}
2318 </label>
2319 <TimePicker
2320 value={formData.arrival_time || ""}
2321 onChange={(value: string) =>
2322 setFormData((prev) => ({
2323 ...prev,
2324 arrival_time: value,
2325 }))
2326 }
2327 placeholder="17:00"
2328 />
2329 </div>
2330 </div>
2331 </div>
2332 )}
2333 </div>
2334 )}
2335
2336 {/* Multi-Day Trip: Single Departure Time */}
2337 {!isSingleDayTrip && formData.trip_id > 0 && (
2338 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
2339 <div>
2340 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2341 {__("Departure Time", "yatra")}
2342 </label>
2343 <TimePicker
2344 value={formData.departure_time || ""}
2345 onChange={(value: string) =>
2346 setFormData((prev) => ({
2347 ...prev,
2348 departure_time: value,
2349 }))
2350 }
2351 placeholder="08:00"
2352 />
2353 </div>
2354 <div>
2355 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2356 {__("Return Time", "yatra")}{" "}
2357 <span className="text-gray-400">
2358 ({__("on final day", "yatra")})
2359 </span>
2360 </label>
2361 <TimePicker
2362 value={formData.arrival_time || ""}
2363 onChange={(value: string) =>
2364 setFormData((prev) => ({
2365 ...prev,
2366 arrival_time: value,
2367 }))
2368 }
2369 placeholder="18:00"
2370 />
2371 </div>
2372 </div>
2373 )}
2374
2375 {/* No trip selected */}
2376 {!formData.trip_id && (
2377 <div className="text-center py-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
2378 <AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
2379 <p className="text-sm text-gray-500 dark:text-gray-400">
2380 {__(
2381 "Select a trip above to configure time settings",
2382 "yatra",
2383 )}
2384 </p>
2385 </div>
2386 )}
2387
2388 {/* Location & Cutoff - always shown when trip selected */}
2389 {formData.trip_id > 0 && (
2390 <>
2391 <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 pt-4 border-t border-gray-200 dark:border-gray-700">
2392 <div className="space-y-4">
2393 <div className="flex items-center gap-3 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
2394 <div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
2395 <MapPin className="w-5 h-5 text-white" />
2396 </div>
2397 <div className="flex-1">
2398 <h4 className="text-base font-semibold text-blue-900 dark:text-blue-100">
2399 {__("Starting Point", "yatra")}
2400 </h4>
2401 <p className="text-xs text-blue-700 dark:text-blue-300">
2402 {__("Where the journey begins", "yatra")}
2403 </p>
2404 </div>
2405 {formData.from_latitude &&
2406 formData.from_longitude && (
2407 <div
2408 className="w-2 h-2 bg-blue-500 rounded-full"
2409 title={__("Coordinates set", "yatra")}
2410 />
2411 )}
2412 </div>
2413 <div className="space-y-2">
2414 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
2415 {__("From location (departure)", "yatra")}
2416 </label>
2417 <LocationPicker
2418 value={{
2419 name: formData.from_location || "",
2420 latitude: formData.from_latitude || "",
2421 longitude: formData.from_longitude || "",
2422 }}
2423 onChange={(loc) =>
2424 setFormData((prev) => ({
2425 ...prev,
2426 from_location: loc.name,
2427 from_latitude: loc.latitude,
2428 from_longitude: loc.longitude,
2429 }))
2430 }
2431 label=""
2432 placeholder={__(
2433 "Search for starting location...",
2434 "yatra",
2435 )}
2436 helpText=""
2437 required={false}
2438 defaultMapCenter={
2439 formData.from_latitude && formData.from_longitude
2440 ? [
2441 parseFloat(formData.from_latitude),
2442 parseFloat(formData.from_longitude),
2443 ]
2444 : tripForLocations?.starting_latitude &&
2445 tripForLocations?.starting_longitude
2446 ? [
2447 parseFloat(
2448 String(
2449 tripForLocations.starting_latitude,
2450 ),
2451 ),
2452 parseFloat(
2453 String(
2454 tripForLocations.starting_longitude,
2455 ),
2456 ),
2457 ]
2458 : [20, 0]
2459 }
2460 defaultZoom={
2461 formData.from_latitude && formData.from_longitude
2462 ? 13
2463 : tripForLocations?.starting_latitude &&
2464 tripForLocations?.starting_longitude
2465 ? 13
2466 : 2
2467 }
2468 mapHeight="300px"
2469 showMapButton={false}
2470 searchLimit={8}
2471 __={__}
2472 className=""
2473 mapClassName="rounded-lg"
2474 showManualCoordinateFields
2475 />
2476 <p className="text-xs text-gray-500 dark:text-gray-400">
2477 {__(
2478 "Default: trip starting location. Set per rule to override.",
2479 "yatra",
2480 )}
2481 </p>
2482 </div>
2483 </div>
2484
2485 <div className="space-y-4">
2486 <div className="flex items-center gap-3 p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-xl border border-green-200 dark:border-green-800">
2487 <div className="w-10 h-10 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
2488 <CheckCircle2 className="w-5 h-5 text-white" />
2489 </div>
2490 <div className="flex-1">
2491 <h4 className="text-base font-semibold text-green-900 dark:text-green-100">
2492 {__("Ending Point", "yatra")}
2493 </h4>
2494 <p className="text-xs text-green-700 dark:text-green-300">
2495 {__("Where the journey concludes", "yatra")}
2496 </p>
2497 </div>
2498 {formData.to_latitude && formData.to_longitude && (
2499 <div
2500 className="w-2 h-2 bg-green-500 rounded-full"
2501 title={__("Coordinates set", "yatra")}
2502 />
2503 )}
2504 </div>
2505 <div className="space-y-2">
2506 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
2507 {__("To location (destination)", "yatra")}
2508 </label>
2509 <LocationPicker
2510 value={{
2511 name: formData.to_location || "",
2512 latitude: formData.to_latitude || "",
2513 longitude: formData.to_longitude || "",
2514 }}
2515 onChange={(loc) =>
2516 setFormData((prev) => ({
2517 ...prev,
2518 to_location: loc.name,
2519 to_latitude: loc.latitude,
2520 to_longitude: loc.longitude,
2521 }))
2522 }
2523 label=""
2524 placeholder={__(
2525 "Search for ending location...",
2526 "yatra",
2527 )}
2528 helpText=""
2529 required={false}
2530 defaultMapCenter={
2531 formData.to_latitude && formData.to_longitude
2532 ? [
2533 parseFloat(formData.to_latitude),
2534 parseFloat(formData.to_longitude),
2535 ]
2536 : tripForLocations?.ending_latitude &&
2537 tripForLocations?.ending_longitude
2538 ? [
2539 parseFloat(
2540 String(
2541 tripForLocations.ending_latitude,
2542 ),
2543 ),
2544 parseFloat(
2545 String(
2546 tripForLocations.ending_longitude,
2547 ),
2548 ),
2549 ]
2550 : formData.from_latitude &&
2551 formData.from_longitude
2552 ? [
2553 parseFloat(formData.from_latitude),
2554 parseFloat(formData.from_longitude),
2555 ]
2556 : tripForLocations?.starting_latitude &&
2557 tripForLocations?.starting_longitude
2558 ? [
2559 parseFloat(
2560 String(
2561 tripForLocations.starting_latitude,
2562 ),
2563 ),
2564 parseFloat(
2565 String(
2566 tripForLocations.starting_longitude,
2567 ),
2568 ),
2569 ]
2570 : [20, 0]
2571 }
2572 defaultZoom={
2573 formData.to_latitude && formData.to_longitude
2574 ? 13
2575 : tripForLocations?.ending_latitude &&
2576 tripForLocations?.ending_longitude
2577 ? 13
2578 : formData.from_latitude &&
2579 formData.from_longitude
2580 ? 13
2581 : tripForLocations?.starting_latitude &&
2582 tripForLocations?.starting_longitude
2583 ? 13
2584 : 2
2585 }
2586 mapHeight="300px"
2587 showMapButton={false}
2588 searchLimit={8}
2589 __={__}
2590 className=""
2591 mapClassName="rounded-lg"
2592 showManualCoordinateFields
2593 />
2594 <p className="text-xs text-gray-500 dark:text-gray-400">
2595 {__(
2596 "Default: trip ending location. Set per rule to override.",
2597 "yatra",
2598 )}
2599 </p>
2600 </div>
2601 </div>
2602 </div>
2603 <div>
2604 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
2605 {__("Booking Cutoff (hours before)", "yatra")}
2606 </label>
2607 <Input
2608 type="number"
2609 min={0}
2610 value={formData.cutoff_hours}
2611 onChange={(e) =>
2612 setFormData((prev) => ({
2613 ...prev,
2614 cutoff_hours: parseInt(e.target.value) || 0,
2615 }))
2616 }
2617 />
2618 </div>
2619 </>
2620 )}
2621 </CardContent>
2622 </Card>
2623 </div>
2624
2625 {/* Sidebar */}
2626 <div className="space-y-6">
2627 {/* Status */}
2628 <Card>
2629 <CardHeader>
2630 <CardTitle>{__("Status", "yatra")}</CardTitle>
2631 </CardHeader>
2632 <CardContent>
2633 <Select
2634 value={formData.status}
2635 onChange={(e) =>
2636 setFormData((prev) => ({
2637 ...prev,
2638 status: e.target.value as "active" | "inactive",
2639 }))
2640 }
2641 >
2642 <option value="active">{__("Active", "yatra")}</option>
2643 <option value="inactive">{__("Inactive", "yatra")}</option>
2644 </Select>
2645 </CardContent>
2646 </Card>
2647
2648 {/* Preview */}
2649 <Card>
2650 <CardHeader>
2651 <CardTitle className="flex items-center gap-2">
2652 <Eye className="w-5 h-5" />
2653 {__("Preview", "yatra")}
2654 </CardTitle>
2655 <CardDescription>
2656 {__("See which dates will be generated", "yatra")}
2657 </CardDescription>
2658 </CardHeader>
2659 <CardContent>
2660 <Button
2661 type="button"
2662 variant="outline"
2663 className="w-full mb-4"
2664 onClick={() => previewMutation.mutate(formData)}
2665 disabled={previewMutation.isPending || !formData.trip_id}
2666 >
2667 {previewMutation.isPending ? (
2668 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
2669 ) : (
2670 <Eye className="w-4 h-4 mr-2" />
2671 )}
2672 {__("Generate Preview", "yatra")}
2673 </Button>
2674
2675 {previewData && (
2676 <div className="space-y-3">
2677 <div className="flex items-center justify-between text-sm">
2678 <span className="text-gray-600 dark:text-gray-400">
2679 {__("Total dates:", "yatra")}
2680 </span>
2681 <Badge variant="success">{previewData.total}</Badge>
2682 </div>
2683 <div className="max-h-48 overflow-y-auto space-y-1">
2684 {previewData.dates &&
2685 Array.isArray(previewData.dates) &&
2686 previewData.dates.map((date: any, index: number) => (
2687 <div
2688 key={index}
2689 className="text-xs px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded flex justify-between"
2690 >
2691 <span>
2692 {toDateValue(
2693 date.departure_date,
2694 ).toLocaleDateString("en-US", {
2695 weekday: "short",
2696 month: "short",
2697 day: "numeric",
2698 year: "numeric",
2699 })}
2700 </span>
2701 {date.departure_time && (
2702 <span className="text-gray-500">
2703 {date.departure_time}
2704 </span>
2705 )}
2706 </div>
2707 ))}
2708 {previewData.total > 20 && (
2709 <div className="text-xs text-gray-500 text-center py-1">
2710 +{previewData.total - 20} {__("more dates", "yatra")}
2711 </div>
2712 )}
2713 </div>
2714 </div>
2715 )}
2716 </CardContent>
2717 </Card>
2718
2719 {/* Actions */}
2720 <Card>
2721 <CardContent className="pt-6">
2722 <div className="space-y-3">
2723 <Button type="submit" className="w-full" disabled={isLoading}>
2724 {isLoading ? (
2725 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
2726 ) : (
2727 <Save className="w-4 h-4 mr-2" />
2728 )}
2729 {isEditing
2730 ? __("Update Rule", "yatra")
2731 : __("Create Rule", "yatra")}
2732 </Button>
2733 <Button
2734 type="button"
2735 variant="outline"
2736 className="w-full"
2737 onClick={() =>
2738 navigate({
2739 subpage: "trips",
2740 tab: "availability",
2741 trip_id:
2742 formData.trip_id?.toString() || tripIdFromUrl || "",
2743 })
2744 }
2745 >
2746 {__("Cancel", "yatra")}
2747 </Button>
2748 </div>
2749 </CardContent>
2750 </Card>
2751
2752 {/* Help */}
2753 <Alert>
2754 <div className="ml-2">
2755 <h4 className="font-medium">{__("How it works", "yatra")}</h4>
2756 <p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
2757 {__(
2758 "Dates are generated automatically based on your pattern. Manually added specific dates will take priority over generated dates.",
2759 "yatra",
2760 )}
2761 </p>
2762 </div>
2763 </Alert>
2764 </div>
2765 </div>
2766 </form>
2767 </div>
2768 );
2769 };
2770
2771 export default RecurringRuleForm;
2772