// components/generic/BroadcastTargetPicker.jsx // Single-select searchable picker for notification broadcast targeting. // Fetches the right list (task lists / courses / tier plans) based on targetType. import { useEffect, useMemo, useState } from "react"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import api from "@/utils/api.util"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; // ─── Per-target-type data source config ─────────────────────────────────────── const TARGET_CONFIGS = { task_list: { fetch: () => api.get("/admin/task-lists", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []), idKey: "task_list_id", labelKey: "name", placeholder: "Select a task list…", }, course: { fetch: () => api.get("/admin/courses/flat").then((res) => res.data?.data ?? []), idKey: "uuid", labelKey: "title", placeholder: "Select a course…", }, tier_plan: { fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []), idKey: "plan_id", labelKey: "label", placeholder: "Select a tier plan…", }, }; export function BroadcastTargetPicker({ targetType, value, onChange, onLabelResolved }) { const config = TARGET_CONFIGS[targetType]; const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { if (!config) return; let cancelled = false; setLoading(true); config.fetch() .then((data) => { if (!cancelled) setItems(Array.isArray(data) ? data : []); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetType]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q || !config) return items; return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q)); }, [items, query, config]); const selected = config ? items.find((item) => String(item[config.idKey]) === String(value)) : undefined; // Lets the parent (Review step summaries, etc.) show the resolved name // instead of just the raw id — fires whenever the matched item changes, // including on initial load once the fetched list resolves `value`. useEffect(() => { onLabelResolved?.(selected ? selected[config.labelKey] : null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected]); if (!config) return null; return ( { setOpen(v); if (!v) setQuery(""); }}>
setQuery(e.target.value)} placeholder="Search…" className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground" />
{loading ? (
Loading…
) : filtered.length === 0 ? (

No results found.

) : ( filtered.map((item) => { const id = item[config.idKey]; const isSelected = String(value) === String(id); return (
{ onChange(id); setOpen(false); setQuery(""); }} className={cn( "flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground", isSelected && "bg-accent/50" )} > {item[config.labelKey]}
); }) )}
); }