PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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.10, at resources/js/components/availability/RecurringRules.tsx

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