Files
starr-philproperties/src/components/generic/BroadcastTargetPicker.jsx
T
kennethobsequio fa92d924f4 added
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-12 12:39:47 +08:00

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>
);
}