/*********************************************************************************************************************************************************************** * File Name : GroupMultiSelect.jsx * Type : Reusable Component * Description : Searchable multi-select dropdown for User Groups. * - Portal-based dropdown (escapes Card overflow clipping) * - First badge + "+N" overflow chip when 2+ selected * - "Select all" and "Clear" actions in dropdown header * * Props: * value : number[] — selected group_ids * onChange : (ids: number[]) => void * groups? : { group_id, name }[] — skip API fetch if provided * disabled? : boolean * placeholder?: string ***********************************************************************************************************************************************************************/ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import api from '@/utils/api.util'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { Check, ChevronsUpDown, X, Users, ChevronLeft, ChevronRight } from 'lucide-react'; const PAGE_SIZE = 5; export default function GroupMultiSelect({ value = [], onChange, groups: groupsProp = null, disabled = false, placeholder = 'Select groups…', }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [allGroups, setAllGroups] = useState(groupsProp ?? []); const [loadingGroups, setLoadingGroups] = useState(!groupsProp); const [dropdownStyle, setDropdownStyle] = useState({}); const triggerRef = useRef(null); const dropdownRef = useRef(null); // ── Fetch groups if not provided by parent ──────────────────────────────── useEffect(() => { if (groupsProp !== null) { setAllGroups(groupsProp); setLoadingGroups(false); return; } let cancelled = false; setLoadingGroups(true); api.get('/admin/groups', { params: { limit: 500 } }) .then((res) => { if (!cancelled) { const raw = res.data?.data?.data ?? res.data?.data ?? []; setAllGroups(Array.isArray(raw) ? raw : []); } }) .catch(() => { if (!cancelled) setAllGroups([]); }) .finally(() => { if (!cancelled) setLoadingGroups(false); }); return () => { cancelled = true; }; }, [groupsProp]); // ── Position portal dropdown under trigger ──────────────────────────────── useLayoutEffect(() => { if (!open || !triggerRef.current) return; const reposition = () => { const rect = triggerRef.current.getBoundingClientRect(); setDropdownStyle({ position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999, }); }; reposition(); window.addEventListener('scroll', reposition, true); window.addEventListener('resize', reposition); return () => { window.removeEventListener('scroll', reposition, true); window.removeEventListener('resize', reposition); }; }, [open]); // ── Close on outside click ──────────────────────────────────────────────── useEffect(() => { if (!open) return; const handler = (e) => { if ( triggerRef.current?.contains(e.target) || dropdownRef.current?.contains(e.target) ) return; setOpen(false); setSearch(''); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); // ── Helpers ─────────────────────────────────────────────────────────────── const filtered = allGroups.filter((g) => g.name.toLowerCase().includes(search.toLowerCase()) ); const selectedGroups = allGroups.filter((g) => value.includes(g.group_id)); const overflowCount = selectedGroups.length - 1; // All currently visible (filtered) IDs — used for select-all scope const filteredIds = filtered.map((g) => g.group_id); const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id)); // ── Pagination ──────────────────────────────────────────────────────────── const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages); const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); // Reset to page 1 whenever the search term changes const handleSearchChange = (e) => { setSearch(e.target.value); setPage(1); }; const toggle = (groupId) => onChange(value.includes(groupId) ? value.filter((id) => id !== groupId) : [...value, groupId] ); const remove = (e, groupId) => { e.stopPropagation(); onChange(value.filter((id) => id !== groupId)); }; // Select all visible (filtered) groups const handleSelectAll = () => { const merged = Array.from(new Set([...value, ...filteredIds])); onChange(merged); }; // Clear all selections const handleClear = () => onChange([]); // ── Portal dropdown ─────────────────────────────────────────────────────── const dropdown = open && createPortal(
{/* Search row */}
{/* Select all / Clear row — only when groups are loaded */} {!loadingGroups && allGroups.length > 0 && (
{value.length > 0 && ( )}
)} {/* Options */} {/* Pagination */} {!loadingGroups && filtered.length > PAGE_SIZE && (
{safePage} / {totalPages}
)}
, document.body ); return ( <> {/* ── Trigger button ───────────────────────────────────────────── */} {/* ── Portalled dropdown ────────────────────────────────────────── */} {dropdown} ); }