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

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