import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { api } from '../api.js'; /** * CategorySelect — multi-select with checkboxes + inline "Add category" * * Props: * selected : number[] — ids sélectionnés * onChange : (ids: number[]) => void * categories : { id, nom }[] — liste complète fournie par le parent * onCategoryAdded : ({ id, nom }) => void — appelé après création inline * * Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect) * pour échapper au overflow:auto des modales parentes. */ export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) { const [open, setOpen] = useState(false); const [adding, setAdding] = useState(false); const [newName, setNewName] = useState(''); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 }); const wrapRef = useRef(null); const triggerRef = useRef(null); /* ── Position fixe calculée à chaque ouverture ───────────────── */ useLayoutEffect(() => { if (!open || !triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); setDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width, }); }, [open]); /* ── Fermeture : clic extérieur + scroll + resize ────────────── */ useEffect(() => { if (!open) return; const close = (e) => { if (wrapRef.current?.contains(e.target)) return; // Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap) const drop = document.getElementById('cat-select-dropdown-portal'); if (drop?.contains(e.target)) return; setOpen(false); }; const closeOnScroll = (e) => { const drop = document.getElementById('cat-select-dropdown-portal'); if (drop?.contains(e.target)) return; setOpen(false); }; document.addEventListener('mousedown', close); window.addEventListener('scroll', closeOnScroll, true); window.addEventListener('resize', closeOnScroll); return () => { document.removeEventListener('mousedown', close); window.removeEventListener('scroll', closeOnScroll, true); window.removeEventListener('resize', closeOnScroll); }; }, [open]); const toggle = (id) => { onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); }; const addCategory = async (e) => { e.preventDefault(); if (!newName.trim()) return; setBusy(true); setErr(null); try { const cat = await api.post('/categories', { nom: newName.trim() }); onCategoryAdded(cat); onChange([...selected, cat.id]); setNewName(''); setAdding(false); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; /* ── Label du bouton déclencheur ─────────────────────────────── */ const label = (() => { if (selected.length === 0) return "Aucune catégorie d'investissement"; const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom); if (names.length <= 2) return names.join(', '); return `${names.length} catégories d'invest.`; })(); /* ── Dropdown rendu en position:fixed ────────────────────────── */ const dropdown = open ? (
{/* Liste des catégories */} {categories.length === 0 && (
Aucune catégorie d'investissement disponible
)} {categories.map(cat => { const checked = selected.includes(cat.id); return ( ); })} {/* Séparateur + ajout */}
{!adding ? ( ) : (
setNewName(e.target.value)} placeholder="Nom de la catégorie d'investissement" maxLength={100} />
{err &&
{err}
}
)}
) : null; return ( <>
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */} {dropdown} ); }