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
← All changes | resources/js/pages/RecurringRuleForm.tsx +172 -45 3.0.6 → 3.0.16 View file →
@@ -18,8 +18,9 @@
18 18 MapPin,
19 19 CheckCircle2,
20 20 } from "lucide-react";
21 21 import { __ } from "../lib/i18n";
22 +import { toDateValue, todayYmd } from "../lib/dateFormat";
22 23 import { Button } from "../components/ui/button";
23 24 import { Input } from "../components/ui/input";
24 25 import { Select } from "../components/ui/select";
25 26 import { SearchableSelect } from "../components/ui/searchable-select";
@@ -86,8 +87,26 @@
86 87 sale_price?: number;
87 88 traveler_pricing?: TravelerPricing[];
88 89 }
89 90
91 +/**
92 + * One entry in a rule's exclusion list: either a single day ("2027-03-08", the
93 + * original format) or an inclusive period. Both are stored in the same
94 + * `excluded_dates` column, so rules saved before periods existed load as-is.
95 + */
96 +type ExcludedEntry = string | { start: string; end: string };
97 +
98 +const isPeriod = (e: ExcludedEntry): e is { start: string; end: string } =>
99 + typeof e === "object" && e !== null && "start" in e && "end" in e;
100 +
101 +/** Sort key: a period sorts by its first day, alongside single dates. */
102 +const excludedSortKey = (e: ExcludedEntry): string =>
103 + isPeriod(e) ? e.start : e;
104 +
105 +/** Stable identity for list keys and removal, for both shapes. */
106 +const excludedId = (e: ExcludedEntry): string =>
107 + isPeriod(e) ? `${e.start}..${e.end}` : e;
108 +
90 109 interface RecurringRule {
91 110 id?: number;
92 111 trip_id: number;
93 112 name: string;
@@ -97,9 +116,9 @@
97 116 day_of_week?: number;
98 117 interval_days?: number;
99 118 start_date: string;
100 119 end_date?: string;
101 - excluded_dates: string[];
120 + excluded_dates: ExcludedEntry[];
102 121 months: number[]; // Array of month numbers (1-12) to filter by
103 122 time_slots: TimeSlot[]; // For single-day trips with multiple slots
104 123 pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type
105 124 original_price?: number;
@@ -156,9 +175,9 @@
156 175 days_of_week: [0], // Sunday by default
157 176 week_of_month: "first",
158 177 day_of_week: 0,
159 178 interval_days: 7,
160 - start_date: new Date().toISOString().split("T")[0],
179 + start_date: todayYmd(),
161 180 end_date: "",
162 181 excluded_dates: [],
163 182 months: [], // Empty = all months, otherwise specific months (1-12)
164 183 time_slots: [], // For single-day trips with multiple slots
@@ -180,8 +199,11 @@
180 199 status: "active",
181 200 });
182 201
183 202 const [newExcludedDate, setNewExcludedDate] = useState("");
203 + const [newExcludedStart, setNewExcludedStart] = useState("");
204 + const [newExcludedEnd, setNewExcludedEnd] = useState("");
205 + const [excludedError, setExcludedError] = useState("");
184 206 const [previewData, setPreviewData] = useState<{
185 207 total: number;
186 208 dates: any[];
187 209 } | null>(null);
@@ -342,10 +364,9 @@
342 364 days_of_week: daysOfWeek.length > 0 ? daysOfWeek : [0],
343 365 week_of_month: existingRule.week_of_month || "first",
344 366 day_of_week: existingRule.day_of_week ?? 0,
345 367 interval_days: existingRule.interval_days || 7,
346 - start_date:
347 - existingRule.start_date || new Date().toISOString().split("T")[0],
368 + start_date: existingRule.start_date || todayYmd(),
348 369 end_date: existingRule.end_date || "",
349 370 excluded_dates: Array.isArray(existingRule.excluded_dates)
350 371 ? existingRule.excluded_dates
351 372 : [],
@@ -564,24 +585,79 @@
564 585 : [...prev.days_of_week, day].sort((a, b) => a - b),
565 586 }));
566 587 };
567 588
568 - // Add excluded date
589 + const sortExcluded = (list: ExcludedEntry[]): ExcludedEntry[] =>
590 + [...list].sort((a, b) => excludedSortKey(a).localeCompare(excludedSortKey(b)));
591 +
592 + // Add a single excluded day
569 593 const addExcludedDate = () => {
570 - if (newExcludedDate && !formData.excluded_dates.includes(newExcludedDate)) {
594 + if (!newExcludedDate) {
595 + return;
596 + }
597 + const exists = formData.excluded_dates.some(
598 + (e) => excludedId(e) === newExcludedDate,
599 + );
600 + if (exists) {
601 + setExcludedError(__("That date is already excluded.", "yatra"));
602 + return;
603 + }
604 + setExcludedError("");
605 + setFormData((prev) => ({
606 + ...prev,
607 + excluded_dates: sortExcluded([...prev.excluded_dates, newExcludedDate]),
608 + }));
609 + setNewExcludedDate("");
610 + };
611 +
612 + // Add an excluded period — one entry covering every day from start to end,
613 + // so a shutdown doesn't have to be added a day at a time.
614 + const addExcludedPeriod = () => {
615 + if (!newExcludedStart || !newExcludedEnd) {
616 + return;
617 + }
618 + // Accept the two dates in either order rather than rejecting the input.
619 + const start =
620 + newExcludedStart <= newExcludedEnd ? newExcludedStart : newExcludedEnd;
621 + const end =
622 + newExcludedStart <= newExcludedEnd ? newExcludedEnd : newExcludedStart;
623 +
624 + if (start === end) {
625 + // A one-day period is just a single date; keep the list in its simplest form.
626 + if (formData.excluded_dates.some((e) => excludedId(e) === start)) {
627 + setExcludedError(__("That date is already excluded.", "yatra"));
628 + return;
629 + }
630 + setExcludedError("");
571 631 setFormData((prev) => ({
572 632 ...prev,
573 - excluded_dates: [...prev.excluded_dates, newExcludedDate].sort(),
633 + excluded_dates: sortExcluded([...prev.excluded_dates, start]),
574 634 }));
575 - setNewExcludedDate("");
635 + setNewExcludedStart("");
636 + setNewExcludedEnd("");
637 + return;
576 638 }
639 +
640 + const id = `${start}..${end}`;
641 + if (formData.excluded_dates.some((e) => excludedId(e) === id)) {
642 + setExcludedError(__("That period is already excluded.", "yatra"));
643 + return;
644 + }
645 + setExcludedError("");
646 + setFormData((prev) => ({
647 + ...prev,
648 + excluded_dates: sortExcluded([...prev.excluded_dates, { start, end }]),
649 + }));
650 + setNewExcludedStart("");
651 + setNewExcludedEnd("");
577 652 };
578 653
579 - // Remove excluded date
580 - const removeExcludedDate = (date: string) => {
654 + // Remove an excluded day or period
655 + const removeExcluded = (id: string) => {
656 + setExcludedError("");
581 657 setFormData((prev) => ({
582 658 ...prev,
583 - excluded_dates: prev.excluded_dates.filter((d) => d !== date),
659 + excluded_dates: prev.excluded_dates.filter((e) => excludedId(e) !== id),
584 660 }));
585 661 };
586 662
587 663 const isLoading = createMutation.isPending || updateMutation.isPending;
@@ -836,9 +912,9 @@
836 912 setFormData((prev) => ({ ...prev, end_date: value }))
837 913 }
838 914 minDate={
839 915 formData.start_date
840 - ? new Date(formData.start_date)
916 + ? toDateValue(formData.start_date)
841 917 : undefined
842 918 }
843 919 placeholder={__("Select end date (optional)", "yatra")}
844 920 />
@@ -907,9 +983,9 @@
907 983 </p>
908 984 )}
909 985 </div>
910 986
911 - {/* Excluded Dates */}
987 + {/* Excluded Dates & Periods */}
912 988 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
913 989 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
914 990 {__("Excluded Dates", "yatra")}{" "}
915 991 <span className="text-gray-400">
@@ -932,34 +1008,86 @@
932 1008 >
933 1009 <Plus className="w-4 h-4" />
934 1010 </Button>
935 1011 </div>
1012 +
1013 + {/* A whole period in one go — a shutdown shouldn't have to be
1014 + added a day at a time. Named "Periods" so it can't be
1015 + confused with the rule's own Date Range above. */}
1016 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 mt-4">
1017 + {__("Excluded Periods", "yatra")}{" "}
1018 + <span className="text-gray-400">
1019 + ({__("holiday closures, vacations", "yatra")})
1020 + </span>
1021 + </label>
1022 + <div className="flex flex-col sm:flex-row gap-2 mb-1">
1023 + <div className="flex-1">
1024 + <DatePicker
1025 + value={newExcludedStart}
1026 + onChange={(value: string) => setNewExcludedStart(value)}
1027 + placeholder={__("First day", "yatra")}
1028 + />
1029 + </div>
1030 + <div className="flex-1">
1031 + <DatePicker
1032 + value={newExcludedEnd}
1033 + onChange={(value: string) => setNewExcludedEnd(value)}
1034 + placeholder={__("Last day", "yatra")}
1035 + />
1036 + </div>
1037 + <Button
1038 + type="button"
1039 + variant="outline"
1040 + onClick={addExcludedPeriod}
1041 + disabled={!newExcludedStart || !newExcludedEnd}
1042 + >
1043 + <Plus className="w-4 h-4" />
1044 + </Button>
1045 + </div>
1046 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
1047 + {__(
1048 + "Both days are included. Every date in between is skipped by this rule.",
1049 + "yatra",
1050 + )}
1051 + </p>
1052 +
1053 + {excludedError && (
1054 + <p className="text-xs text-red-600 dark:text-red-400 mb-2">
1055 + {excludedError}
1056 + </p>
1057 + )}
1058 +
936 1059 {formData.excluded_dates.length > 0 && (
937 1060 <div className="flex flex-wrap gap-2">
938 - {formData.excluded_dates.map((date) => (
939 - <Badge
940 - key={date}
941 - variant="outline"
942 - className="flex items-center gap-1"
943 - >
944 - {new Date(date + "T00:00:00").toLocaleDateString(
945 - "en-US",
946 - {
947 - weekday: "short",
948 - month: "short",
949 - day: "numeric",
950 - year: "numeric",
951 - },
952 - )}
953 - <button
954 - type="button"
955 - onClick={() => removeExcludedDate(date)}
956 - className="ml-1 hover:text-red-500"
1061 + {formData.excluded_dates.map((entry) => {
1062 + const id = excludedId(entry);
1063 + const fmt = (d: string) =>
1064 + new Date(d + "T00:00:00").toLocaleDateString("en-US", {
1065 + weekday: "short",
1066 + month: "short",
1067 + day: "numeric",
1068 + year: "numeric",
1069 + });
1070 + return (
1071 + <Badge
1072 + key={id}
1073 + variant="outline"
1074 + className="flex items-center gap-1"
957 1075 >
958 - <X className="w-3 h-3" />
959 - </button>
960 - </Badge>
961 - ))}
1076 + {isPeriod(entry)
1077 + ? `${fmt(entry.start)} → ${fmt(entry.end)}`
1078 + : fmt(entry)}
1079 + <button
1080 + type="button"
1081 + onClick={() => removeExcluded(id)}
1082 + className="ml-1 hover:text-red-500"
1083 + aria-label={__("Remove exclusion", "yatra")}
1084 + >
1085 + <X className="w-3 h-3" />
1086 + </button>
1087 + </Badge>
1088 + );
1089 + })}
962 1090 </div>
963 1091 )}
964 1092 </div>
965 1093 </CardContent>
@@ -2560,17 +2688,16 @@
2560 2688 key={index}
2561 2689 className="text-xs px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded flex justify-between"
2562 2690 >
2563 2691 <span>
2564 - {new Date(date.departure_date).toLocaleDateString(
2565 - "en-US",
2566 - {
2567 - weekday: "short",
2568 - month: "short",
2569 - day: "numeric",
2570 - year: "numeric",
2571 - },
2572 - )}
2692 + {toDateValue(
2693 + date.departure_date,
2694 + ).toLocaleDateString("en-US", {
2695 + weekday: "short",
2696 + month: "short",
2697 + day: "numeric",
2698 + year: "numeric",
2699 + })}
2573 2700 </span>
2574 2701 {date.departure_time && (
2575 2702 <span className="text-gray-500">
2576 2703 {date.departure_time}