197 lines
7.1 KiB
React
197 lines
7.1 KiB
React
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 ? (
|
|
<div
|
|
id="cat-select-dropdown-portal"
|
|
className="cat-select-dropdown"
|
|
role="listbox"
|
|
aria-multiselectable="true"
|
|
style={{
|
|
position: 'fixed',
|
|
top: dropPos.top,
|
|
left: dropPos.left,
|
|
width: dropPos.width,
|
|
zIndex: 9999,
|
|
}}
|
|
>
|
|
{/* Liste des catégories */}
|
|
{categories.length === 0 && (
|
|
<div className="cat-select-empty">Aucune catégorie d'investissement disponible</div>
|
|
)}
|
|
{categories.map(cat => {
|
|
const checked = selected.includes(cat.id);
|
|
return (
|
|
<label key={cat.id} className={`cat-select-item${checked ? ' checked' : ''}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => toggle(cat.id)}
|
|
/>
|
|
<span>{cat.nom}</span>
|
|
{checked && (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
style={{ marginLeft: 'auto', color: 'var(--accent)' }} aria-hidden="true">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
)}
|
|
</label>
|
|
);
|
|
})}
|
|
|
|
{/* Séparateur + ajout */}
|
|
<div className="cat-select-sep" />
|
|
|
|
{!adding ? (
|
|
<button type="button" className="cat-select-add-btn"
|
|
onClick={() => { setAdding(true); setErr(null); }}>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
aria-hidden="true">
|
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
Ajouter une catégorie d'investissement
|
|
</button>
|
|
) : (
|
|
<form onSubmit={addCategory} className="cat-select-new-form">
|
|
<input
|
|
autoFocus
|
|
value={newName}
|
|
onChange={e => setNewName(e.target.value)}
|
|
placeholder="Nom de la catégorie d'investissement"
|
|
maxLength={100}
|
|
/>
|
|
<div className="cat-select-new-actions">
|
|
<button type="submit" className="primary" disabled={busy || !newName.trim()}>
|
|
{busy ? '…' : 'Créer'}
|
|
</button>
|
|
<button type="button" className="ghost"
|
|
onClick={() => { setAdding(false); setNewName(''); setErr(null); }}>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
{err && <div className="cat-select-err">{err}</div>}
|
|
</form>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<>
|
|
<div ref={wrapRef} className="cat-select-wrap">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
className={`cat-select-trigger${open ? ' open' : ''}`}
|
|
onClick={() => { setOpen(o => !o); setAdding(false); setErr(null); }}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
>
|
|
<span className="cat-select-label">{label}</span>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
|
style={{ flexShrink: 0, transition: 'transform .15s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)' }}
|
|
aria-hidden="true">
|
|
<path d="M18 15l-6-6-6 6"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Dropdown rendu hors du wrap pour échapper à overflow:auto */}
|
|
{dropdown}
|
|
</>
|
|
);
|
|
}
|