PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / AvailabilityForm.tsx

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

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