Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
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}
</>
);
}