mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
133 lines
6.0 KiB
React
133 lines
6.0 KiB
React
// 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 (
|
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setQuery(""); }}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="w-full justify-between font-normal h-9"
|
|
>
|
|
<span className="truncate">
|
|
{selected ? selected[config.labelKey] : config.placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<div className="flex items-center gap-2 border-b px-3">
|
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search…"
|
|
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
|
<Spinner className="size-4" />
|
|
Loading…
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
|
|
) : (
|
|
filtered.map((item) => {
|
|
const id = item[config.idKey];
|
|
const isSelected = String(value) === String(id);
|
|
return (
|
|
<div
|
|
key={id}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => { 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"
|
|
)}
|
|
>
|
|
<Check className={cn("size-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
|
<span className="truncate">{item[config.labelKey]}</span>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|