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 +97 -35 3.0.3 → 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";
@@ -94,10 +95,10 @@
94 95 const queryClient = useQueryClient();
95 96 const { showToast } = useToast();
96 97 const adminCurrency =
97 98 (typeof window !== "undefined" &&
98 - (window as unknown as { yatraAdmin?: { currency?: string } })
99 - .yatraAdmin?.currency) ||
99 + (window as unknown as { yatraAdmin?: { currency?: string } }).yatraAdmin
100 + ?.currency) ||
100 101 "USD";
101 102
102 103 // State management
103 104 const [searchTerm, setSearchTerm] = useState("");
@@ -116,8 +117,9 @@
116 117 status: true,
117 118 });
118 119
119 120 // Confirmation dialogs
121 + const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
120 122 const [deleteConfirm, setDeleteConfirm] = useState<{
121 123 isOpen: boolean;
122 124 rule: RecurringRule | null;
123 125 }>({
@@ -149,11 +151,17 @@
149 151 return "Unknown pattern";
150 152 }
151 153 };
152 154
153 - // 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.
154 162 const { data: countsData } = useQuery({
155 - queryKey: ["recurring-availability-counts", tripId],
163 + queryKey: ["recurring-availability", "counts", tripId],
156 164 queryFn: async () => {
157 165 const response = await apiClient.get("/recurring-availability/counts", {
158 166 params: {
159 167 trip_id: tripId,
@@ -186,8 +194,9 @@
186 194 staleTime: 0, // Always fetch fresh data
187 195 gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
188 196 });
189 197
198 + // eslint-disable-next-line react-hooks/exhaustive-deps
190 199 const allRules = rulesData?.rules || [];
191 200
192 201 // Filter rules based on status and search
193 202 const rules = useMemo(() => {
@@ -218,8 +227,12 @@
218 227 mutationFn: async (id: number) => {
219 228 return await apiClient.delete(`/recurring-availability/${id}`);
220 229 },
221 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.
222 235 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
223 236 showToast(__("Recurring rule deleted successfully", "yatra"), "success");
224 237 setDeleteConfirm({ isOpen: false, rule: null });
225 238 setSelectedIds([]);
@@ -274,25 +287,27 @@
274 287 });
275 288
276 289 // Handle bulk actions
277 290 const handleBulkApply = () => {
278 - if (!bulkAction || selectedIds.length === 0) {
279 - 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");
280 297 return;
281 298 }
282 299
300 + if (!bulkAction) {
301 + showToast(__("Please choose an action to apply.", "yatra"), "warning");
302 + return;
303 + }
304 +
283 305 switch (bulkAction) {
284 306 case "delete":
285 - if (
286 - confirm(
287 - __(
288 - "Are you sure you want to delete {count} rule(s)?",
289 - "yatra",
290 - ).replace("{count}", selectedIds.length.toString()),
291 - )
292 - ) {
293 - bulkDeleteMutation.mutate(selectedIds.map((id) => id.toString()));
294 - }
307 + // Confirmed through the shared dialog, matching the single-rule delete
308 + // and duplicate actions in this same component.
309 + setBulkDeleteConfirm(true);
295 310 break;
296 311 }
297 312
298 313 setBulkAction("");
@@ -309,9 +324,9 @@
309 324 // Format date
310 325 const formatDate = (dateString: string | null | undefined): string => {
311 326 if (!dateString) return "--";
312 327 try {
313 - const date = new Date(dateString);
328 + const date = toDateValue(dateString);
314 329 return date.toLocaleDateString("en-US", {
315 330 year: "numeric",
316 331 month: "short",
317 332 day: "numeric",
@@ -459,23 +474,52 @@
459 474 cols.push({
460 475 key: "price",
461 476 label: __("Price", "yatra"),
462 477 visible: visibleColumns.price,
463 - render: (rule: RecurringRule) => (
464 - <div className="text-sm font-semibold text-gray-900 dark:text-white">
465 - {rule.sale_price
466 - ? formatYatraMoney(Number(rule.sale_price) || 0, adminCurrency, {
467 - zeroAsUnknown: false,
468 - })
469 - : rule.original_price
470 - ? formatYatraMoney(
471 - Number(rule.original_price) || 0,
472 - adminCurrency,
473 - { zeroAsUnknown: false },
474 - )
475 - : formatYatraMoney(0, adminCurrency, { zeroAsUnknown: false })}
476 - </div>
477 - ),
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 + },
478 522 });
479 523 }
480 524
481 525 if (visibleColumns.status) {
@@ -487,8 +531,9 @@
487 531 });
488 532 }
489 533
490 534 return cols;
535 + // eslint-disable-next-line react-hooks/exhaustive-deps
491 536 }, [
492 537 visibleColumns,
493 538 formatRulePattern,
494 539 formatDate,
@@ -507,12 +552,11 @@
507 552 }) => {
508 553 return await apiClient.put(`/recurring-availability/${id}`, { status });
509 554 },
510 555 onSuccess: () => {
556 + // Single prefix invalidation refreshes both the rules list
557 + // and the status badge counts (now keyed under the same prefix).
511 558 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
512 - queryClient.invalidateQueries({
513 - queryKey: ["recurring-availability-counts"],
514 - });
515 559 showToast(__("Rule status updated successfully", "yatra"), "success");
516 560 },
517 561 onError: (error: any) => {
518 562 showToast(
@@ -897,8 +941,26 @@
897 941 </div>
898 942 </div>
899 943 </CardContent>
900 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 + />
901 963 </div>
902 964 );
903 965 };
904 966