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/components/availability/RecurringRules.tsx +108 -28 3.0.2.8 → 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