PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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.5, at resources/js/pages/RecurringRuleForm.tsx

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