/*********************************************************************************************************************************************************************** * File Name : TaskMultiSelect.jsx * Type : Reusable Component * Description : Searchable multi-select dropdown for sibling Tasks (used to pick * a task's prerequisite tasks). Cloned from GroupMultiSelect.jsx's * UI/UX — portal dropdown, badge + "+N" overflow, select all/clear — * but takes `tasks` directly from the caller instead of fetching its * own endpoint, since the parent page already has the sibling task * list loaded. * * Props: * value : string[] — selected task_ids * onChange : (ids: string[]) => void * tasks : { task_id, name }[] — candidate tasks (already excludes self) * disabled? : boolean * placeholder?: string ***********************************************************************************************************************************************************************/ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { Check, ChevronsUpDown, X, ListChecks } from 'lucide-react'; export default function TaskMultiSelect({ value = [], onChange, tasks = [], disabled = false, placeholder = 'Select tasks…', }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [dropdownStyle, setDropdownStyle] = useState({}); const triggerRef = useRef(null); const dropdownRef = useRef(null); // ── 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 = tasks.filter((t) => t.name.toLowerCase().includes(search.toLowerCase()) ); const selectedTasks = tasks.filter((t) => value.includes(t.task_id)); const overflowCount = selectedTasks.length - 1; // All currently visible (filtered) IDs — used for select-all scope const filteredIds = filtered.map((t) => t.task_id); const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id)); const toggle = (taskId) => onChange(value.includes(taskId) ? value.filter((id) => id !== taskId) : [...value, taskId] ); const remove = (e, taskId) => { e.stopPropagation(); onChange(value.filter((id) => id !== taskId)); }; // Select all visible (filtered) tasks const handleSelectAll = () => { const merged = Array.from(new Set([...value, ...filteredIds])); onChange(merged); }; // Clear all selections const handleClear = () => onChange([]); // ── Portal dropdown ─────────────────────────────────────────────────────── const dropdown = open && createPortal(