PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 2.0.10 All 83 releases
yatra / resources / js / components / availability / RecurringRules.tsx

RecurringRules.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.15, at resources/js/components/availability/RecurringRules.tsx

968 lines 30.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Recurring Availability Rules Component
3 * Manage recurring patterns for trip availability
4 * Uses the same UI structure as Specific Dates table
5 */
6
7 import React, { useState, useMemo } from "react";
8 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
9 import {
10 Plus,
11 Edit,
12 Trash2,
13 Copy,
14 CheckCircle,
15 XCircle,
16 AlertCircle,
17 RefreshCw,
18 Search,
19 X,
20 } from "lucide-react";
21 import { __ } from "../../lib/i18n";
22 import { toDateValue } from "../../lib/dateFormat";
23 import { Button } from "../ui/button";
24 import { Input } from "../ui/input";
25 import { Select } from "../ui/select";
26 import { Card, CardContent } from "../ui/card";
27 import { Badge } from "../ui/badge";
28 import { apiClient } from "../../lib/api-client";
29 import { useToast } from "../ui/toast";
30 import { BulkActionToolbar, Table as SharedTable } from "../shared";
31 import { ConfirmationDialog } from "../ui/confirmation-dialog";
32 import { formatYatraMoney } from "../../lib/currency-display";
33
34 interface RecurringRule {
35 id: number;
36 trip_id: number;
37 name: string;
38 rule_type: "weekly" | "monthly" | "interval";
39 days_of_week?: string;
40 days_of_week_array?: number[];
41 week_of_month?: "first" | "second" | "third" | "fourth" | "last";
42 day_of_week?: number;
43 interval_days?: number;
44 start_date: string;
45 end_date?: string;
46 excluded_dates: string[];
47 original_price?: number;
48 sale_price?: number;
49 seats_total: number;
50 departure_time?: string;
51 arrival_time?: string;
52 from_location?: string;
53 to_location?: string;
54 cutoff_hours: number;
55 status: "active" | "inactive";
56 generated_count?: number;
57 pricing_type?: "regular" | "traveler_based";
58 traveler_pricing?: Array<{
59 category_id: number;
60 category_name?: string;
61 original_price: number;
62 sale_price?: number;
63 }>;
64 }
65
66 interface RecurringRulesProps {
67 tripId: number;
68 tripName?: string;
69 tripType?: "single_day" | "multi_day";
70 pricingType?: "regular" | "traveler_based";
71 onAddRule: () => void;
72 onEditRule: (id: number) => void;
73 }
74
75 const dayNames = [
76 { value: 0, label: "Sunday" },
77 { value: 1, label: "Monday" },
78 { value: 2, label: "Tuesday" },
79 { value: 3, label: "Wednesday" },
80 { value: 4, label: "Thursday" },
81 { value: 5, label: "Friday" },
82 { value: 6, label: "Saturday" },
83 ];
84
85 export const RecurringRules: React.FC<RecurringRulesProps> = ({
86 tripId,
87 tripName,
88 tripType = "multi_day",
89 pricingType = "regular",
90 onAddRule,
91 onEditRule,
92 }) => {
93 const isSingleDayTrip = tripType === "single_day";
94 const isTravelerBased = pricingType === "traveler_based";
95 const queryClient = useQueryClient();
96 const { showToast } = useToast();
97 const adminCurrency =
98 (typeof window !== "undefined" &&
99 (window as unknown as { yatraAdmin?: { currency?: string } }).yatraAdmin
100 ?.currency) ||
101 "USD";
102
103 // State management
104 const [searchTerm, setSearchTerm] = useState("");
105 const [statusFilter, setStatusFilter] = useState("all");
106 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
107 const [bulkAction, setBulkAction] = useState("");
108 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
109 const [visibleColumns, setVisibleColumns] = useState({
110 name: true,
111 pattern: true,
112 start_date: true,
113 end_date: true,
114 capacity: true,
115 generated: true,
116 price: true,
117 status: true,
118 });
119
120 // Confirmation dialogs
121 const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
122 const [deleteConfirm, setDeleteConfirm] = useState<{
123 isOpen: boolean;
124 rule: RecurringRule | null;
125 }>({
126 isOpen: false,
127 rule: null,
128 });
129 const [duplicateConfirm, setDuplicateConfirm] = useState<{
130 isOpen: boolean;
131 rule: RecurringRule | null;
132 }>({
133 isOpen: false,
134 rule: null,
135 });
136
137 // Format rule pattern for display
138 const formatRulePattern = (rule: RecurringRule): string => {
139 switch (rule.rule_type) {
140 case "weekly":
141 const days = (rule.days_of_week_array || [])
142 .map((d) => dayNames.find((dn) => dn.value === d)?.label.slice(0, 3))
143 .filter(Boolean)
144 .join(", ");
145 return `Every ${days}`;
146 case "monthly":
147 return `${rule.week_of_month || ""} ${dayNames.find((d) => d.value === rule.day_of_week)?.label || ""} of month`;
148 case "interval":
149 return `Every ${rule.interval_days} days`;
150 default:
151 return "Unknown pattern";
152 }
153 };
154
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.
162 const { data: countsData } = useQuery({
163 queryKey: ["recurring-availability", "counts", tripId],
164 queryFn: async () => {
165 const response = await apiClient.get("/recurring-availability/counts", {
166 params: {
167 trip_id: tripId,
168 },
169 });
170 return response || { all: 0, active: 0, inactive: 0 };
171 },
172 enabled: !!tripId,
173 staleTime: 0,
174 gcTime: 0,
175 });
176
177 const statusCounts = countsData || { all: 0, active: 0, inactive: 0 };
178
179 // Fetch recurring rules (no caching, always fresh data)
180 const { data: rulesData, isLoading } = useQuery({
181 queryKey: ["recurring-availability", tripId],
182 queryFn: async () => {
183 const response = await apiClient.get("/recurring-availability", {
184 params: {
185 trip_id: tripId,
186 },
187 });
188 return {
189 rules: (response?.data || []) as RecurringRule[],
190 total: response?.total || 0,
191 };
192 },
193 enabled: !!tripId,
194 staleTime: 0, // Always fetch fresh data
195 gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions)
196 });
197
198 // eslint-disable-next-line react-hooks/exhaustive-deps
199 const allRules = rulesData?.rules || [];
200
201 // Filter rules based on status and search
202 const rules = useMemo(() => {
203 let filtered = allRules;
204
205 // Filter by status
206 if (statusFilter !== "all") {
207 filtered = filtered.filter(
208 (r: RecurringRule) => r.status === statusFilter,
209 );
210 }
211
212 // Filter by search term
213 if (searchTerm) {
214 const search = searchTerm.toLowerCase();
215 filtered = filtered.filter(
216 (r: RecurringRule) =>
217 (r.name && r.name.toLowerCase().includes(search)) ||
218 formatRulePattern(r).toLowerCase().includes(search),
219 );
220 }
221
222 return filtered;
223 }, [allRules, statusFilter, searchTerm]);
224
225 // Delete mutation
226 const deleteMutation = useMutation({
227 mutationFn: async (id: number) => {
228 return await apiClient.delete(`/recurring-availability/${id}`);
229 },
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.
235 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
236 showToast(__("Recurring rule deleted successfully", "yatra"), "success");
237 setDeleteConfirm({ isOpen: false, rule: null });
238 setSelectedIds([]);
239 },
240 onError: (error: any) => {
241 showToast(
242 error?.message || __("Failed to delete rule", "yatra"),
243 "error",
244 );
245 },
246 });
247
248 // Bulk delete mutation
249 const bulkDeleteMutation = useMutation({
250 mutationFn: async (ids: string[]) => {
251 await Promise.all(
252 ids.map((id) => apiClient.delete(`/recurring-availability/${id}`)),
253 );
254 },
255 onSuccess: () => {
256 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
257 showToast(__("Rules deleted successfully", "yatra"), "success");
258 setSelectedIds([]);
259 },
260 onError: (error: any) => {
261 showToast(
262 error?.message || __("Failed to delete rules", "yatra"),
263 "error",
264 );
265 },
266 });
267
268 // Duplicate rule mutation
269 const duplicateMutation = useMutation({
270 mutationFn: async (id: number) => {
271 const response = await apiClient.post(
272 `/recurring-availability/${id}/duplicate`,
273 );
274 return response;
275 },
276 onSuccess: () => {
277 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
278 showToast(__("Rule duplicated successfully", "yatra"), "success");
279 setDuplicateConfirm({ isOpen: false, rule: null });
280 },
281 onError: (error: any) => {
282 showToast(
283 error?.message || __("Failed to duplicate rule", "yatra"),
284 "error",
285 );
286 },
287 });
288
289 // Handle bulk actions
290 const handleBulkApply = () => {
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");
297 return;
298 }
299
300 if (!bulkAction) {
301 showToast(__("Please choose an action to apply.", "yatra"), "warning");
302 return;
303 }
304
305 switch (bulkAction) {
306 case "delete":
307 // Confirmed through the shared dialog, matching the single-rule delete
308 // and duplicate actions in this same component.
309 setBulkDeleteConfirm(true);
310 break;
311 }
312
313 setBulkAction("");
314 };
315
316 // Toggle column visibility
317 const toggleColumn = (key: keyof typeof visibleColumns) => {
318 setVisibleColumns((prev) => ({
319 ...prev,
320 [key]: !prev[key],
321 }));
322 };
323
324 // Format date
325 const formatDate = (dateString: string | null | undefined): string => {
326 if (!dateString) return "--";
327 try {
328 const date = toDateValue(dateString);
329 return date.toLocaleDateString("en-US", {
330 year: "numeric",
331 month: "short",
332 day: "numeric",
333 });
334 } catch {
335 return dateString;
336 }
337 };
338
339 // Get status badge
340 const getStatusBadge = (status: string) => {
341 const statusConfig: Record<
342 string,
343 { label: string; className: string; icon: React.ReactNode }
344 > = {
345 active: {
346 label: __("Active", "yatra"),
347 className:
348 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
349 icon: <CheckCircle className="w-3 h-3" />,
350 },
351 inactive: {
352 label: __("Inactive", "yatra"),
353 className:
354 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
355 icon: <XCircle className="w-3 h-3" />,
356 },
357 };
358
359 const config = statusConfig[status] || statusConfig.inactive;
360 return (
361 <Badge className={`${config.className} flex items-center gap-1`}>
362 {config.icon}
363 {config.label}
364 </Badge>
365 );
366 };
367
368 // Define table columns (matching Specific Dates structure)
369 const tableColumns = useMemo(() => {
370 const cols = [];
371
372 if (visibleColumns.name) {
373 cols.push({
374 key: "name",
375 label: __("Rule Name", "yatra"),
376 visible: visibleColumns.name,
377 render: (rule: RecurringRule) => (
378 <div className="flex flex-col">
379 <button
380 onClick={() => onEditRule(rule.id)}
381 className="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-left hover:underline"
382 >
383 {rule.name || formatRulePattern(rule)}
384 </button>
385 <span className="text-xs text-gray-500 dark:text-gray-400">
386 {rule.rule_type === "weekly"
387 ? __("Weekly", "yatra")
388 : rule.rule_type === "monthly"
389 ? __("Monthly", "yatra")
390 : __("Interval", "yatra")}
391 </span>
392 </div>
393 ),
394 });
395 }
396
397 if (visibleColumns.pattern) {
398 cols.push({
399 key: "pattern",
400 label: __("Pattern", "yatra"),
401 visible: visibleColumns.pattern,
402 render: (rule: RecurringRule) => (
403 <div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
404 <RefreshCw className="w-3 h-3" />
405 <span>{formatRulePattern(rule)}</span>
406 </div>
407 ),
408 });
409 }
410
411 if (visibleColumns.start_date) {
412 cols.push({
413 key: "start_date",
414 label: __("Start Date", "yatra"),
415 visible: visibleColumns.start_date,
416 render: (rule: RecurringRule) => (
417 <div className="text-sm text-gray-900 dark:text-white">
418 {formatDate(rule.start_date)}
419 </div>
420 ),
421 });
422 }
423
424 if (visibleColumns.end_date) {
425 cols.push({
426 key: "end_date",
427 label: __("End Date", "yatra"),
428 visible: visibleColumns.end_date,
429 render: (rule: RecurringRule) => (
430 <div className="text-sm text-gray-500 dark:text-gray-400">
431 {rule.end_date ? formatDate(rule.end_date) : __("Ongoing", "yatra")}
432 </div>
433 ),
434 });
435 }
436
437 if (visibleColumns.capacity) {
438 cols.push({
439 key: "capacity",
440 label: __("Capacity", "yatra"),
441 visible: visibleColumns.capacity,
442 render: (rule: RecurringRule) => (
443 <div className="flex flex-col items-center">
444 <span className="text-sm font-medium text-gray-900 dark:text-white">
445 {rule.seats_total || 0}
446 </span>
447 <span className="text-xs text-gray-500 dark:text-gray-400">
448 {__("total", "yatra")}
449 </span>
450 </div>
451 ),
452 });
453 }
454
455 if (visibleColumns.generated) {
456 cols.push({
457 key: "generated",
458 label: __("Generated", "yatra"),
459 visible: visibleColumns.generated,
460 render: (rule: RecurringRule) => (
461 <div className="flex flex-col items-center">
462 <span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
463 {rule.generated_count || 0}
464 </span>
465 <span className="text-xs text-gray-500 dark:text-gray-400">
466 {__("dates", "yatra")}
467 </span>
468 </div>
469 ),
470 });
471 }
472
473 if (visibleColumns.price) {
474 cols.push({
475 key: "price",
476 label: __("Price", "yatra"),
477 visible: visibleColumns.price,
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 },
522 });
523 }
524
525 if (visibleColumns.status) {
526 cols.push({
527 key: "status",
528 label: __("Status", "yatra"),
529 visible: visibleColumns.status,
530 render: (rule: RecurringRule) => getStatusBadge(rule.status),
531 });
532 }
533
534 return cols;
535 // eslint-disable-next-line react-hooks/exhaustive-deps
536 }, [
537 visibleColumns,
538 formatRulePattern,
539 formatDate,
540 getStatusBadge,
541 adminCurrency,
542 ]);
543
544 // Status toggle mutation
545 const toggleStatusMutation = useMutation({
546 mutationFn: async ({
547 id,
548 status,
549 }: {
550 id: number;
551 status: "active" | "inactive";
552 }) => {
553 return await apiClient.put(`/recurring-availability/${id}`, { status });
554 },
555 onSuccess: () => {
556 // Single prefix invalidation refreshes both the rules list
557 // and the status badge counts (now keyed under the same prefix).
558 queryClient.invalidateQueries({ queryKey: ["recurring-availability"] });
559 showToast(__("Rule status updated successfully", "yatra"), "success");
560 },
561 onError: (error: any) => {
562 showToast(
563 error?.message || __("Failed to update rule status", "yatra"),
564 "error",
565 );
566 },
567 });
568
569 // Table actions (matching Specific Dates structure)
570 const tableActions = useMemo(
571 () => [
572 {
573 key: "edit",
574 label: __("Edit", "yatra"),
575 icon: <Edit className="w-4 h-4" />,
576 onClick: (rule: RecurringRule) => onEditRule(rule.id),
577 },
578 {
579 key: "set-inactive",
580 label: __("Set Inactive", "yatra"),
581 icon: <XCircle className="w-4 h-4" />,
582 onClick: (rule: RecurringRule) => {
583 toggleStatusMutation.mutate({ id: rule.id, status: "inactive" });
584 },
585 condition: (rule: RecurringRule) => rule.status === "active",
586 },
587 {
588 key: "set-active",
589 label: __("Set Active", "yatra"),
590 icon: <CheckCircle className="w-4 h-4" />,
591 onClick: (rule: RecurringRule) => {
592 toggleStatusMutation.mutate({ id: rule.id, status: "active" });
593 },
594 condition: (rule: RecurringRule) => rule.status === "inactive",
595 },
596 {
597 key: "duplicate",
598 label: __("Duplicate", "yatra"),
599 icon: <Copy className="w-4 h-4" />,
600 onClick: (rule: RecurringRule) =>
601 setDuplicateConfirm({ isOpen: true, rule }),
602 },
603 {
604 key: "delete",
605 label: __("Delete", "yatra"),
606 icon: <Trash2 className="w-4 h-4" />,
607 onClick: (rule: RecurringRule) =>
608 setDeleteConfirm({ isOpen: true, rule }),
609 variant: "destructive" as const,
610 },
611 ],
612 [toggleStatusMutation, onEditRule],
613 );
614
615 return (
616 <div className="space-y-6">
617 {/* Confirmation Dialogs */}
618 <ConfirmationDialog
619 isOpen={deleteConfirm.isOpen}
620 onClose={() => setDeleteConfirm({ isOpen: false, rule: null })}
621 onConfirm={() => {
622 if (deleteConfirm.rule) {
623 deleteMutation.mutate(deleteConfirm.rule.id);
624 }
625 }}
626 title={__("Delete Recurring Rule", "yatra")}
627 message={
628 deleteConfirm.rule
629 ? __(
630 'Are you sure you want to delete the rule "{name}"? This action cannot be undone.',
631 "yatra",
632 ).replace(
633 "{name}",
634 deleteConfirm.rule.name ||
635 formatRulePattern(deleteConfirm.rule),
636 )
637 : __(
638 "Are you sure you want to delete this rule? This action cannot be undone.",
639 "yatra",
640 )
641 }
642 confirmText={__("Delete", "yatra")}
643 cancelText={__("Cancel", "yatra")}
644 variant="danger"
645 isLoading={deleteMutation.isPending}
646 />
647
648 <ConfirmationDialog
649 isOpen={duplicateConfirm.isOpen}
650 onClose={() => setDuplicateConfirm({ isOpen: false, rule: null })}
651 onConfirm={() => {
652 if (duplicateConfirm.rule) {
653 duplicateMutation.mutate(duplicateConfirm.rule.id);
654 }
655 }}
656 title={__("Duplicate Recurring Rule", "yatra")}
657 message={__(
658 "This will create a copy of this rule. You can edit it after creation.",
659 "yatra",
660 )}
661 confirmText={__("Duplicate", "yatra")}
662 cancelText={__("Cancel", "yatra")}
663 isLoading={duplicateMutation.isPending}
664 />
665
666 {/* Header */}
667 <div className="flex items-center justify-between">
668 <div className="flex items-center gap-3">
669 <h3 className="text-lg font-medium text-gray-900 dark:text-white">
670 {__("Recurring Rules", "yatra")}
671 </h3>
672 <Badge
673 className={
674 isSingleDayTrip
675 ? "bg-purple-100 text-purple-700 dark:bg-purple-900/20 dark:text-purple-400"
676 : "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-400"
677 }
678 >
679 {isSingleDayTrip
680 ? __("Single-Day Trip", "yatra")
681 : __("Multi-Day Trip", "yatra")}
682 </Badge>
683 <Badge
684 className={
685 isTravelerBased
686 ? "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400"
687 : "bg-gray-100 text-gray-700 dark:bg-gray-900/20 dark:text-gray-400"
688 }
689 >
690 {isTravelerBased
691 ? __("Traveler-Based Pricing", "yatra")
692 : __("Regular Pricing", "yatra")}
693 </Badge>
694 </div>
695 <Button variant="outline" onClick={onAddRule}>
696 <Plus className="w-4 h-4 mr-2" />
697 {isSingleDayTrip
698 ? __("Add Time Slots Rule", "yatra")
699 : __("Add Recurring Rule", "yatra")}
700 </Button>
701 </div>
702
703 <div className="text-sm text-gray-500 dark:text-gray-400">
704 {isSingleDayTrip
705 ? __(
706 "Create recurring time slots for your single-day trip (supports multiple time slots per day)",
707 "yatra",
708 )
709 : __(
710 "Automatically generate availability dates based on patterns",
711 "yatra",
712 )}
713 </div>
714
715 {/* Filters - Matching Specific Dates */}
716 <Card>
717 <CardContent className="pt-6">
718 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
719 <div>
720 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
721 {__("Search", "yatra")}
722 </label>
723 <div className="relative">
724 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
725 <Input
726 type="text"
727 value={searchTerm}
728 onChange={(e) => setSearchTerm(e.target.value)}
729 placeholder={__("Search rules...", "yatra")}
730 className="pl-10"
731 />
732 </div>
733 </div>
734 <div>
735 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
736 {__("Status", "yatra")}
737 </label>
738 <Select
739 value={statusFilter}
740 onChange={(e) => setStatusFilter(e.target.value)}
741 >
742 <option value="all">{__("All Status", "yatra")}</option>
743 <option value="active">{__("Active", "yatra")}</option>
744 <option value="inactive">{__("Inactive", "yatra")}</option>
745 </Select>
746 </div>
747 <div className="flex items-end">
748 <Button
749 variant="outline"
750 onClick={() => {
751 setSearchTerm("");
752 setStatusFilter("all");
753 }}
754 className="w-full"
755 >
756 <X className="w-4 h-4 mr-2" />
757 {__("Clear Filters", "yatra")}
758 </Button>
759 </div>
760 </div>
761 </CardContent>
762 </Card>
763
764 {/* Bulk Action Toolbar - Matching Specific Dates */}
765 <BulkActionToolbar
766 selectedIds={selectedIds}
767 bulkAction={bulkAction}
768 setBulkAction={setBulkAction}
769 onApply={handleBulkApply}
770 onClearSelection={() => setSelectedIds([])}
771 statusFilter={statusFilter}
772 setStatusFilter={setStatusFilter}
773 statusOptions={[
774 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
775 {
776 key: "active",
777 label: __("Active", "yatra"),
778 count: statusCounts.active,
779 },
780 {
781 key: "inactive",
782 label: __("Inactive", "yatra"),
783 count: statusCounts.inactive,
784 },
785 ]}
786 showColumnsDropdown={showColumnsDropdown}
787 setShowColumnsDropdown={setShowColumnsDropdown}
788 columnOptions={[
789 {
790 key: "name",
791 label: __("Rule Name", "yatra"),
792 visible: visibleColumns.name,
793 },
794 {
795 key: "pattern",
796 label: __("Pattern", "yatra"),
797 visible: visibleColumns.pattern,
798 },
799 {
800 key: "start_date",
801 label: __("Start Date", "yatra"),
802 visible: visibleColumns.start_date,
803 },
804 {
805 key: "end_date",
806 label: __("End Date", "yatra"),
807 visible: visibleColumns.end_date,
808 },
809 {
810 key: "capacity",
811 label: __("Capacity", "yatra"),
812 visible: visibleColumns.capacity,
813 },
814 {
815 key: "generated",
816 label: __("Generated", "yatra"),
817 visible: visibleColumns.generated,
818 },
819 {
820 key: "price",
821 label: __("Price", "yatra"),
822 visible: visibleColumns.price,
823 },
824 {
825 key: "status",
826 label: __("Status", "yatra"),
827 visible: visibleColumns.status,
828 },
829 ]}
830 onToggleColumn={(columnKey: string) =>
831 toggleColumn(columnKey as keyof typeof visibleColumns)
832 }
833 bulkMutationPending={bulkDeleteMutation.isPending}
834 totalItems={rules.length}
835 bulkActionOptions={[{ value: "delete", label: __("Delete", "yatra") }]}
836 />
837
838 {/* Recurring Rules Section - Matching Specific Dates */}
839 <Card>
840 <CardContent className="pt-6">
841 {/* Section Header */}
842 <div className="mb-6">
843 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
844 {__("Recurring Rules", "yatra")}
845 </h3>
846 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
847 {tripName && (
848 <>
849 {__("Managing availability for", "yatra")}{" "}
850 <strong>{tripName}</strong>
851 {rules.length > 0 && (
852 <span className="ml-2">
853 ({rules.length}{" "}
854 {rules.length === 1
855 ? __("rule", "yatra")
856 : __("rules", "yatra")}
857 )
858 </span>
859 )}
860 </>
861 )}
862 </p>
863 </div>
864
865 <SharedTable
866 data={rules}
867 columns={tableColumns}
868 actions={tableActions}
869 isLoading={isLoading}
870 isError={false}
871 selectedItemIds={selectedIds}
872 onSelectItem={(id, checked) => {
873 if (checked) {
874 setSelectedIds([...selectedIds, id]);
875 } else {
876 setSelectedIds(selectedIds.filter((sid) => sid !== id));
877 }
878 }}
879 onSelectAll={(checked) => {
880 if (checked) {
881 setSelectedIds(rules.map((r: RecurringRule) => r.id));
882 } else {
883 setSelectedIds([]);
884 }
885 }}
886 isAllSelected={
887 selectedIds.length === rules.length && rules.length > 0
888 }
889 getItemId={(rule) => rule.id}
890 emptyText={__("No recurring rules found", "yatra")}
891 emptyDescription={__(
892 "Create your first recurring rule to get started",
893 "yatra",
894 )}
895 onCreateClick={onAddRule}
896 skeletonRows={5}
897 capability="yatra_view_trips"
898 />
899 </CardContent>
900 </Card>
901
902 {/* Info Box */}
903 <Card className="bg-blue-50 dark:bg-blue-900/10 border-blue-200 dark:border-blue-800">
904 <CardContent className="py-4">
905 <div className="flex items-start gap-3">
906 <AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
907 <div>
908 <h5 className="font-medium text-blue-900 dark:text-blue-200 mb-1">
909 {__("How Recurring Rules Work", "yatra")}
910 </h5>
911 <ul className="text-sm text-blue-800 dark:text-blue-300 space-y-1">
912 <li>
913 {" "}
914 {__(
915 "Dates are generated automatically based on your patterns",
916 "yatra",
917 )}
918 </li>
919 <li>
920 {" "}
921 {__(
922 "Specific dates (added manually) take priority over generated dates",
923 "yatra",
924 )}
925 </li>
926 <li>
927 {" "}
928 {__(
929 "Use excluded dates to skip holidays or special occasions",
930 "yatra",
931 )}
932 </li>
933 <li>
934 {" "}
935 {__(
936 "Bookings for generated dates create specific availability entries",
937 "yatra",
938 )}
939 </li>
940 </ul>
941 </div>
942 </div>
943 </CardContent>
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 />
963 </div>
964 );
965 };
966
967 export default RecurringRules;
968