| @@ -87,8 +87,26 @@ | ||
| 87 | 87 | sale_price?: number; |
| 88 | 88 | traveler_pricing?: TravelerPricing[]; |
| 89 | 89 | } |
| 90 | 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 | + | |
| 91 | 109 | interface RecurringRule { |
| 92 | 110 | id?: number; |
| 93 | 111 | trip_id: number; |
| 94 | 112 | name: string; |
| @@ -98,9 +116,9 @@ | ||
| 98 | 116 | day_of_week?: number; |
| 99 | 117 | interval_days?: number; |
| 100 | 118 | start_date: string; |
| 101 | 119 | end_date?: string; |
| 102 | - excluded_dates: string[]; | |
| 120 | + excluded_dates: ExcludedEntry[]; | |
| 103 | 121 | months: number[]; // Array of month numbers (1-12) to filter by |
| 104 | 122 | time_slots: TimeSlot[]; // For single-day trips with multiple slots |
| 105 | 123 | pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type |
| 106 | 124 | original_price?: number; |
| @@ -181,8 +199,11 @@ | ||
| 181 | 199 | status: "active", |
| 182 | 200 | }); |
| 183 | 201 | |
| 184 | 202 | const [newExcludedDate, setNewExcludedDate] = useState(""); |
| 203 | + const [newExcludedStart, setNewExcludedStart] = useState(""); | |
| 204 | + const [newExcludedEnd, setNewExcludedEnd] = useState(""); | |
| 205 | + const [excludedError, setExcludedError] = useState(""); | |
| 185 | 206 | const [previewData, setPreviewData] = useState<{ |
| 186 | 207 | total: number; |
| 187 | 208 | dates: any[]; |
| 188 | 209 | } | null>(null); |
| @@ -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; |
| @@ -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> |