PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / AvailabilityForm.tsx

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

1,715 lines 70.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Availability Form
3 * Add or edit availability dates for a trip
4 */
5
6 import React, { useState, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { useNavigate } from "../hooks/useNavigate";
9 import {
10 ArrowLeft,
11 Save,
12 Plus,
13 X,
14 Loader2,
15 MapPin,
16 CheckCircle2,
17 } from "lucide-react";
18 import { __ } from "../lib/i18n";
19 import { toDateValue } from "../lib/dateFormat";
20 import { Button } from "../components/ui/button";
21 import { Input } from "../components/ui/input";
22 import { Select } from "../components/ui/select";
23 import { PageHeader } from "../components/common/PageHeader";
24 import {
25 Card,
26 CardContent,
27 CardHeader,
28 CardTitle,
29 CardDescription,
30 } from "../components/ui/card";
31 import { HelpText } from "../components/ui/help-text";
32 import { Alert } from "../components/ui/alert";
33 import { AlertCircle } from "lucide-react";
34 import { DatePicker } from "../components/ui/date-picker";
35 import { TimePicker } from "../components/ui/time-picker";
36 import { apiClient } from "../lib/api-client";
37 import { useToast } from "../components/ui/toast";
38 import { AvailabilityFormSkeleton } from "../components/availability/AvailabilityFormSkeleton";
39 import { LocationPicker } from "../components/trip-form/LocationPicker";
40
41 function coordFromApi(v: unknown): string {
42 if (v == null || v === "") return "";
43 return String(v);
44 }
45
46 interface PriceType {
47 category_id: number;
48 original_price: string;
49 discounted_price: string;
50 }
51
52 interface TravelerCategory {
53 id: number;
54 label: string;
55 description: string;
56 status: "active" | "inactive" | "publish" | "draft";
57 age_min?: number;
58 age_max?: number;
59 }
60
61 interface AvailabilityFormData {
62 departure_date: string;
63 departure_time: string;
64 arrival_date: string;
65 arrival_time: string;
66 total_seats: string; // Total capacity
67 booked_seats: string; // Currently booked (read-only in edit mode)
68 seats_remaining: string; // Legacy field
69 pricing_type: "regular" | "traveler_based";
70 original_price: string;
71 discounted_price: string;
72 price_types: PriceType[];
73 status: "available" | "sold_out" | "limited" | "closed" | "blocked";
74 is_blocked: boolean; // Blocked for maintenance/holidays
75 block_reason: string; // Reason for blocking
76 alert_threshold: string; // Alert when seats drop below this
77 from_location: string;
78 to_location: string;
79 from_latitude: string;
80 from_longitude: string;
81 to_latitude: string;
82 to_longitude: string;
83 }
84
85 const AvailabilityForm: React.FC = () => {
86 const { navigate } = useNavigate();
87 const queryClient = useQueryClient();
88 const { showToast } = useToast();
89
90 // Get trip_id and id from URL
91 const urlParams = new URLSearchParams(window.location.search);
92 const tripIdFromUrl = urlParams.get("trip_id")
93 ? parseInt(urlParams.get("trip_id")!)
94 : null;
95 // Create vs edit is driven by the `action` param, NOT merely the presence of
96 // an `id`. navigate() merges URL params, so a stale `id` from a prior edit can
97 // linger when the user clicks "Add" (action=create); without this guard the
98 // form would wrongly PUT to that dead id ("Availability date not found").
99 const availabilityId =
100 urlParams.get("action") === "create" ? null : urlParams.get("id") || null;
101 const isEditMode = !!availabilityId;
102
103 // In edit mode, trip_id can come from availability data, so we'll use state
104 const [tripId, setTripId] = useState<number | null>(tripIdFromUrl);
105
106 const [formData, setFormData] = useState<AvailabilityFormData>({
107 departure_date: "",
108 departure_time: "",
109 arrival_date: "",
110 arrival_time: "",
111 total_seats: "",
112 booked_seats: "0",
113 seats_remaining: "",
114 pricing_type: "regular",
115 original_price: "",
116 discounted_price: "",
117 price_types: [],
118 status: "available",
119 is_blocked: false,
120 block_reason: "",
121 alert_threshold: "5",
122 from_location: "",
123 to_location: "",
124 from_latitude: "",
125 from_longitude: "",
126 to_latitude: "",
127 to_longitude: "",
128 });
129
130 const [errors, setErrors] = useState<
131 Partial<Record<keyof AvailabilityFormData, string>> & {
132 submit?: string;
133 [key: string]: string | undefined;
134 }
135 >({});
136 const [showCategorySelector, setShowCategorySelector] = useState(false);
137
138 // Fetch trip details
139 const { data: tripData } = useQuery({
140 queryKey: ["trip", tripId],
141 queryFn: async () => {
142 if (!tripId) return null;
143 const response = await apiClient.get(`/trips/${tripId}`);
144 const data = response?.data || response;
145
146 return data;
147 },
148 enabled: !!tripId,
149 });
150
151 // Fetch traveler categories
152 const { data: categoriesData, isLoading: isLoadingCategories } = useQuery({
153 queryKey: ["traveler-categories"],
154 queryFn: async () => {
155 const response = await apiClient.get("/traveler-categories");
156 const categories = response?.data || response || [];
157 return {
158 categories: Array.isArray(categories) ? categories : [],
159 };
160 },
161 });
162
163 // Filter for active categories - check both 'active' and 'publish' status
164 const activeCategories =
165 categoriesData?.categories?.filter(
166 (cat: TravelerCategory) =>
167 cat.status === "active" || cat.status === "publish",
168 ) || [];
169
170 // Fetch existing availability data if editing
171 const { data: availabilityData, isLoading: isLoadingAvailability } = useQuery(
172 {
173 queryKey: ["availability", availabilityId],
174 queryFn: async () => {
175 if (!availabilityId) return null;
176 const response = await apiClient.get(`/availability/${availabilityId}`);
177 return response?.data || response;
178 },
179 enabled: isEditMode && !!availabilityId,
180 },
181 );
182
183 // Set trip_id from availability data if not in URL (for edit mode)
184 useEffect(() => {
185 if (availabilityData && availabilityData.trip_id && !tripId) {
186 setTripId(availabilityData.trip_id);
187 }
188 }, [availabilityData, tripId]);
189
190 // Load form data when trip or availability data is available
191 useEffect(() => {
192 if (tripData && !isEditMode) {
193 const rawPriceTypes = tripData.price_types;
194 const hasTravelerPricing = Array.isArray(rawPriceTypes)
195 ? rawPriceTypes.length > 0
196 : false;
197 const effectivePricingType = hasTravelerPricing
198 ? "traveler_based"
199 : tripData.pricing_type || "regular";
200
201 setFormData((prev) => ({
202 ...prev,
203 from_location: tripData.starting_location || "",
204 to_location: tripData.ending_location || "",
205 from_latitude: coordFromApi(tripData.starting_latitude),
206 from_longitude: coordFromApi(tripData.starting_longitude),
207 to_latitude: coordFromApi(tripData.ending_latitude),
208 to_longitude: coordFromApi(tripData.ending_longitude),
209 // Default pricing type based on trip's pricing type
210 pricing_type: effectivePricingType as "regular" | "traveler_based",
211 }));
212 }
213 }, [tripData, isEditMode]);
214
215 useEffect(() => {
216 if (availabilityData) {
217 const totalSeats =
218 availabilityData.total_seats || availabilityData.seats_total || 0;
219 const availableSeats =
220 availabilityData.available_seats ||
221 availabilityData.seats_available ||
222 0;
223 const bookedSeats = totalSeats - availableSeats;
224
225 // ALWAYS use trip's pricing type, not the old availability pricing type
226 // This ensures availability dates match the current trip pricing configuration
227 const rawTripPriceTypes = tripData?.price_types;
228 const tripHasTravelerPricing = Array.isArray(rawTripPriceTypes)
229 ? rawTripPriceTypes.length > 0
230 : false;
231 const pricingType = tripHasTravelerPricing
232 ? "traveler_based"
233 : tripData?.pricing_type || availabilityData.pricing_type || "regular";
234
235 setFormData({
236 departure_date: availabilityData.departure_date || "",
237 departure_time: availabilityData.departure_time || "",
238 arrival_date:
239 availabilityData.arrival_date ||
240 availabilityData.departure_date ||
241 "",
242 arrival_time: availabilityData.arrival_time || "",
243 total_seats: totalSeats.toString(),
244 booked_seats: bookedSeats.toString(),
245 seats_remaining:
246 availableSeats > 10 ? "10+" : availableSeats.toString(),
247 pricing_type: pricingType as "regular" | "traveler_based",
248 original_price: availabilityData.original_price?.toString() || "",
249 discounted_price: availabilityData.discounted_price?.toString() || "",
250 price_types: availabilityData.price_types || [],
251 status: (availabilityData.status ||
252 (availabilityData.is_blocked ? "blocked" : "available")) as
253 | "available"
254 | "sold_out"
255 | "limited"
256 | "closed"
257 | "blocked",
258 is_blocked:
259 availabilityData.is_blocked ||
260 availabilityData.status === "blocked" ||
261 false,
262 block_reason: availabilityData.block_reason || "",
263 alert_threshold: availabilityData.alert_threshold?.toString() || "5",
264 from_location:
265 availabilityData.from_location || tripData?.starting_location || "",
266 to_location:
267 availabilityData.to_location || tripData?.ending_location || "",
268 from_latitude:
269 coordFromApi(availabilityData.from_latitude) ||
270 coordFromApi(tripData?.starting_latitude),
271 from_longitude:
272 coordFromApi(availabilityData.from_longitude) ||
273 coordFromApi(tripData?.starting_longitude),
274 to_latitude:
275 coordFromApi(availabilityData.to_latitude) ||
276 coordFromApi(tripData?.ending_latitude),
277 to_longitude:
278 coordFromApi(availabilityData.to_longitude) ||
279 coordFromApi(tripData?.ending_longitude),
280 });
281 }
282 }, [availabilityData, tripData]);
283
284 const handleFieldChange = (
285 field: keyof AvailabilityFormData,
286 value: string | boolean,
287 ) => {
288 setFormData((prev) => ({
289 ...prev,
290 [field]: value,
291 }));
292
293 // Clear error for this field
294 if (errors[field]) {
295 setErrors((prev) => {
296 const newErrors = { ...prev };
297 delete newErrors[field];
298 return newErrors;
299 });
300 }
301 };
302
303 const handlePriceTypeAdd = (categoryId: number) => {
304 // Check if category already exists
305 if (formData.price_types.some((pt) => pt.category_id === categoryId)) {
306 return;
307 }
308
309 // Find the category to get its label
310 const category = activeCategories.find((cat) => cat.id === categoryId);
311
312 setFormData((prev) => ({
313 ...prev,
314 price_types: [
315 ...prev.price_types,
316 {
317 category_id: categoryId,
318 category_label: category?.label || "",
319 original_price: "",
320 discounted_price: "",
321 },
322 ],
323 }));
324 };
325
326 const handlePriceTypeRemove = (categoryId: number) => {
327 setFormData((prev) => ({
328 ...prev,
329 price_types: prev.price_types.filter(
330 (pt) => pt.category_id !== categoryId,
331 ),
332 }));
333 };
334
335 const handlePriceTypeChange = (
336 categoryId: number,
337 field: "original_price" | "discounted_price",
338 value: string,
339 ) => {
340 setFormData((prev) => ({
341 ...prev,
342 price_types: prev.price_types.map((pt) =>
343 pt.category_id === categoryId ? { ...pt, [field]: value } : pt,
344 ),
345 }));
346 };
347
348 const validateForm = (): boolean => {
349 const newErrors: Partial<Record<keyof AvailabilityFormData, string>> & {
350 [key: string]: string;
351 } = {};
352
353 if (!formData.departure_date) {
354 newErrors.departure_date = __("Departure date is required", "yatra");
355 }
356
357 if (!formData.arrival_date) {
358 newErrors.arrival_date = __("Arrival date is required", "yatra");
359 }
360
361 // Validate dates and times based on trip type
362 const isSingleDay = tripData?.trip_type === "single_day";
363
364 if (isSingleDay) {
365 // For single day trips, both dates should be the same
366 if (
367 formData.departure_date &&
368 formData.arrival_date &&
369 formData.departure_date !== formData.arrival_date
370 ) {
371 newErrors.arrival_date = __(
372 "For single day trips, departure and arrival must be on the same date",
373 "yatra",
374 );
375 }
376 // Validate times
377 if (!formData.departure_time) {
378 newErrors.departure_time = __(
379 "Departure time is required for single day trips",
380 "yatra",
381 );
382 }
383 if (!formData.arrival_time) {
384 newErrors.arrival_time = __(
385 "Arrival time is required for single day trips",
386 "yatra",
387 );
388 }
389 // Validate that arrival time is after departure time
390 if (
391 formData.departure_time &&
392 formData.arrival_time &&
393 formData.departure_date === formData.arrival_date
394 ) {
395 const [depHour, depMin] = formData.departure_time
396 .split(":")
397 .map(Number);
398 const [arrHour, arrMin] = formData.arrival_time.split(":").map(Number);
399 const depMinutes = depHour * 60 + depMin;
400 const arrMinutes = arrHour * 60 + arrMin;
401 if (arrMinutes <= depMinutes) {
402 newErrors.arrival_time = __(
403 "Arrival time must be after departure time",
404 "yatra",
405 );
406 }
407 }
408 } else {
409 // For multi-day trips, validate dates
410 if (formData.departure_date && formData.arrival_date) {
411 const departure = new Date(formData.departure_date);
412 const arrival = new Date(formData.arrival_date);
413 if (arrival <= departure) {
414 newErrors.arrival_date = __(
415 "Arrival date must be after departure date",
416 "yatra",
417 );
418 }
419 }
420 }
421
422 // Validate inventory
423 if (!formData.total_seats || parseInt(formData.total_seats) <= 0) {
424 newErrors.total_seats = __(
425 "Total capacity is required and must be greater than 0",
426 "yatra",
427 );
428 }
429
430 if (isEditMode && formData.booked_seats) {
431 const total = parseInt(formData.total_seats) || 0;
432 const booked = parseInt(formData.booked_seats) || 0;
433 if (booked > total) {
434 newErrors.booked_seats = __(
435 "Booked seats cannot exceed total capacity",
436 "yatra",
437 );
438 }
439 }
440
441 // Validate pricing based on pricing type (OPTIONAL - only validate if provided)
442 if (formData.pricing_type === "regular") {
443 // Only validate if user has entered a price (optional override)
444 if (formData.original_price && parseFloat(formData.original_price) <= 0) {
445 newErrors.original_price = __(
446 "Original price must be greater than 0",
447 "yatra",
448 );
449 }
450 if (
451 formData.discounted_price &&
452 formData.original_price &&
453 parseFloat(formData.discounted_price) >=
454 parseFloat(formData.original_price)
455 ) {
456 newErrors.discounted_price = __(
457 "Discounted price must be less than original price",
458 "yatra",
459 );
460 }
461 } else {
462 // Validate price types only if provided (optional override)
463 if (formData.price_types.length > 0) {
464 formData.price_types.forEach((priceType, index) => {
465 if (
466 priceType.original_price &&
467 (isNaN(parseFloat(priceType.original_price)) ||
468 parseFloat(priceType.original_price) < 0)
469 ) {
470 newErrors[`price_type_${index}_original`] = __(
471 "Original price must be a valid number",
472 "yatra",
473 );
474 }
475 if (
476 priceType.discounted_price &&
477 (isNaN(parseFloat(priceType.discounted_price)) ||
478 parseFloat(priceType.discounted_price) < 0)
479 ) {
480 newErrors[`price_type_${index}_discounted`] = __(
481 "Discounted price must be a valid number",
482 "yatra",
483 );
484 }
485 if (
486 priceType.discounted_price &&
487 priceType.original_price &&
488 parseFloat(priceType.discounted_price) >=
489 parseFloat(priceType.original_price)
490 ) {
491 newErrors[`price_type_${index}_discounted`] = __(
492 "Discounted price must be less than original price",
493 "yatra",
494 );
495 }
496 });
497 }
498 }
499
500 setErrors(newErrors);
501 return Object.keys(newErrors).length === 0;
502 };
503
504 const saveMutation = useMutation({
505 mutationFn: async (data: AvailabilityFormData) => {
506 const totalSeats = data.total_seats ? parseInt(data.total_seats) : 0;
507 const bookedSeats =
508 isEditMode && data.booked_seats ? parseInt(data.booked_seats) : 0;
509 const availableSeats = totalSeats - bookedSeats;
510
511 const payload = {
512 trip_id: tripId,
513 departure_date: data.departure_date,
514 departure_time:
515 tripData?.trip_type === "single_day"
516 ? data.departure_time || null
517 : null,
518 arrival_date: data.arrival_date || null,
519 arrival_time:
520 tripData?.trip_type === "single_day"
521 ? data.arrival_time || null
522 : null,
523 seats_total: totalSeats,
524 seats_available: availableSeats,
525 seats_reserved: 0,
526 seats_waitlist: 0,
527 pricing_type: data.pricing_type,
528 original_price:
529 data.pricing_type === "regular" && data.original_price
530 ? parseFloat(data.original_price)
531 : null,
532 discounted_price:
533 data.pricing_type === "regular" && data.discounted_price
534 ? parseFloat(data.discounted_price)
535 : null,
536 price_types:
537 data.pricing_type === "traveler_based" ? data.price_types : null,
538 status: data.is_blocked ? "blocked" : data.status,
539 from_location: data.from_location || null,
540 to_location: data.to_location || null,
541 from_latitude: data.from_latitude?.trim() || null,
542 from_longitude: data.from_longitude?.trim() || null,
543 to_latitude: data.to_latitude?.trim() || null,
544 to_longitude: data.to_longitude?.trim() || null,
545 special_notes: null,
546 cutoff_hours: 24,
547 alert_threshold: data.alert_threshold
548 ? parseInt(data.alert_threshold)
549 : null,
550 is_blocked: data.is_blocked,
551 block_reason: data.is_blocked ? data.block_reason || null : null,
552 };
553
554 if (isEditMode && availabilityId) {
555 const response = await apiClient.put(
556 `/availability/${availabilityId}`,
557 payload,
558 );
559 return response?.data || response;
560 } else {
561 const response = await apiClient.post("/availability", payload);
562 return response?.data || response;
563 }
564 },
565 onSuccess: () => {
566 queryClient.invalidateQueries({ queryKey: ["availability"] });
567 queryClient.invalidateQueries({ queryKey: ["trips"] });
568 // Navigate immediately, toast will be shown on the availability page if needed
569 navigate({
570 subpage: "trips",
571 tab: "availability",
572 trip_id: tripId?.toString(),
573 });
574 // Show toast after a brief delay to ensure navigation happens first
575 setTimeout(() => {
576 showToast(
577 isEditMode
578 ? __("Availability date updated successfully", "yatra")
579 : __("Availability date created successfully", "yatra"),
580 "success",
581 3000, // Shorter duration
582 );
583 }, 100);
584 },
585 onError: (error: any) => {
586 const errorMessage =
587 error?.message ||
588 error?.data?.message ||
589 __("An error occurred while saving", "yatra");
590 showToast(errorMessage, "error");
591 setErrors({
592 submit: errorMessage,
593 });
594 },
595 });
596
597 const handleSubmit = (e: React.FormEvent) => {
598 e.preventDefault();
599 if (!validateForm()) {
600 return;
601 }
602 saveMutation.mutate(formData);
603 };
604
605 const getCurrencySymbol = (currency: string) => {
606 const symbols: Record<string, string> = {
607 USD: "$",
608 EUR: "€",
609 GBP: "£",
610 INR: "₹",
611 };
612 return symbols[currency] || currency;
613 };
614
615 // Show loading if we're in edit mode and waiting for availability data to get trip_id
616 if (isEditMode && !tripId && isLoadingAvailability) {
617 return <AvailabilityFormSkeleton />;
618 }
619
620 if (!tripId) {
621 return (
622 <Alert variant="error">
623 <AlertCircle className="w-4 h-4" />
624 <div>
625 <p className="font-medium">{__("Trip ID Required", "yatra")}</p>
626 <p className="text-sm">
627 {__("Please select a trip first.", "yatra")}
628 </p>
629 </div>
630 </Alert>
631 );
632 }
633
634 // Show skeleton loader while loading data
635 if (isLoadingAvailability || (isEditMode && !availabilityData)) {
636 return <AvailabilityFormSkeleton />;
637 }
638
639 return (
640 <div className="space-y-6">
641 <PageHeader
642 title={
643 isEditMode
644 ? __("Edit Availability Date", "yatra")
645 : __("Add Availability Date", "yatra")
646 }
647 description={
648 tripData
649 ? `${isEditMode ? __("Edit", "yatra") : __("Add")} availability date for ${tripData.title} (Trip ID: ${tripId})`
650 : __("Add a new availability date for this trip", "yatra")
651 }
652 actions={
653 tripId ? (
654 <Button
655 variant="outline"
656 onClick={() =>
657 navigate({
658 subpage: "trips",
659 tab: "availability",
660 trip_id: tripId.toString(),
661 })
662 }
663 >
664 <ArrowLeft className="w-4 h-4 mr-2" />
665 {__("Back", "yatra")}
666 </Button>
667 ) : null
668 }
669 />
670
671 <form onSubmit={handleSubmit} className="space-y-6">
672 <Card>
673 <CardHeader>
674 <CardTitle>{__("Date Information", "yatra")}</CardTitle>
675 <CardDescription>
676 {__(
677 "Set the departure and arrival dates for this availability",
678 "yatra",
679 )}
680 </CardDescription>
681 </CardHeader>
682 <CardContent className="space-y-4">
683 {tripData?.trip_type === "single_day" ? (
684 // Single Day Trip - Show date and time pickers
685 <>
686 <div>
687 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
688 {__("Date", "yatra")}{" "}
689 <span className="text-red-500">*</span>
690 </label>
691 <DatePicker
692 value={formData.departure_date}
693 onChange={(value: string) => {
694 handleFieldChange("departure_date", value);
695 handleFieldChange("arrival_date", value); // Same date for single day
696 }}
697 placeholder={__("Select date", "yatra")}
698 error={!!errors.departure_date}
699 />
700 {errors.departure_date && (
701 <p className="mt-1 text-xs text-red-600">
702 {errors.departure_date}
703 </p>
704 )}
705 </div>
706 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
707 <div>
708 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
709 {__("Departure Time", "yatra")}{" "}
710 <span className="text-red-500">*</span>
711 </label>
712 <TimePicker
713 value={formData.departure_time}
714 onChange={(value: string) =>
715 handleFieldChange("departure_time", value)
716 }
717 placeholder={__("Select departure time", "yatra")}
718 error={!!errors.departure_time}
719 />
720 {errors.departure_time && (
721 <p className="mt-1 text-xs text-red-600">
722 {errors.departure_time}
723 </p>
724 )}
725 </div>
726 <div>
727 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
728 {__("Arrival Time", "yatra")}{" "}
729 <span className="text-red-500">*</span>
730 </label>
731 <TimePicker
732 value={formData.arrival_time}
733 onChange={(value: string) =>
734 handleFieldChange("arrival_time", value)
735 }
736 placeholder={__("Select arrival time", "yatra")}
737 error={!!errors.arrival_time}
738 />
739 {errors.arrival_time && (
740 <p className="mt-1 text-xs text-red-600">
741 {errors.arrival_time}
742 </p>
743 )}
744 </div>
745 </div>
746 </>
747 ) : (
748 // Multi-Day Trip - Show date pickers only
749 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
750 <div>
751 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
752 {__("Departure Date", "yatra")}{" "}
753 <span className="text-red-500">*</span>
754 </label>
755 <DatePicker
756 value={formData.departure_date}
757 onChange={(value: string) =>
758 handleFieldChange("departure_date", value)
759 }
760 placeholder={__("Select departure date", "yatra")}
761 error={!!errors.departure_date}
762 />
763 {errors.departure_date && (
764 <p className="mt-1 text-xs text-red-600">
765 {errors.departure_date}
766 </p>
767 )}
768 </div>
769
770 <div>
771 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
772 {__("Arrival Date", "yatra")}{" "}
773 <span className="text-red-500">*</span>
774 </label>
775 <DatePicker
776 value={formData.arrival_date}
777 onChange={(value: string) =>
778 handleFieldChange("arrival_date", value)
779 }
780 minDate={
781 formData.departure_date
782 ? toDateValue(formData.departure_date)
783 : undefined
784 }
785 placeholder={__("Select arrival date", "yatra")}
786 error={!!errors.arrival_date}
787 />
788 {errors.arrival_date && (
789 <p className="mt-1 text-xs text-red-600">
790 {errors.arrival_date}
791 </p>
792 )}
793 {tripData?.duration_days &&
794 formData.departure_date &&
795 formData.arrival_date && (
796 <div className="mt-1.5">
797 {(() => {
798 // Intentionally `new Date()` (UTC), NOT toDateValue():
799 // this is a day-COUNT between two dates. UTC midnights
800 // are DST-immune so the diff is a whole number of
801 // days; parsing as local could make it off by the DST
802 // hour and round the duration wrong. (Display of these
803 // dates elsewhere uses the local-safe shared formatter.)
804 const departure = new Date(formData.departure_date);
805 const arrival = new Date(formData.arrival_date);
806 // INCLUSIVE calendar-day span (both endpoints count):
807 // Jun 25 → Jul 04 is 10 days, not 9. duration_days is
808 // also inclusive (a 9-day trip ends on departure + 8;
809 // see BookingRepository::calculateEndDate), so the two
810 // are compared like-for-like. The bare date difference
811 // was one day short.
812 const selectedDays =
813 Math.round(
814 (arrival.getTime() - departure.getTime()) /
815 (1000 * 60 * 60 * 24),
816 ) + 1;
817 const expectedDays = tripData.duration_days;
818 const diff = selectedDays - expectedDays;
819
820 if (diff === 0) {
821 return (
822 <p className="text-xs text-green-600 dark:text-green-400">
823 {__("Duration matches trip duration", "yatra")}{" "}
824 ({selectedDays} {__("days", "yatra")})
825 </p>
826 );
827 } else if (diff < 0) {
828 return (
829 <p className="text-xs text-amber-600 dark:text-amber-400">
830 {__("Shorter than trip duration", "yatra")}:{" "}
831 {selectedDays} {__("days", "yatra")} (
832 {Math.abs(diff)} {__("days shorter", "yatra")}{" "}
833 than expected {expectedDays}{" "}
834 {__("days", "yatra")})
835 </p>
836 );
837 } else {
838 return (
839 <p className="text-xs text-amber-600 dark:text-amber-400">
840 {__("Longer than trip duration", "yatra")}:{" "}
841 {selectedDays} {__("days", "yatra")} ({diff}{" "}
842 {__("days longer", "yatra")} than expected{" "}
843 {expectedDays} {__("days", "yatra")})
844 </p>
845 );
846 }
847 })()}
848 </div>
849 )}
850 {tripData?.duration_days && !formData.arrival_date && (
851 <HelpText
852 text={`${__("Trip duration", "yatra")}: ${tripData.duration_days} ${tripData.duration_days === 1 ? __("day", "yatra") : __("days", "yatra")}${tripData.duration_nights ? ` (${tripData.duration_nights} ${tripData.duration_nights === 1 ? __("night", "yatra") : __("nights", "yatra")})` : ""}`}
853 className="mt-1"
854 />
855 )}
856 </div>
857 </div>
858 )}
859
860 <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 pt-2">
861 <div className="space-y-4">
862 <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">
863 <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">
864 <MapPin className="w-5 h-5 text-white" />
865 </div>
866 <div className="flex-1">
867 <h4 className="text-base font-semibold text-blue-900 dark:text-blue-100">
868 {__("Starting Point", "yatra")}
869 </h4>
870 <p className="text-xs text-blue-700 dark:text-blue-300">
871 {__("Where the journey begins", "yatra")}
872 </p>
873 </div>
874 {formData.from_latitude && formData.from_longitude && (
875 <div
876 className="w-2 h-2 bg-blue-500 rounded-full"
877 title={__("Coordinates set", "yatra")}
878 />
879 )}
880 </div>
881 <div className="space-y-2">
882 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
883 {__("From location (departure)", "yatra")}
884 </label>
885 <LocationPicker
886 value={{
887 name: formData.from_location,
888 latitude: formData.from_latitude,
889 longitude: formData.from_longitude,
890 }}
891 onChange={(loc) => {
892 setFormData((prev) => ({
893 ...prev,
894 from_location: loc.name,
895 from_latitude: loc.latitude,
896 from_longitude: loc.longitude,
897 }));
898 }}
899 label=""
900 placeholder={__("Search for starting location...", "yatra")}
901 helpText=""
902 required={false}
903 defaultMapCenter={
904 formData.from_latitude && formData.from_longitude
905 ? [
906 parseFloat(formData.from_latitude),
907 parseFloat(formData.from_longitude),
908 ]
909 : tripData?.starting_latitude &&
910 tripData?.starting_longitude
911 ? [
912 parseFloat(String(tripData.starting_latitude)),
913 parseFloat(String(tripData.starting_longitude)),
914 ]
915 : [20, 0]
916 }
917 defaultZoom={
918 formData.from_latitude && formData.from_longitude
919 ? 13
920 : tripData?.starting_latitude &&
921 tripData?.starting_longitude
922 ? 13
923 : 2
924 }
925 mapHeight="300px"
926 showMapButton={false}
927 searchLimit={8}
928 __={__}
929 className=""
930 mapClassName="rounded-lg"
931 showManualCoordinateFields
932 />
933 <HelpText
934 text={__(
935 "Overrides trip starting location for this date only. Coordinates power maps and distance features.",
936 "yatra",
937 )}
938 />
939 </div>
940 </div>
941
942 <div className="space-y-4">
943 <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">
944 <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">
945 <CheckCircle2 className="w-5 h-5 text-white" />
946 </div>
947 <div className="flex-1">
948 <h4 className="text-base font-semibold text-green-900 dark:text-green-100">
949 {__("Ending Point", "yatra")}
950 </h4>
951 <p className="text-xs text-green-700 dark:text-green-300">
952 {__("Where the journey concludes", "yatra")}
953 </p>
954 </div>
955 {formData.to_latitude && formData.to_longitude && (
956 <div
957 className="w-2 h-2 bg-green-500 rounded-full"
958 title={__("Coordinates set", "yatra")}
959 />
960 )}
961 </div>
962 <div className="space-y-2">
963 <label className="text-sm font-medium text-gray-700 dark:text-gray-300">
964 {__("To location (destination)", "yatra")}
965 </label>
966 <LocationPicker
967 value={{
968 name: formData.to_location,
969 latitude: formData.to_latitude,
970 longitude: formData.to_longitude,
971 }}
972 onChange={(loc) => {
973 setFormData((prev) => ({
974 ...prev,
975 to_location: loc.name,
976 to_latitude: loc.latitude,
977 to_longitude: loc.longitude,
978 }));
979 }}
980 label=""
981 placeholder={__("Search for ending location...", "yatra")}
982 helpText=""
983 required={false}
984 defaultMapCenter={
985 formData.to_latitude && formData.to_longitude
986 ? [
987 parseFloat(formData.to_latitude),
988 parseFloat(formData.to_longitude),
989 ]
990 : tripData?.ending_latitude &&
991 tripData?.ending_longitude
992 ? [
993 parseFloat(String(tripData.ending_latitude)),
994 parseFloat(String(tripData.ending_longitude)),
995 ]
996 : formData.from_latitude && formData.from_longitude
997 ? [
998 parseFloat(formData.from_latitude),
999 parseFloat(formData.from_longitude),
1000 ]
1001 : tripData?.starting_latitude &&
1002 tripData?.starting_longitude
1003 ? [
1004 parseFloat(
1005 String(tripData.starting_latitude),
1006 ),
1007 parseFloat(
1008 String(tripData.starting_longitude),
1009 ),
1010 ]
1011 : [20, 0]
1012 }
1013 defaultZoom={
1014 formData.to_latitude && formData.to_longitude
1015 ? 13
1016 : tripData?.ending_latitude &&
1017 tripData?.ending_longitude
1018 ? 13
1019 : formData.from_latitude && formData.from_longitude
1020 ? 13
1021 : tripData?.starting_latitude &&
1022 tripData?.starting_longitude
1023 ? 13
1024 : 2
1025 }
1026 mapHeight="300px"
1027 showMapButton={false}
1028 searchLimit={8}
1029 __={__}
1030 className=""
1031 mapClassName="rounded-lg"
1032 showManualCoordinateFields
1033 />
1034 <HelpText
1035 text={__(
1036 "Overrides trip ending location for this date only.",
1037 "yatra",
1038 )}
1039 />
1040 </div>
1041 </div>
1042 </div>
1043 </CardContent>
1044 </Card>
1045
1046 <Card>
1047 <CardHeader>
1048 <CardTitle>{__("Pricing & Availability", "yatra")}</CardTitle>
1049 <CardDescription>
1050 {__(
1051 "Set pricing for traveler categories and seat availability for this date",
1052 "yatra",
1053 )}
1054 </CardDescription>
1055 </CardHeader>
1056 <CardContent className="space-y-4">
1057 {/* Pricing Override Info */}
1058 <div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-4">
1059 <div className="flex items-start gap-3">
1060 <div className="p-2 rounded-full bg-amber-100 dark:bg-amber-900/30">
1061 <svg
1062 className="w-5 h-5 text-amber-600 dark:text-amber-400"
1063 fill="none"
1064 stroke="currentColor"
1065 viewBox="0 0 24 24"
1066 >
1067 <path
1068 strokeLinecap="round"
1069 strokeLinejoin="round"
1070 strokeWidth={2}
1071 d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
1072 />
1073 </svg>
1074 </div>
1075 <div className="flex-1">
1076 <p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
1077 {__("Pricing Override (Optional)", "yatra")}
1078 </p>
1079 <p className="text-xs text-gray-600 dark:text-gray-400">
1080 {__(
1081 "Leave pricing fields empty to use the trip's default pricing. Fill them in only if you want to override the default pricing for this specific date.",
1082 "yatra",
1083 )}
1084 </p>
1085 </div>
1086 </div>
1087 </div>
1088
1089 {/* Pricing Type Info - Inherited from Trip */}
1090 <div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
1091 <div className="flex items-center gap-3">
1092 <div
1093 className={`p-2 rounded-full ${formData.pricing_type === "traveler_based" ? "bg-green-100 dark:bg-green-900/30" : "bg-blue-100 dark:bg-blue-900/30"}`}
1094 >
1095 {formData.pricing_type === "traveler_based" ? (
1096 <svg
1097 className="w-5 h-5 text-green-600 dark:text-green-400"
1098 fill="none"
1099 stroke="currentColor"
1100 viewBox="0 0 24 24"
1101 >
1102 <path
1103 strokeLinecap="round"
1104 strokeLinejoin="round"
1105 strokeWidth={2}
1106 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"
1107 />
1108 </svg>
1109 ) : (
1110 <svg
1111 className="w-5 h-5 text-blue-600 dark:text-blue-400"
1112 fill="none"
1113 stroke="currentColor"
1114 viewBox="0 0 24 24"
1115 >
1116 <path
1117 strokeLinecap="round"
1118 strokeLinejoin="round"
1119 strokeWidth={2}
1120 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"
1121 />
1122 </svg>
1123 )}
1124 </div>
1125 <div>
1126 <p className="text-sm font-medium text-gray-900 dark:text-white">
1127 {formData.pricing_type === "traveler_based"
1128 ? __("Traveler-Based Pricing", "yatra")
1129 : __("Regular Pricing", "yatra")}
1130 </p>
1131 <p className="text-xs text-gray-600 dark:text-gray-400">
1132 {formData.pricing_type === "traveler_based"
1133 ? __(
1134 "This trip uses traveler category pricing. Set prices for each category below.",
1135 "yatra",
1136 )
1137 : __(
1138 "This trip uses regular pricing. Set a single price for all travelers below.",
1139 "yatra",
1140 )}
1141 </p>
1142 </div>
1143 </div>
1144 </div>
1145
1146 {/* Regular Pricing */}
1147 {formData.pricing_type === "regular" && (
1148 <div className="space-y-4">
1149 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1150 <div>
1151 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1152 {__("Original Price", "yatra")}{" "}
1153 <span className="text-gray-400 text-xs">
1154 ({__("Optional", "yatra")})
1155 </span>
1156 </label>
1157 <div className="relative">
1158 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1159 {getCurrencySymbol(tripData?.currency || "USD")}
1160 </span>
1161 <Input
1162 type="number"
1163 step="0.01"
1164 min="0"
1165 value={formData.original_price}
1166 onChange={(e) =>
1167 handleFieldChange("original_price", e.target.value)
1168 }
1169 placeholder="0.00"
1170 className={`pl-7 ${errors.original_price ? "border-red-500" : ""}`}
1171 />
1172 </div>
1173 {errors.original_price && (
1174 <p className="mt-1 text-xs text-red-600">
1175 {errors.original_price}
1176 </p>
1177 )}
1178 {!formData.original_price && tripData && (
1179 <p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
1180 {__("Using trip default", "yatra")}:{" "}
1181 {getCurrencySymbol(tripData?.currency || "USD")}
1182 {tripData.original_price || "0.00"}
1183 </p>
1184 )}
1185 </div>
1186
1187 <div>
1188 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1189 {__("Discounted Price", "yatra")} (
1190 {__("Optional", "yatra")})
1191 </label>
1192 <div className="relative">
1193 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1194 {getCurrencySymbol(tripData?.currency || "USD")}
1195 </span>
1196 <Input
1197 type="number"
1198 step="0.01"
1199 min="0"
1200 value={formData.discounted_price}
1201 onChange={(e) =>
1202 handleFieldChange("discounted_price", e.target.value)
1203 }
1204 placeholder="0.00"
1205 className={`pl-7 ${errors.discounted_price ? "border-red-500" : ""}`}
1206 />
1207 </div>
1208 {errors.discounted_price && (
1209 <p className="mt-1 text-xs text-red-600">
1210 {errors.discounted_price}
1211 </p>
1212 )}
1213 </div>
1214 </div>
1215 </div>
1216 )}
1217
1218 {/* Traveler Category Pricing */}
1219 {formData.pricing_type === "traveler_based" && (
1220 <div className="space-y-4">
1221 <div>
1222 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
1223 {__("Traveler Category Pricing", "yatra")}{" "}
1224 <span className="text-gray-400 text-xs">
1225 ({__("Optional", "yatra")})
1226 </span>
1227 </label>
1228 <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
1229 {__(
1230 "Add pricing to override trip default pricing for specific traveler categories. Leave empty to use trip default pricing.",
1231 "yatra",
1232 )}
1233 </p>
1234 </div>
1235
1236 {isLoadingCategories ? (
1237 <div className="flex items-center justify-center py-8">
1238 <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
1239 </div>
1240 ) : activeCategories.length === 0 ? (
1241 <div className="p-6 border border-gray-200 dark:border-gray-700 rounded-lg text-center">
1242 <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
1243 {__("No active traveler categories found.", "yatra")}
1244 </p>
1245 <Button
1246 type="button"
1247 variant="outline"
1248 onClick={() =>
1249 (window.location.href =
1250 "?subpage=traveler-categories&action=create")
1251 }
1252 className="flex items-center gap-2 mx-auto"
1253 >
1254 <Plus className="w-4 h-4" />
1255 {__("Create Category", "yatra")}
1256 </Button>
1257 </div>
1258 ) : (
1259 <div className="space-y-4">
1260 {/* Add Pricing Button */}
1261 <div className="relative">
1262 <Button
1263 type="button"
1264 variant="outline"
1265 onClick={() =>
1266 setShowCategorySelector(!showCategorySelector)
1267 }
1268 className="flex items-center gap-2"
1269 disabled={
1270 activeCategories.filter(
1271 (cat) =>
1272 !formData.price_types.some(
1273 (pt) => pt.category_id === cat.id,
1274 ),
1275 ).length === 0
1276 }
1277 >
1278 <Plus className="w-4 h-4" />
1279 {__("Add Pricing", "yatra")}
1280 </Button>
1281
1282 {/* Category Selection Dropdown */}
1283 {showCategorySelector && (
1284 <>
1285 <div
1286 className="fixed inset-0 z-10"
1287 onClick={() => setShowCategorySelector(false)}
1288 />
1289 <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">
1290 <div className="p-2">
1291 <div className="text-xs font-medium text-gray-700 dark:text-gray-300 px-3 py-2 mb-1">
1292 {__(
1293 "Select a category to add pricing",
1294 "yatra",
1295 )}
1296 </div>
1297 {activeCategories.filter(
1298 (cat) =>
1299 !formData.price_types.some(
1300 (pt) => pt.category_id === cat.id,
1301 ),
1302 ).length === 0 ? (
1303 <div className="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center">
1304 {__(
1305 "All categories have pricing added",
1306 "yatra",
1307 )}
1308 </div>
1309 ) : (
1310 <div className="space-y-1">
1311 {activeCategories
1312 .filter(
1313 (cat) =>
1314 !formData.price_types.some(
1315 (pt) => pt.category_id === cat.id,
1316 ),
1317 )
1318 .map((category: TravelerCategory) => {
1319 const ageRange =
1320 category.age_min !== undefined ||
1321 category.age_max !== undefined
1322 ? category.age_min !== undefined &&
1323 category.age_max !== undefined
1324 ? `${category.age_min}-${category.age_max} ${__("years", "yatra")}`
1325 : category.age_min !== undefined
1326 ? `${category.age_min}+ ${__("years", "yatra")}`
1327 : category.age_max !== undefined
1328 ? `${__("Under", "yatra")} ${category.age_max} ${__("years", "yatra")}`
1329 : ""
1330 : null;
1331
1332 return (
1333 <button
1334 key={category.id}
1335 type="button"
1336 onClick={() => {
1337 handlePriceTypeAdd(category.id);
1338 setShowCategorySelector(false);
1339 }}
1340 className="w-full text-left px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
1341 >
1342 <div className="font-medium text-sm text-gray-900 dark:text-white">
1343 {category.label}
1344 {ageRange && (
1345 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1346 ({ageRange})
1347 </span>
1348 )}
1349 </div>
1350 {category.description && (
1351 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1352 {category.description}
1353 </div>
1354 )}
1355 </button>
1356 );
1357 })}
1358 </div>
1359 )}
1360 </div>
1361 </div>
1362 </>
1363 )}
1364 </div>
1365
1366 {/* Added Pricing List */}
1367 {formData.price_types.length > 0 && (
1368 <div className="space-y-3">
1369 {formData.price_types.map((priceType, index) => {
1370 const category = activeCategories.find(
1371 (cat) => cat.id === priceType.category_id,
1372 );
1373 if (!category) return null;
1374
1375 return (
1376 <div
1377 key={priceType.category_id}
1378 className="p-4 border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
1379 >
1380 <div className="flex items-start justify-between mb-3">
1381 <div className="flex-1">
1382 <div className="flex items-center gap-2 mb-1">
1383 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
1384 {category.label}
1385 {(category.age_min !== undefined ||
1386 category.age_max !== undefined) && (
1387 <span className="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
1388 (
1389 {category.age_min !== undefined &&
1390 category.age_max !== undefined
1391 ? `${category.age_min}-${category.age_max} ${__("years", "yatra")}`
1392 : category.age_min !== undefined
1393 ? `${category.age_min}+ ${__("years", "yatra")}`
1394 : category.age_max !== undefined
1395 ? `${__("Under", "yatra")} ${category.age_max} ${__("years", "yatra")}`
1396 : ""}
1397 )
1398 </span>
1399 )}
1400 </h4>
1401 </div>
1402 <p className="text-xs text-gray-600 dark:text-gray-400">
1403 {category.description}
1404 </p>
1405 </div>
1406 <button
1407 type="button"
1408 onClick={() =>
1409 handlePriceTypeRemove(category.id)
1410 }
1411 className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
1412 title={__("Remove Pricing", "yatra")}
1413 >
1414 <X className="w-4 h-4" />
1415 </button>
1416 </div>
1417 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1418 <div>
1419 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1420 {__("Original Price", "yatra")}{" "}
1421 <span className="text-gray-400 text-xs">
1422 ({__("Optional", "yatra")})
1423 </span>
1424 </label>
1425 <div className="relative">
1426 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1427 {getCurrencySymbol(
1428 tripData?.currency || "USD",
1429 )}
1430 </span>
1431 <Input
1432 type="number"
1433 step="0.01"
1434 min="0"
1435 value={priceType.original_price}
1436 onChange={(e) =>
1437 handlePriceTypeChange(
1438 category.id,
1439 "original_price",
1440 e.target.value,
1441 )
1442 }
1443 placeholder="0.00"
1444 className={`pl-7 ${errors[`price_type_${index}_original`] ? "border-red-500" : ""}`}
1445 />
1446 </div>
1447 {errors[`price_type_${index}_original`] && (
1448 <p className="mt-1 text-xs text-red-600 dark:text-red-400">
1449 {errors[`price_type_${index}_original`]}
1450 </p>
1451 )}
1452 </div>
1453 <div>
1454 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1455 {__("Discounted Price", "yatra")} (
1456 {__("Optional", "yatra")})
1457 </label>
1458 <div className="relative">
1459 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1460 {getCurrencySymbol(
1461 tripData?.currency || "USD",
1462 )}
1463 </span>
1464 <Input
1465 type="number"
1466 step="0.01"
1467 min="0"
1468 value={priceType.discounted_price}
1469 onChange={(e) =>
1470 handlePriceTypeChange(
1471 category.id,
1472 "discounted_price",
1473 e.target.value,
1474 )
1475 }
1476 placeholder="0.00"
1477 className={`pl-7 ${errors[`price_type_${index}_discounted`] ? "border-red-500" : ""}`}
1478 />
1479 </div>
1480 {errors[`price_type_${index}_discounted`] && (
1481 <p className="mt-1 text-xs text-red-600 dark:text-red-400">
1482 {errors[`price_type_${index}_discounted`]}
1483 </p>
1484 )}
1485 </div>
1486 </div>
1487 </div>
1488 );
1489 })}
1490 </div>
1491 )}
1492 {errors.price_types && (
1493 <p className="text-xs text-red-600 dark:text-red-400">
1494 {errors.price_types}
1495 </p>
1496 )}
1497 </div>
1498 )}
1499 </div>
1500 )}
1501
1502 {/* Inventory Management */}
1503 <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
1504 <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
1505 {__("Inventory Management", "yatra")}
1506 </h3>
1507 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
1508 <div>
1509 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1510 {__("Total Capacity", "yatra")}{" "}
1511 <span className="text-red-500">*</span>
1512 </label>
1513 <Input
1514 type="number"
1515 min="1"
1516 value={formData.total_seats}
1517 onChange={(e) =>
1518 handleFieldChange("total_seats", e.target.value)
1519 }
1520 placeholder="20"
1521 className={errors.total_seats ? "border-red-500" : ""}
1522 />
1523 {errors.total_seats && (
1524 <p className="mt-1 text-xs text-red-600">
1525 {errors.total_seats}
1526 </p>
1527 )}
1528 <HelpText
1529 text={__(
1530 "Maximum number of seats available for this date",
1531 "yatra",
1532 )}
1533 className="mt-1"
1534 />
1535 </div>
1536 {isEditMode && (
1537 <div>
1538 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1539 {__("Booked Seats", "yatra")}
1540 </label>
1541 <Input
1542 type="number"
1543 min="0"
1544 value={formData.booked_seats}
1545 readOnly
1546 className="bg-gray-50 dark:bg-gray-800 cursor-not-allowed"
1547 />
1548 <HelpText
1549 text={__("Currently booked seats (read-only)", "yatra")}
1550 className="mt-1"
1551 />
1552 </div>
1553 )}
1554 <div>
1555 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1556 {__("Alert Threshold", "yatra")}
1557 </label>
1558 <Input
1559 type="number"
1560 min="1"
1561 value={formData.alert_threshold}
1562 onChange={(e) =>
1563 handleFieldChange("alert_threshold", e.target.value)
1564 }
1565 placeholder="5"
1566 />
1567 <HelpText
1568 text={__(
1569 "Alert when available seats drop below this number",
1570 "yatra",
1571 )}
1572 className="mt-1"
1573 />
1574 </div>
1575 </div>
1576 </div>
1577
1578 {/* Block Date */}
1579 <div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
1580 <div className="flex items-center justify-between mb-4">
1581 <h3 className="text-sm font-semibold text-gray-900 dark:text-white">
1582 {__("Block Date", "yatra")}
1583 </h3>
1584 <label className="flex items-center gap-2 cursor-pointer">
1585 <input
1586 type="checkbox"
1587 checked={formData.is_blocked}
1588 onChange={(e) => {
1589 handleFieldChange("is_blocked", e.target.checked);
1590 if (e.target.checked) {
1591 handleFieldChange("status", "blocked");
1592 } else {
1593 handleFieldChange("status", "available");
1594 }
1595 }}
1596 className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
1597 />
1598 <span className="text-sm text-gray-700 dark:text-gray-300">
1599 {__("Block this date from bookings", "yatra")}
1600 </span>
1601 </label>
1602 </div>
1603 {formData.is_blocked && (
1604 <div>
1605 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1606 {__("Block Reason", "yatra")}
1607 </label>
1608 <Input
1609 type="text"
1610 maxLength={255}
1611 value={formData.block_reason}
1612 onChange={(e) =>
1613 handleFieldChange("block_reason", e.target.value)
1614 }
1615 placeholder={__(
1616 "e.g., Maintenance, Holiday, Special Event",
1617 "yatra",
1618 )}
1619 />
1620 <HelpText
1621 text={__(
1622 "Reason for blocking this date (optional but recommended)",
1623 "yatra",
1624 )}
1625 className="mt-1"
1626 />
1627 </div>
1628 )}
1629 </div>
1630
1631 {/* Status */}
1632 <div className="mt-4">
1633 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1634 {__("Status", "yatra")}
1635 </label>
1636 <Select
1637 value={formData.status}
1638 onChange={(e) => {
1639 handleFieldChange("status", e.target.value);
1640 if (e.target.value === "blocked") {
1641 handleFieldChange("is_blocked", true);
1642 } else if (
1643 formData.is_blocked &&
1644 e.target.value !== "blocked"
1645 ) {
1646 handleFieldChange("is_blocked", false);
1647 }
1648 }}
1649 disabled={formData.is_blocked}
1650 >
1651 <option value="available">{__("Available", "yatra")}</option>
1652 <option value="limited">{__("Limited", "yatra")}</option>
1653 <option value="sold_out">{__("Sold Out", "yatra")}</option>
1654 <option value="closed">{__("Closed", "yatra")}</option>
1655 <option value="blocked">{__("Blocked", "yatra")}</option>
1656 </Select>
1657 {formData.is_blocked && (
1658 <HelpText
1659 text={__(
1660 'Status is automatically set to "Blocked" when date is blocked',
1661 "yatra",
1662 )}
1663 className="mt-1"
1664 />
1665 )}
1666 </div>
1667 </CardContent>
1668 </Card>
1669
1670 {errors.submit && (
1671 <Alert variant="error">
1672 <AlertCircle className="w-4 h-4" />
1673 <p>{errors.submit}</p>
1674 </Alert>
1675 )}
1676
1677 <div className="flex items-center justify-end gap-4">
1678 <Button
1679 type="button"
1680 variant="outline"
1681 onClick={() => {
1682 if (tripId) {
1683 navigate({
1684 subpage: "trips",
1685 tab: "availability",
1686 trip_id: tripId.toString(),
1687 });
1688 } else {
1689 navigate({ subpage: "trips", tab: "availability" });
1690 }
1691 }}
1692 >
1693 {__("Cancel", "yatra")}
1694 </Button>
1695 <Button type="submit" disabled={saveMutation.isPending}>
1696 {saveMutation.isPending ? (
1697 <>
1698 <div className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
1699 {__("Saving...", "yatra")}
1700 </>
1701 ) : (
1702 <>
1703 <Save className="w-4 h-4 mr-2" />
1704 {__("Save Availability", "yatra")}
1705 </>
1706 )}
1707 </Button>
1708 </div>
1709 </form>
1710 </div>
1711 );
1712 };
1713
1714 export default AvailabilityForm;
1715