← All changes
|
resources/js/components/availability/RecurringRules.tsx
+108
-28
3.0.2.7
→
3.0.16
View file →
| @@ -18,8 +18,9 @@ | ||
| 18 | 18 | Search, |
| 19 | 19 | X, |
| 20 | 20 | } from "lucide-react"; |
| 21 | 21 | import { __ } from "../../lib/i18n"; |
| 22 | +import { toDateValue } from "../../lib/dateFormat"; | |
| 22 | 23 | import { Button } from "../ui/button"; |
| 23 | 24 | import { Input } from "../ui/input"; |
| 24 | 25 | import { Select } from "../ui/select"; |
| 25 | 26 | import { Card, CardContent } from "../ui/card"; |
| @@ -27,8 +28,9 @@ | ||
| 27 | 28 | import { apiClient } from "../../lib/api-client"; |
| 28 | 29 | import { useToast } from "../ui/toast"; |
| 29 | 30 | import { BulkActionToolbar, Table as SharedTable } from "../shared"; |
| 30 | 31 | import { ConfirmationDialog } from "../ui/confirmation-dialog"; |
| 32 | +import { formatYatraMoney } from "../../lib/currency-display"; | |
| 31 | 33 | |
| 32 | 34 | interface RecurringRule { |
| 33 | 35 | id: number; |
| 34 | 36 | trip_id: number; |
| @@ -91,8 +93,13 @@ | ||
| 91 | 93 | const isSingleDayTrip = tripType === "single_day"; |
| 92 | 94 | const isTravelerBased = pricingType === "traveler_based"; |
| 93 | 95 | const queryClient = useQueryClient(); |
| 94 | 96 | const { showToast } = useToast(); |
| 97 | + const adminCurrency = | |
| 98 | + (typeof window !== "undefined" && | |
| 99 | + (window as unknown as { yatraAdmin?: { currency?: string } }).yatraAdmin | |
| 100 | + ?.currency) || | |
| 101 | + "USD"; | |
| 95 | 102 | |
| 96 | 103 | // State management |
| 97 | 104 | const [searchTerm, setSearchTerm] = useState(""); |
| 98 | 105 | const [statusFilter, setStatusFilter] = useState("all"); |
| @@ -110,8 +117,9 @@ | ||
| 110 | 117 | status: true, |
| 111 | 118 | }); |
| 112 | 119 | |
| 113 | 120 | // Confirmation dialogs |
| 121 | + const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false); | |
| 114 | 122 | const [deleteConfirm, setDeleteConfirm] = useState<{ |
| 115 | 123 | isOpen: boolean; |
| 116 | 124 | rule: RecurringRule | null; |
| 117 | 125 | }>({ |
| @@ -143,11 +151,17 @@ | ||
| 143 | 151 | return "Unknown pattern"; |
| 144 | 152 | } |
| 145 | 153 | }; |
| 146 | 154 | |
| 147 | - // Fetch status counts from API endpoint | |
| 155 | + // Fetch status counts from API endpoint. | |
| 156 | + // Key is nested under ["recurring-availability", ...] so a single | |
| 157 | + // invalidateQueries({ queryKey: ["recurring-availability"] }) refreshes | |
| 158 | + // both the list AND the status badge counts (All / Active / Inactive). | |
| 159 | + // Otherwise, after deleting the last rule, the list correctly went to 0 | |
| 160 | + // while the badges remained stale at 1, which looked like "no data but | |
| 161 | + // 1 on All and Active" for the trip. | |
| 148 | 162 | const { data: countsData } = useQuery({ |
| 149 | - queryKey: ["recurring-availability-counts", tripId], | |
| 163 | + queryKey: ["recurring-availability", "counts", tripId], | |
| 150 | 164 | queryFn: async () => { |
| 151 | 165 | const response = await apiClient.get("/recurring-availability/counts", { |
| 152 | 166 | params: { |
| 153 | 167 | trip_id: tripId, |
| @@ -180,8 +194,9 @@ | ||
| 180 | 194 | staleTime: 0, // Always fetch fresh data |
| 181 | 195 | gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions) |
| 182 | 196 | }); |
| 183 | 197 | |
| 198 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 184 | 199 | const allRules = rulesData?.rules || []; |
| 185 | 200 | |
| 186 | 201 | // Filter rules based on status and search |
| 187 | 202 | const rules = useMemo(() => { |
| @@ -212,8 +227,12 @@ | ||
| 212 | 227 | mutationFn: async (id: number) => { |
| 213 | 228 | return await apiClient.delete(`/recurring-availability/${id}`); |
| 214 | 229 | }, |
| 215 | 230 | onSuccess: () => { |
| 231 | + // Invalidate list AND badge counts. Both keys share the | |
| 232 | + // ["recurring-availability", ...] prefix so a single call refreshes | |
| 233 | + // both, but we keep the list key around in case a future refactor | |
| 234 | + // narrows the prefix. | |
| 216 | 235 | queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); |
| 217 | 236 | showToast(__("Recurring rule deleted successfully", "yatra"), "success"); |
| 218 | 237 | setDeleteConfirm({ isOpen: false, rule: null }); |
| 219 | 238 | setSelectedIds([]); |
| @@ -268,25 +287,27 @@ | ||
| 268 | 287 | }); |
| 269 | 288 | |
| 270 | 289 | // Handle bulk actions |
| 271 | 290 | const handleBulkApply = () => { |
| 272 | - if (!bulkAction || selectedIds.length === 0) { | |
| 273 | - showToast(__("Please select rules and an action", "yatra"), "warning"); | |
| 291 | + // Report the input that is actually missing. The Apply button is disabled | |
| 292 | + // whenever nothing is selected, so this can only be reached with a non-empty | |
| 293 | + // selection — the combined message blamed the selection and left operators | |
| 294 | + // re-selecting rows that were already ticked. | |
| 295 | + if (selectedIds.length === 0) { | |
| 296 | + showToast(__("Please select at least one rule.", "yatra"), "warning"); | |
| 274 | 297 | return; |
| 275 | 298 | } |
| 276 | 299 | |
| 300 | + if (!bulkAction) { | |
| 301 | + showToast(__("Please choose an action to apply.", "yatra"), "warning"); | |
| 302 | + return; | |
| 303 | + } | |
| 304 | + | |
| 277 | 305 | switch (bulkAction) { |
| 278 | 306 | case "delete": |
| 279 | - if ( | |
| 280 | - confirm( | |
| 281 | - __( | |
| 282 | - "Are you sure you want to delete {count} rule(s)?", | |
| 283 | - "yatra", | |
| 284 | - ).replace("{count}", selectedIds.length.toString()), | |
| 285 | - ) | |
| 286 | - ) { | |
| 287 | - bulkDeleteMutation.mutate(selectedIds.map((id) => id.toString())); | |
| 288 | - } | |
| 307 | + // Confirmed through the shared dialog, matching the single-rule delete | |
| 308 | + // and duplicate actions in this same component. | |
| 309 | + setBulkDeleteConfirm(true); | |
| 289 | 310 | break; |
| 290 | 311 | } |
| 291 | 312 | |
| 292 | 313 | setBulkAction(""); |
| @@ -303,9 +324,9 @@ | ||
| 303 | 324 | // Format date |
| 304 | 325 | const formatDate = (dateString: string | null | undefined): string => { |
| 305 | 326 | if (!dateString) return "--"; |
| 306 | 327 | try { |
| 307 | - const date = new Date(dateString); | |
| 328 | + const date = toDateValue(dateString); | |
| 308 | 329 | return date.toLocaleDateString("en-US", { |
| 309 | 330 | year: "numeric", |
| 310 | 331 | month: "short", |
| 311 | 332 | day: "numeric", |
| @@ -453,17 +474,52 @@ | ||
| 453 | 474 | cols.push({ |
| 454 | 475 | key: "price", |
| 455 | 476 | label: __("Price", "yatra"), |
| 456 | 477 | visible: visibleColumns.price, |
| 457 | - render: (rule: RecurringRule) => ( | |
| 458 | - <div className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 459 | - {rule.sale_price | |
| 460 | - ? `$${rule.sale_price}` | |
| 461 | - : rule.original_price | |
| 462 | - ? `$${rule.original_price}` | |
| 463 | - : "$0"} | |
| 464 | - </div> | |
| 465 | - ), | |
| 478 | + render: (rule: RecurringRule) => { | |
| 479 | + // Traveler-based rules store prices per category in traveler_pricing, | |
| 480 | + // leaving the top-level sale_price/original_price columns NULL. Derive | |
| 481 | + // a price (or min–max range) from the per-category data in that case. | |
| 482 | + const tp = Array.isArray(rule.traveler_pricing) | |
| 483 | + ? rule.traveler_pricing | |
| 484 | + : []; | |
| 485 | + const travelerPrices = tp | |
| 486 | + .map((p) => Number(p.sale_price ?? p.original_price) || 0) | |
| 487 | + .filter((n) => n > 0); | |
| 488 | + | |
| 489 | + let display: string; | |
| 490 | + if (travelerPrices.length > 0) { | |
| 491 | + const min = Math.min(...travelerPrices); | |
| 492 | + const max = Math.max(...travelerPrices); | |
| 493 | + display = | |
| 494 | + min === max | |
| 495 | + ? formatYatraMoney(min, adminCurrency, { zeroAsUnknown: false }) | |
| 496 | + : `${formatYatraMoney(min, adminCurrency, { | |
| 497 | + zeroAsUnknown: false, | |
| 498 | + })} – ${formatYatraMoney(max, adminCurrency, { | |
| 499 | + zeroAsUnknown: false, | |
| 500 | + })}`; | |
| 501 | + } else { | |
| 502 | + // Regular rules: prefer sale price, fall back to original. | |
| 503 | + // Use Number(...) > 0 (not truthiness) so a "0.00" string from the | |
| 504 | + // DB doesn't mask a real original_price. | |
| 505 | + const sale = Number(rule.sale_price) || 0; | |
| 506 | + const original = Number(rule.original_price) || 0; | |
| 507 | + display = formatYatraMoney( | |
| 508 | + sale > 0 ? sale : original, | |
| 509 | + adminCurrency, | |
| 510 | + { | |
| 511 | + zeroAsUnknown: false, | |
| 512 | + }, | |
| 513 | + ); | |
| 514 | + } | |
| 515 | + | |
| 516 | + return ( | |
| 517 | + <div className="text-sm font-semibold text-gray-900 dark:text-white"> | |
| 518 | + {display} | |
| 519 | + </div> | |
| 520 | + ); | |
| 521 | + }, | |
| 466 | 522 | }); |
| 467 | 523 | } |
| 468 | 524 | |
| 469 | 525 | if (visibleColumns.status) { |
| @@ -475,9 +531,16 @@ | ||
| 475 | 531 | }); |
| 476 | 532 | } |
| 477 | 533 | |
| 478 | 534 | return cols; |
| 479 | - }, [visibleColumns, formatRulePattern, formatDate, getStatusBadge]); | |
| 535 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 536 | + }, [ | |
| 537 | + visibleColumns, | |
| 538 | + formatRulePattern, | |
| 539 | + formatDate, | |
| 540 | + getStatusBadge, | |
| 541 | + adminCurrency, | |
| 542 | + ]); | |
| 480 | 543 | |
| 481 | 544 | // Status toggle mutation |
| 482 | 545 | const toggleStatusMutation = useMutation({ |
| 483 | 546 | mutationFn: async ({ |
| @@ -489,12 +552,11 @@ | ||
| 489 | 552 | }) => { |
| 490 | 553 | return await apiClient.put(`/recurring-availability/${id}`, { status }); |
| 491 | 554 | }, |
| 492 | 555 | onSuccess: () => { |
| 556 | + // Single prefix invalidation refreshes both the rules list | |
| 557 | + // and the status badge counts (now keyed under the same prefix). | |
| 493 | 558 | queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); |
| 494 | - queryClient.invalidateQueries({ | |
| 495 | - queryKey: ["recurring-availability-counts"], | |
| 496 | - }); | |
| 497 | 559 | showToast(__("Rule status updated successfully", "yatra"), "success"); |
| 498 | 560 | }, |
| 499 | 561 | onError: (error: any) => { |
| 500 | 562 | showToast( |
| @@ -879,8 +941,26 @@ | ||
| 879 | 941 | </div> |
| 880 | 942 | </div> |
| 881 | 943 | </CardContent> |
| 882 | 944 | </Card> |
| 945 | + | |
| 946 | + <ConfirmationDialog | |
| 947 | + isOpen={bulkDeleteConfirm} | |
| 948 | + onClose={() => setBulkDeleteConfirm(false)} | |
| 949 | + onConfirm={() => { | |
| 950 | + bulkDeleteMutation.mutate(selectedIds.map((id) => id.toString())); | |
| 951 | + setBulkDeleteConfirm(false); | |
| 952 | + }} | |
| 953 | + title={__("Delete Rules", "yatra")} | |
| 954 | + message={__( | |
| 955 | + "Are you sure you want to delete {count} rule(s)? This action cannot be undone.", | |
| 956 | + "yatra", | |
| 957 | + ).replace("{count}", selectedIds.length.toString())} | |
| 958 | + confirmText={__("Delete", "yatra")} | |
| 959 | + cancelText={__("Cancel", "yatra")} | |
| 960 | + variant="danger" | |
| 961 | + isLoading={bulkDeleteMutation.isPending} | |
| 962 | + /> | |
| 883 | 963 | </div> |
| 884 | 964 | ); |
| 885 | 965 | }; |
| 886 | 966 | |