| @@ -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); |
| @@ -200,12 +222,14 @@ | ||
| 200 | 222 | // effectively traveler-based (price types configured). Infer the effective |
| 201 | 223 | // pricing type from `price_types` when present so the Rules UI reflects |
| 202 | 224 | // real trip configuration. |
| 203 | 225 | const rawPriceTypes = trip.price_types; |
| 204 | - const hasTravelerPricing = | |
| 205 | - Array.isArray(rawPriceTypes) ? rawPriceTypes.length > 0 : false; | |
| 206 | - const effectivePricingType = | |
| 207 | - hasTravelerPricing ? "traveler_based" : trip.pricing_type || "regular"; | |
| 226 | + const hasTravelerPricing = Array.isArray(rawPriceTypes) | |
| 227 | + ? rawPriceTypes.length > 0 | |
| 228 | + : false; | |
| 229 | + const effectivePricingType = hasTravelerPricing | |
| 230 | + ? "traveler_based" | |
| 231 | + : trip.pricing_type || "regular"; | |
| 208 | 232 | |
| 209 | 233 | return { |
| 210 | 234 | id: Number(trip.id) || 0, |
| 211 | 235 | title: trip.title, |
| @@ -320,9 +344,11 @@ | ||
| 320 | 344 | |
| 321 | 345 | // Prefer the *trip's effective* pricing type over any stale rule.pricing_type. |
| 322 | 346 | // Trips can be traveler-based simply by having price_types configured, even if |
| 323 | 347 | // trip.pricing_type is still "regular". |
| 324 | - const tripRow = tripsData.trips.find((t) => t.id === Number(existingRule.trip_id)); | |
| 348 | + const tripRow = tripsData.trips.find( | |
| 349 | + (t) => t.id === Number(existingRule.trip_id), | |
| 350 | + ); | |
| 325 | 351 | const effectivePricingType = |
| 326 | 352 | tripRow?.pricing_type || |
| 327 | 353 | ((tripForLocations as any)?.price_types && |
| 328 | 354 | Array.isArray((tripForLocations as any).price_types) && |
| @@ -338,10 +364,9 @@ | ||
| 338 | 364 | days_of_week: daysOfWeek.length > 0 ? daysOfWeek : [0], |
| 339 | 365 | week_of_month: existingRule.week_of_month || "first", |
| 340 | 366 | day_of_week: existingRule.day_of_week ?? 0, |
| 341 | 367 | interval_days: existingRule.interval_days || 7, |
| 342 | - start_date: | |
| 343 | - existingRule.start_date || new Date().toISOString().split("T")[0], | |
| 368 | + start_date: existingRule.start_date || todayYmd(), | |
| 344 | 369 | end_date: existingRule.end_date || "", |
| 345 | 370 | excluded_dates: Array.isArray(existingRule.excluded_dates) |
| 346 | 371 | ? existingRule.excluded_dates |
| 347 | 372 | : [], |
| @@ -375,12 +400,16 @@ | ||
| 375 | 400 | useEffect(() => { |
| 376 | 401 | if (!isEditing && !existingRule) { |
| 377 | 402 | const inferred = (() => { |
| 378 | 403 | const priceTypes = (tripForLocations as any)?.price_types; |
| 379 | - const hasTravelerPricing = Array.isArray(priceTypes) && priceTypes.length > 0; | |
| 380 | - return (hasTravelerPricing | |
| 381 | - ? "traveler_based" | |
| 382 | - : (selectedTrip?.pricing_type || (tripForLocations as Trip)?.pricing_type || "regular") | |
| 404 | + const hasTravelerPricing = | |
| 405 | + Array.isArray(priceTypes) && priceTypes.length > 0; | |
| 406 | + return ( | |
| 407 | + hasTravelerPricing | |
| 408 | + ? "traveler_based" | |
| 409 | + : selectedTrip?.pricing_type || | |
| 410 | + (tripForLocations as Trip)?.pricing_type || | |
| 411 | + "regular" | |
| 383 | 412 | ) as "regular" | "traveler_based"; |
| 384 | 413 | })(); |
| 385 | 414 | |
| 386 | 415 | setFormData((prev) => ({ |
| @@ -556,24 +585,79 @@ | ||
| 556 | 585 | : [...prev.days_of_week, day].sort((a, b) => a - b), |
| 557 | 586 | })); |
| 558 | 587 | }; |
| 559 | 588 | |
| 560 | - // 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 | |
| 561 | 593 | const addExcludedDate = () => { |
| 562 | - 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(""); | |
| 563 | 631 | setFormData((prev) => ({ |
| 564 | 632 | ...prev, |
| 565 | - excluded_dates: [...prev.excluded_dates, newExcludedDate].sort(), | |
| 633 | + excluded_dates: sortExcluded([...prev.excluded_dates, start]), | |
| 566 | 634 | })); |
| 567 | - setNewExcludedDate(""); | |
| 635 | + setNewExcludedStart(""); | |
| 636 | + setNewExcludedEnd(""); | |
| 637 | + return; | |
| 568 | 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(""); | |
| 569 | 652 | }; |
| 570 | 653 | |
| 571 | - // Remove excluded date | |
| 572 | - const removeExcludedDate = (date: string) => { | |
| 654 | + // Remove an excluded day or period | |
| 655 | + const removeExcluded = (id: string) => { | |
| 656 | + setExcludedError(""); | |
| 573 | 657 | setFormData((prev) => ({ |
| 574 | 658 | ...prev, |
| 575 | - excluded_dates: prev.excluded_dates.filter((d) => d !== date), | |
| 659 | + excluded_dates: prev.excluded_dates.filter((e) => excludedId(e) !== id), | |
| 576 | 660 | })); |
| 577 | 661 | }; |
| 578 | 662 | |
| 579 | 663 | const isLoading = createMutation.isPending || updateMutation.isPending; |
| @@ -828,9 +912,9 @@ | ||
| 828 | 912 | setFormData((prev) => ({ ...prev, end_date: value })) |
| 829 | 913 | } |
| 830 | 914 | minDate={ |
| 831 | 915 | formData.start_date |
| 832 | - ? new Date(formData.start_date) | |
| 916 | + ? toDateValue(formData.start_date) | |
| 833 | 917 | : undefined |
| 834 | 918 | } |
| 835 | 919 | placeholder={__("Select end date (optional)", "yatra")} |
| 836 | 920 | /> |
| @@ -899,9 +983,9 @@ | ||
| 899 | 983 | </p> |
| 900 | 984 | )} |
| 901 | 985 | </div> |
| 902 | 986 | |
| 903 | - {/* Excluded Dates */} | |
| 987 | + {/* Excluded Dates & Periods */} | |
| 904 | 988 | <div className="pt-4 border-t border-gray-200 dark:border-gray-700"> |
| 905 | 989 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> |
| 906 | 990 | {__("Excluded Dates", "yatra")}{" "} |
| 907 | 991 | <span className="text-gray-400"> |
| @@ -924,34 +1008,86 @@ | ||
| 924 | 1008 | > |
| 925 | 1009 | <Plus className="w-4 h-4" /> |
| 926 | 1010 | </Button> |
| 927 | 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 | + | |
| 928 | 1059 | {formData.excluded_dates.length > 0 && ( |
| 929 | 1060 | <div className="flex flex-wrap gap-2"> |
| 930 | - {formData.excluded_dates.map((date) => ( | |
| 931 | - <Badge | |
| 932 | - key={date} | |
| 933 | - variant="outline" | |
| 934 | - className="flex items-center gap-1" | |
| 935 | - > | |
| 936 | - {new Date(date + "T00:00:00").toLocaleDateString( | |
| 937 | - "en-US", | |
| 938 | - { | |
| 939 | - weekday: "short", | |
| 940 | - month: "short", | |
| 941 | - day: "numeric", | |
| 942 | - year: "numeric", | |
| 943 | - }, | |
| 944 | - )} | |
| 945 | - <button | |
| 946 | - type="button" | |
| 947 | - onClick={() => removeExcludedDate(date)} | |
| 948 | - 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" | |
| 949 | 1075 | > |
| 950 | - <X className="w-3 h-3" /> | |
| 951 | - </button> | |
| 952 | - </Badge> | |
| 953 | - ))} | |
| 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 | + })} | |
| 954 | 1090 | </div> |
| 955 | 1091 | )} |
| 956 | 1092 | </div> |
| 957 | 1093 | </CardContent> |
| @@ -2006,9 +2142,11 @@ | ||
| 2006 | 2142 | setFormData((prev) => { |
| 2007 | 2143 | const newSlots = [ |
| 2008 | 2144 | ...prev.time_slots, |
| 2009 | 2145 | ]; |
| 2010 | - if (!newSlots[index]) { | |
| 2146 | + if ( | |
| 2147 | + !newSlots[index] | |
| 2148 | + ) { | |
| 2011 | 2149 | return prev; |
| 2012 | 2150 | } |
| 2013 | 2151 | newSlots[index] = { |
| 2014 | 2152 | ...newSlots[index], |
| @@ -2023,9 +2161,10 @@ | ||
| 2023 | 2161 | ), |
| 2024 | 2162 | }; |
| 2025 | 2163 | return { |
| 2026 | 2164 | ...prev, |
| 2027 | - time_slots: newSlots, | |
| 2165 | + time_slots: | |
| 2166 | + newSlots, | |
| 2028 | 2167 | }; |
| 2029 | 2168 | }); |
| 2030 | 2169 | }} |
| 2031 | 2170 | className="p-0.5 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors" |
| @@ -2549,17 +2688,16 @@ | ||
| 2549 | 2688 | key={index} |
| 2550 | 2689 | className="text-xs px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded flex justify-between" |
| 2551 | 2690 | > |
| 2552 | 2691 | <span> |
| 2553 | - {new Date(date.departure_date).toLocaleDateString( | |
| 2554 | - "en-US", | |
| 2555 | - { | |
| 2556 | - weekday: "short", | |
| 2557 | - month: "short", | |
| 2558 | - day: "numeric", | |
| 2559 | - year: "numeric", | |
| 2560 | - }, | |
| 2561 | - )} | |
| 2692 | + {toDateValue( | |
| 2693 | + date.departure_date, | |
| 2694 | + ).toLocaleDateString("en-US", { | |
| 2695 | + weekday: "short", | |
| 2696 | + month: "short", | |
| 2697 | + day: "numeric", | |
| 2698 | + year: "numeric", | |
| 2699 | + })} | |
| 2562 | 2700 | </span> |
| 2563 | 2701 | {date.departure_time && ( |
| 2564 | 2702 | <span className="text-gray-500"> |
| 2565 | 2703 | {date.departure_time} |