| @@ -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} |