PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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
yatra / resources / js / components / availability / RecurringRules.tsx

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

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