/** * Recurring Availability Rules Component * Manage recurring patterns for trip availability * Uses the same UI structure as Specific Dates table */ import React, { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Edit, Trash2, Copy, CheckCircle, XCircle, AlertCircle, RefreshCw, Search, X, } from "lucide-react"; import { __ } from "../../lib/i18n"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Select } from "../ui/select"; import { Card, CardContent } from "../ui/card"; import { Badge } from "../ui/badge"; import { apiClient } from "../../lib/api-client"; import { useToast } from "../ui/toast"; import { BulkActionToolbar, Table as SharedTable } from "../shared"; import { ConfirmationDialog } from "../ui/confirmation-dialog"; interface RecurringRule { id: number; trip_id: number; name: string; rule_type: "weekly" | "monthly" | "interval"; days_of_week?: string; days_of_week_array?: number[]; week_of_month?: "first" | "second" | "third" | "fourth" | "last"; day_of_week?: number; interval_days?: number; start_date: string; end_date?: string; excluded_dates: string[]; original_price?: number; sale_price?: number; seats_total: number; departure_time?: string; arrival_time?: string; from_location?: string; to_location?: string; cutoff_hours: number; status: "active" | "inactive"; generated_count?: number; pricing_type?: "regular" | "traveler_based"; traveler_pricing?: Array<{ category_id: number; category_name?: string; original_price: number; sale_price?: number; }>; } interface RecurringRulesProps { tripId: number; tripName?: string; tripType?: "single_day" | "multi_day"; pricingType?: "regular" | "traveler_based"; onAddRule: () => void; onEditRule: (id: number) => void; } const dayNames = [ { value: 0, label: "Sunday" }, { value: 1, label: "Monday" }, { value: 2, label: "Tuesday" }, { value: 3, label: "Wednesday" }, { value: 4, label: "Thursday" }, { value: 5, label: "Friday" }, { value: 6, label: "Saturday" }, ]; export const RecurringRules: React.FC = ({ tripId, tripName, tripType = "multi_day", pricingType = "regular", onAddRule, onEditRule, }) => { const isSingleDayTrip = tripType === "single_day"; const isTravelerBased = pricingType === "traveler_based"; const queryClient = useQueryClient(); const { showToast } = useToast(); // State management const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); const [bulkAction, setBulkAction] = useState(""); const [showColumnsDropdown, setShowColumnsDropdown] = useState(false); const [visibleColumns, setVisibleColumns] = useState({ name: true, pattern: true, start_date: true, end_date: true, capacity: true, generated: true, price: true, status: true, }); // Confirmation dialogs const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: RecurringRule | null; }>({ isOpen: false, rule: null, }); const [duplicateConfirm, setDuplicateConfirm] = useState<{ isOpen: boolean; rule: RecurringRule | null; }>({ isOpen: false, rule: null, }); // Format rule pattern for display const formatRulePattern = (rule: RecurringRule): string => { switch (rule.rule_type) { case "weekly": const days = (rule.days_of_week_array || []) .map((d) => dayNames.find((dn) => dn.value === d)?.label.slice(0, 3)) .filter(Boolean) .join(", "); return `Every ${days}`; case "monthly": return `${rule.week_of_month || ""} ${dayNames.find((d) => d.value === rule.day_of_week)?.label || ""} of month`; case "interval": return `Every ${rule.interval_days} days`; default: return "Unknown pattern"; } }; // Fetch status counts from API endpoint const { data: countsData } = useQuery({ queryKey: ["recurring-availability-counts", tripId], queryFn: async () => { const response = await apiClient.get("/recurring-availability/counts", { params: { trip_id: tripId, }, }); return response || { all: 0, active: 0, inactive: 0 }; }, enabled: !!tripId, staleTime: 0, gcTime: 0, }); const statusCounts = countsData || { all: 0, active: 0, inactive: 0 }; // Fetch recurring rules (no caching, always fresh data) const { data: rulesData, isLoading } = useQuery({ queryKey: ["recurring-availability", tripId], queryFn: async () => { const response = await apiClient.get("/recurring-availability", { params: { trip_id: tripId, }, }); return { rules: (response?.data || []) as RecurringRule[], total: response?.total || 0, }; }, enabled: !!tripId, staleTime: 0, // Always fetch fresh data gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions) }); const allRules = rulesData?.rules || []; // Filter rules based on status and search const rules = useMemo(() => { let filtered = allRules; // Filter by status if (statusFilter !== "all") { filtered = filtered.filter( (r: RecurringRule) => r.status === statusFilter, ); } // Filter by search term if (searchTerm) { const search = searchTerm.toLowerCase(); filtered = filtered.filter( (r: RecurringRule) => (r.name && r.name.toLowerCase().includes(search)) || formatRulePattern(r).toLowerCase().includes(search), ); } return filtered; }, [allRules, statusFilter, searchTerm]); // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { return await apiClient.delete(`/recurring-availability/${id}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); showToast(__("Recurring rule deleted successfully", "yatra"), "success"); setDeleteConfirm({ isOpen: false, rule: null }); setSelectedIds([]); }, onError: (error: any) => { showToast( error?.message || __("Failed to delete rule", "yatra"), "error", ); }, }); // Bulk delete mutation const bulkDeleteMutation = useMutation({ mutationFn: async (ids: string[]) => { await Promise.all( ids.map((id) => apiClient.delete(`/recurring-availability/${id}`)), ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); showToast(__("Rules deleted successfully", "yatra"), "success"); setSelectedIds([]); }, onError: (error: any) => { showToast( error?.message || __("Failed to delete rules", "yatra"), "error", ); }, }); // Duplicate rule mutation const duplicateMutation = useMutation({ mutationFn: async (id: number) => { const response = await apiClient.post( `/recurring-availability/${id}/duplicate`, ); return response; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); showToast(__("Rule duplicated successfully", "yatra"), "success"); setDuplicateConfirm({ isOpen: false, rule: null }); }, onError: (error: any) => { showToast( error?.message || __("Failed to duplicate rule", "yatra"), "error", ); }, }); // Handle bulk actions const handleBulkApply = () => { if (!bulkAction || selectedIds.length === 0) { showToast(__("Please select rules and an action", "yatra"), "warning"); return; } switch (bulkAction) { case "delete": if ( confirm( __( "Are you sure you want to delete {count} rule(s)?", "yatra", ).replace("{count}", selectedIds.length.toString()), ) ) { bulkDeleteMutation.mutate(selectedIds.map((id) => id.toString())); } break; } setBulkAction(""); }; // Toggle column visibility const toggleColumn = (key: keyof typeof visibleColumns) => { setVisibleColumns((prev) => ({ ...prev, [key]: !prev[key], })); }; // Format date const formatDate = (dateString: string | null | undefined): string => { if (!dateString) return "--"; try { const date = new Date(dateString); return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", }); } catch { return dateString; } }; // Get status badge const getStatusBadge = (status: string) => { const statusConfig: Record< string, { label: string; className: string; icon: React.ReactNode } > = { active: { label: __("Active", "yatra"), className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", icon: , }, inactive: { label: __("Inactive", "yatra"), className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", icon: , }, }; const config = statusConfig[status] || statusConfig.inactive; return ( {config.icon} {config.label} ); }; // Define table columns (matching Specific Dates structure) const tableColumns = useMemo(() => { const cols = []; if (visibleColumns.name) { cols.push({ key: "name", label: __("Rule Name", "yatra"), visible: visibleColumns.name, render: (rule: RecurringRule) => (
{rule.rule_type === "weekly" ? __("Weekly", "yatra") : rule.rule_type === "monthly" ? __("Monthly", "yatra") : __("Interval", "yatra")}
), }); } if (visibleColumns.pattern) { cols.push({ key: "pattern", label: __("Pattern", "yatra"), visible: visibleColumns.pattern, render: (rule: RecurringRule) => (
{formatRulePattern(rule)}
), }); } if (visibleColumns.start_date) { cols.push({ key: "start_date", label: __("Start Date", "yatra"), visible: visibleColumns.start_date, render: (rule: RecurringRule) => (
{formatDate(rule.start_date)}
), }); } if (visibleColumns.end_date) { cols.push({ key: "end_date", label: __("End Date", "yatra"), visible: visibleColumns.end_date, render: (rule: RecurringRule) => (
{rule.end_date ? formatDate(rule.end_date) : __("Ongoing", "yatra")}
), }); } if (visibleColumns.capacity) { cols.push({ key: "capacity", label: __("Capacity", "yatra"), visible: visibleColumns.capacity, render: (rule: RecurringRule) => (
{rule.seats_total || 0} {__("total", "yatra")}
), }); } if (visibleColumns.generated) { cols.push({ key: "generated", label: __("Generated", "yatra"), visible: visibleColumns.generated, render: (rule: RecurringRule) => (
{rule.generated_count || 0} {__("dates", "yatra")}
), }); } if (visibleColumns.price) { cols.push({ key: "price", label: __("Price", "yatra"), visible: visibleColumns.price, render: (rule: RecurringRule) => (
{rule.sale_price ? `$${rule.sale_price}` : rule.original_price ? `$${rule.original_price}` : "$0"}
), }); } if (visibleColumns.status) { cols.push({ key: "status", label: __("Status", "yatra"), visible: visibleColumns.status, render: (rule: RecurringRule) => getStatusBadge(rule.status), }); } return cols; }, [visibleColumns, formatRulePattern, formatDate, getStatusBadge]); // Status toggle mutation const toggleStatusMutation = useMutation({ mutationFn: async ({ id, status, }: { id: number; status: "active" | "inactive"; }) => { return await apiClient.put(`/recurring-availability/${id}`, { status }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recurring-availability"] }); queryClient.invalidateQueries({ queryKey: ["recurring-availability-counts"], }); showToast(__("Rule status updated successfully", "yatra"), "success"); }, onError: (error: any) => { showToast( error?.message || __("Failed to update rule status", "yatra"), "error", ); }, }); // Table actions (matching Specific Dates structure) const tableActions = useMemo( () => [ { key: "edit", label: __("Edit", "yatra"), icon: , onClick: (rule: RecurringRule) => onEditRule(rule.id), }, { key: "set-inactive", label: __("Set Inactive", "yatra"), icon: , onClick: (rule: RecurringRule) => { toggleStatusMutation.mutate({ id: rule.id, status: "inactive" }); }, condition: (rule: RecurringRule) => rule.status === "active", }, { key: "set-active", label: __("Set Active", "yatra"), icon: , onClick: (rule: RecurringRule) => { toggleStatusMutation.mutate({ id: rule.id, status: "active" }); }, condition: (rule: RecurringRule) => rule.status === "inactive", }, { key: "duplicate", label: __("Duplicate", "yatra"), icon: , onClick: (rule: RecurringRule) => setDuplicateConfirm({ isOpen: true, rule }), }, { key: "delete", label: __("Delete", "yatra"), icon: , onClick: (rule: RecurringRule) => setDeleteConfirm({ isOpen: true, rule }), variant: "destructive" as const, }, ], [toggleStatusMutation, onEditRule], ); return (
{/* Confirmation Dialogs */} setDeleteConfirm({ isOpen: false, rule: null })} onConfirm={() => { if (deleteConfirm.rule) { deleteMutation.mutate(deleteConfirm.rule.id); } }} title={__("Delete Recurring Rule", "yatra")} message={ deleteConfirm.rule ? __( 'Are you sure you want to delete the rule "{name}"? This action cannot be undone.', "yatra", ).replace( "{name}", deleteConfirm.rule.name || formatRulePattern(deleteConfirm.rule), ) : __( "Are you sure you want to delete this rule? This action cannot be undone.", "yatra", ) } confirmText={__("Delete", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteMutation.isPending} /> setDuplicateConfirm({ isOpen: false, rule: null })} onConfirm={() => { if (duplicateConfirm.rule) { duplicateMutation.mutate(duplicateConfirm.rule.id); } }} title={__("Duplicate Recurring Rule", "yatra")} message={__( "This will create a copy of this rule. You can edit it after creation.", "yatra", )} confirmText={__("Duplicate", "yatra")} cancelText={__("Cancel", "yatra")} isLoading={duplicateMutation.isPending} /> {/* Header */}

{__("Recurring Rules", "yatra")}

{isSingleDayTrip ? __("Single-Day Trip", "yatra") : __("Multi-Day Trip", "yatra")} {isTravelerBased ? __("Traveler-Based Pricing", "yatra") : __("Regular Pricing", "yatra")}
{isSingleDayTrip ? __( "Create recurring time slots for your single-day trip (supports multiple time slots per day)", "yatra", ) : __( "Automatically generate availability dates based on patterns", "yatra", )}
{/* Filters - Matching Specific Dates */}
setSearchTerm(e.target.value)} placeholder={__("Search rules...", "yatra")} className="pl-10" />
{/* Bulk Action Toolbar - Matching Specific Dates */} setSelectedIds([])} statusFilter={statusFilter} setStatusFilter={setStatusFilter} statusOptions={[ { key: "all", label: __("All", "yatra"), count: statusCounts.all }, { key: "active", label: __("Active", "yatra"), count: statusCounts.active, }, { key: "inactive", label: __("Inactive", "yatra"), count: statusCounts.inactive, }, ]} showColumnsDropdown={showColumnsDropdown} setShowColumnsDropdown={setShowColumnsDropdown} columnOptions={[ { key: "name", label: __("Rule Name", "yatra"), visible: visibleColumns.name, }, { key: "pattern", label: __("Pattern", "yatra"), visible: visibleColumns.pattern, }, { key: "start_date", label: __("Start Date", "yatra"), visible: visibleColumns.start_date, }, { key: "end_date", label: __("End Date", "yatra"), visible: visibleColumns.end_date, }, { key: "capacity", label: __("Capacity", "yatra"), visible: visibleColumns.capacity, }, { key: "generated", label: __("Generated", "yatra"), visible: visibleColumns.generated, }, { key: "price", label: __("Price", "yatra"), visible: visibleColumns.price, }, { key: "status", label: __("Status", "yatra"), visible: visibleColumns.status, }, ]} onToggleColumn={(columnKey: string) => toggleColumn(columnKey as keyof typeof visibleColumns) } bulkMutationPending={bulkDeleteMutation.isPending} totalItems={rules.length} bulkActionOptions={[{ value: "delete", label: __("Delete", "yatra") }]} /> {/* Recurring Rules Section - Matching Specific Dates */} {/* Section Header */}

{__("Recurring Rules", "yatra")}

{tripName && ( <> {__("Managing availability for", "yatra")}{" "} {tripName} {rules.length > 0 && ( ({rules.length}{" "} {rules.length === 1 ? __("rule", "yatra") : __("rules", "yatra")} ) )} )}

{ if (checked) { setSelectedIds([...selectedIds, id]); } else { setSelectedIds(selectedIds.filter((sid) => sid !== id)); } }} onSelectAll={(checked) => { if (checked) { setSelectedIds(rules.map((r: RecurringRule) => r.id)); } else { setSelectedIds([]); } }} isAllSelected={ selectedIds.length === rules.length && rules.length > 0 } getItemId={(rule) => rule.id} emptyText={__("No recurring rules found", "yatra")} emptyDescription={__( "Create your first recurring rule to get started", "yatra", )} onCreateClick={onAddRule} skeletonRows={5} capability="yatra_view_trips" />
{/* Info Box */}
{__("How Recurring Rules Work", "yatra")}
  • •{" "} {__( "Dates are generated automatically based on your patterns", "yatra", )}
  • •{" "} {__( "Specific dates (added manually) take priority over generated dates", "yatra", )}
  • •{" "} {__( "Use excluded dates to skip holidays or special occasions", "yatra", )}
  • •{" "} {__( "Bookings for generated dates create specific availability entries", "yatra", )}
); }; export default RecurringRules;