| 1 |
import React, { useEffect, useMemo, useState } from "react"; |
| 2 |
import { useQuery } from "@tanstack/react-query"; |
| 3 |
import { __ } from "../../lib/i18n"; |
| 4 |
import { apiClient } from "../../lib/api-client"; |
| 5 |
import { Select } from "../ui/select"; |
| 6 |
import { Input } from "../ui/input"; |
| 7 |
import { Button } from "../ui/button"; |
| 8 |
import { Loader2, Search, X } from "lucide-react"; |
| 9 |
|
| 10 |
type ApplicableValue = "all" | "specific_trips"; |
| 11 |
|
| 12 |
interface ApplicableTripSelectorProps { |
| 13 |
value: ApplicableValue; |
| 14 |
onValueChange: (value: ApplicableValue) => void; |
| 15 |
selectedTripIds: number[]; |
| 16 |
onTripIdsChange: (ids: number[]) => void; |
| 17 |
description?: string; |
| 18 |
placeholder?: string; |
| 19 |
helperText?: string; |
| 20 |
disabled?: boolean; |
| 21 |
} |
| 22 |
|
| 23 |
interface TripOption { |
| 24 |
id: number; |
| 25 |
label: string; |
| 26 |
} |
| 27 |
|
| 28 |
const toTripOption = (item: any): TripOption => { |
| 29 |
const id = Number(item?.id || 0); |
| 30 |
const label = item?.title || item?.name || item?.slug || `Trip #${id}`; |
| 31 |
return { id, label }; |
| 32 |
}; |
| 33 |
|
| 34 |
export const ApplicableTripSelector: React.FC<ApplicableTripSelectorProps> = ({ |
| 35 |
value, |
| 36 |
onValueChange, |
| 37 |
selectedTripIds, |
| 38 |
onTripIdsChange, |
| 39 |
description = __( |
| 40 |
"Control whether this applies to all trips or only selected trips.", |
| 41 |
), |
| 42 |
placeholder = __("Click to select trips..."), |
| 43 |
helperText = __("Only the selected trips will be eligible."), |
| 44 |
disabled = false, |
| 45 |
}) => { |
| 46 |
const [showDropdown, setShowDropdown] = useState(false); |
| 47 |
const [searchTerm, setSearchTerm] = useState(""); |
| 48 |
const [debouncedSearch, setDebouncedSearch] = useState(""); |
| 49 |
|
| 50 |
useEffect(() => { |
| 51 |
const timer = setTimeout(() => { |
| 52 |
setDebouncedSearch(searchTerm); |
| 53 |
}, 300); |
| 54 |
return () => clearTimeout(timer); |
| 55 |
}, [searchTerm]); |
| 56 |
|
| 57 |
const { |
| 58 |
data: trips = [], |
| 59 |
isLoading, |
| 60 |
isFetching, |
| 61 |
} = useQuery({ |
| 62 |
queryKey: ["applicable-trips", debouncedSearch], |
| 63 |
queryFn: async () => { |
| 64 |
const params: Record<string, any> = { per_page: 100 }; |
| 65 |
if (debouncedSearch.trim()) { |
| 66 |
params.search = debouncedSearch.trim(); |
| 67 |
} |
| 68 |
const response = await apiClient.get("/trips", { params }); |
| 69 |
const items = response?.data?.data || response?.data || response || []; |
| 70 |
return Array.isArray(items) ? items.map(toTripOption) : []; |
| 71 |
}, |
| 72 |
enabled: value === "specific_trips", |
| 73 |
staleTime: 5 * 60 * 1000, |
| 74 |
}); |
| 75 |
|
| 76 |
// Fetch selected trip details using React Query |
| 77 |
const { data: selectedTripsData = [] } = useQuery({ |
| 78 |
queryKey: ["selected-trips", selectedTripIds], |
| 79 |
queryFn: async () => { |
| 80 |
if (selectedTripIds.length === 0) return []; |
| 81 |
|
| 82 |
// Try using the list endpoint with include parameter instead of individual calls |
| 83 |
try { |
| 84 |
const response = await apiClient.get("/trips", { |
| 85 |
params: { |
| 86 |
per_page: 100, |
| 87 |
include: selectedTripIds.join(","), // Try to include specific IDs |
| 88 |
}, |
| 89 |
}); |
| 90 |
|
| 91 |
const items = response?.data?.data || response?.data || response || []; |
| 92 |
const trips = Array.isArray(items) ? items.map(toTripOption) : []; |
| 93 |
return trips; |
| 94 |
} catch (error) { |
| 95 |
console.warn("List API failed, trying individual calls:", error); |
| 96 |
|
| 97 |
// Fallback to individual calls |
| 98 |
const results = await Promise.all( |
| 99 |
selectedTripIds.map(async (id) => { |
| 100 |
try { |
| 101 |
const response = await apiClient.get(`/trips/${id}`); |
| 102 |
if (response?.data) { |
| 103 |
const trip = toTripOption(response.data); |
| 104 |
return trip; |
| 105 |
} |
| 106 |
} catch (error) { |
| 107 |
console.warn(`Failed to fetch trip ${id}:`, error); |
| 108 |
} |
| 109 |
return { id, label: `Trip #${id}` }; |
| 110 |
}), |
| 111 |
); |
| 112 |
return results; |
| 113 |
} |
| 114 |
}, |
| 115 |
enabled: selectedTripIds.length > 0, |
| 116 |
staleTime: 10 * 60 * 1000, // Cache for 10 minutes |
| 117 |
}); |
| 118 |
|
| 119 |
const tripLookup = useMemo(() => { |
| 120 |
const map = new Map<number, string>(); |
| 121 |
// Add search results |
| 122 |
trips.forEach((trip) => { |
| 123 |
map.set(trip.id, trip.label); |
| 124 |
}); |
| 125 |
// Add selected trips data |
| 126 |
selectedTripsData.forEach((trip) => { |
| 127 |
map.set(trip.id, trip.label); |
| 128 |
}); |
| 129 |
return map; |
| 130 |
}, [trips, selectedTripsData]); |
| 131 |
|
| 132 |
const selectedChips = useMemo(() => { |
| 133 |
const chips = selectedTripIds.map((id) => ({ |
| 134 |
id, |
| 135 |
label: tripLookup.get(id) || `Trip #${id}`, |
| 136 |
})); |
| 137 |
return chips; |
| 138 |
}, [selectedTripIds, tripLookup]); |
| 139 |
|
| 140 |
const toggleTrip = (tripId: number, checked: boolean) => { |
| 141 |
if (checked) { |
| 142 |
if (!selectedTripIds.includes(tripId)) { |
| 143 |
onTripIdsChange([...selectedTripIds, tripId]); |
| 144 |
} |
| 145 |
} else { |
| 146 |
onTripIdsChange(selectedTripIds.filter((id) => id !== tripId)); |
| 147 |
} |
| 148 |
}; |
| 149 |
|
| 150 |
const clearTrips = (e?: React.MouseEvent) => { |
| 151 |
e?.stopPropagation(); |
| 152 |
onTripIdsChange([]); |
| 153 |
}; |
| 154 |
|
| 155 |
const handleModeChange = (newValue: ApplicableValue) => { |
| 156 |
onValueChange(newValue); |
| 157 |
if (newValue === "all" && selectedTripIds.length > 0) { |
| 158 |
onTripIdsChange([]); |
| 159 |
} |
| 160 |
}; |
| 161 |
|
| 162 |
return ( |
| 163 |
<div className="space-y-3"> |
| 164 |
<div> |
| 165 |
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2"> |
| 166 |
{description} |
| 167 |
</p> |
| 168 |
<Select |
| 169 |
value={value} |
| 170 |
onChange={(e) => handleModeChange(e.target.value as ApplicableValue)} |
| 171 |
disabled={disabled} |
| 172 |
> |
| 173 |
<option value="all">{__("All Trips")}</option> |
| 174 |
<option value="specific_trips">{__("Specific Trips")}</option> |
| 175 |
</Select> |
| 176 |
</div> |
| 177 |
|
| 178 |
{value === "specific_trips" && ( |
| 179 |
<div className="space-y-2 relative"> |
| 180 |
<div |
| 181 |
className={`border border-gray-300 dark:border-gray-600 rounded-lg p-2 cursor-pointer hover:border-gray-400 dark:hover:border-gray-500 bg-white dark:bg-gray-800 ${disabled ? "opacity-70 cursor-not-allowed" : ""}`} |
| 182 |
onClick={() => !disabled && setShowDropdown((prev) => !prev)} |
| 183 |
> |
| 184 |
{selectedChips.length > 0 ? ( |
| 185 |
<div className="flex flex-wrap items-center gap-1.5"> |
| 186 |
{selectedChips.map((chip) => ( |
| 187 |
<span |
| 188 |
key={chip.id} |
| 189 |
className="inline-flex items-center justify-center gap-1 px-2 py-1.5 text-[11px] font-medium bg-blue-50 text-blue-700 border border-blue-100 rounded-full h-6 min-w-[24px]" |
| 190 |
> |
| 191 |
<span className="truncate">{chip.label}</span> |
| 192 |
<span className="text-[9px] text-gray-500 font-normal flex-shrink-0"> |
| 193 |
#{chip.id} |
| 194 |
</span> |
| 195 |
<button |
| 196 |
type="button" |
| 197 |
onClick={(e) => { |
| 198 |
e.stopPropagation(); |
| 199 |
toggleTrip(chip.id, false); |
| 200 |
}} |
| 201 |
className="hover:text-blue-600 text-[10px] flex-shrink-0 leading-none" |
| 202 |
> |
| 203 |
× |
| 204 |
</button> |
| 205 |
</span> |
| 206 |
))} |
| 207 |
<Button |
| 208 |
type="button" |
| 209 |
variant="ghost" |
| 210 |
size="sm" |
| 211 |
onClick={clearTrips} |
| 212 |
className="text-xs text-red-500 hover:text-red-600 px-2 py-0 h-6 flex items-center" |
| 213 |
> |
| 214 |
{__("Clear")} |
| 215 |
</Button> |
| 216 |
</div> |
| 217 |
) : ( |
| 218 |
<span className="text-sm text-gray-500 dark:text-gray-400"> |
| 219 |
{placeholder} |
| 220 |
</span> |
| 221 |
)} |
| 222 |
</div> |
| 223 |
|
| 224 |
{helperText && ( |
| 225 |
<p className="text-xs text-gray-500 dark:text-gray-400"> |
| 226 |
{helperText} |
| 227 |
</p> |
| 228 |
)} |
| 229 |
|
| 230 |
{showDropdown && !disabled && ( |
| 231 |
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg"> |
| 232 |
<div className="p-2 border-b border-gray-200 dark:border-gray-700"> |
| 233 |
<div className="relative"> |
| 234 |
<Input |
| 235 |
type="text" |
| 236 |
value={searchTerm} |
| 237 |
onChange={(e) => setSearchTerm(e.target.value)} |
| 238 |
placeholder={__("Search trips...")} |
| 239 |
className="pl-8" |
| 240 |
/> |
| 241 |
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" /> |
| 242 |
{isFetching && ( |
| 243 |
<Loader2 className="w-4 h-4 animate-spin text-gray-400 absolute right-2 top-1/2 -translate-y-1/2" /> |
| 244 |
)} |
| 245 |
</div> |
| 246 |
</div> |
| 247 |
|
| 248 |
<div className="max-h-64 overflow-y-auto"> |
| 249 |
{isLoading ? ( |
| 250 |
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400"> |
| 251 |
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" /> |
| 252 |
{__("Loading trips...")} |
| 253 |
</div> |
| 254 |
) : trips.length > 0 ? ( |
| 255 |
<div className="divide-y divide-gray-100 dark:divide-gray-700"> |
| 256 |
{trips.map((trip) => { |
| 257 |
const checked = selectedTripIds.includes(trip.id); |
| 258 |
return ( |
| 259 |
<label |
| 260 |
key={trip.id} |
| 261 |
className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40" |
| 262 |
onClick={(e) => e.stopPropagation()} |
| 263 |
> |
| 264 |
<input |
| 265 |
type="checkbox" |
| 266 |
checked={checked} |
| 267 |
onChange={(e) => |
| 268 |
toggleTrip(trip.id, e.target.checked) |
| 269 |
} |
| 270 |
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" |
| 271 |
/> |
| 272 |
<span className="flex-1 text-sm text-gray-900 dark:text-white truncate"> |
| 273 |
{trip.label} |
| 274 |
</span> |
| 275 |
<span className="text-xs text-gray-400 dark:text-gray-500"> |
| 276 |
#{trip.id} |
| 277 |
</span> |
| 278 |
</label> |
| 279 |
); |
| 280 |
})} |
| 281 |
</div> |
| 282 |
) : ( |
| 283 |
<div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400"> |
| 284 |
{searchTerm |
| 285 |
? __("No trips found") |
| 286 |
: __("No trips available")} |
| 287 |
</div> |
| 288 |
)} |
| 289 |
</div> |
| 290 |
|
| 291 |
<div className="p-2 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between"> |
| 292 |
<span className="text-xs text-gray-500 dark:text-gray-400"> |
| 293 |
{selectedTripIds.length} {__("selected")} |
| 294 |
</span> |
| 295 |
<Button |
| 296 |
type="button" |
| 297 |
variant="ghost" |
| 298 |
size="sm" |
| 299 |
onClick={() => setShowDropdown(false)} |
| 300 |
className="text-xs" |
| 301 |
> |
| 302 |
<X className="w-4 h-4 mr-1" /> |
| 303 |
{__("Close")} |
| 304 |
</Button> |
| 305 |
</div> |
| 306 |
</div> |
| 307 |
)} |
| 308 |
</div> |
| 309 |
)} |
| 310 |
</div> |
| 311 |
); |
| 312 |
}; |
| 313 |
|