Notion de Regroupements Patrimoine

This commit is contained in:
ocroguennec committed 2026-09-28 14:30:01 +02:00
1 parent 9591ae400e
commit efa5de79b8
6 files changed
+661 -22

No files matched your search

+308 -7
View File
@@ -56,6 +56,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [categories, setCategories] = useState([]);
const [supports, setSupports] = useState([]);
const [regroupements, setRegroupements] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
@@ -65,6 +66,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
plafond_eur: row?.plafond_eur != null ? String(row.plafond_eur) : '',
regime_fiscal: row?.regime_fiscal || '',
eligible_compte: !!row?.eligible_compte,
regroupement_patrimonial_id: row?.regroupement_patrimonial_id || '',
categories_ids: (row?.categories || []).map(c => c.id),
supports_ids: (row?.supports || []).map(s => s.id),
});
@@ -72,6 +74,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
useEffect(() => {
api.get('/ref-categories-enveloppes').then(setCategories).catch(() => {});
api.get('/supports-referentiel').then(setSupports).catch(() => {});
api.get('/regroupements-patrimoniaux').then(setRegroupements).catch(() => {});
}, []);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
@@ -89,6 +92,7 @@ function EnveloppeModal({ row, onClose, onSaved }) {
plafond_eur: plafond,
regime_fiscal: form.regime_fiscal.trim() || null,
eligible_compte: !!form.eligible_compte,
regroupement_patrimonial_id: form.regroupement_patrimonial_id ? Number(form.regroupement_patrimonial_id) : null,
categories_ids: form.categories_ids || [],
supports_ids: form.supports_ids || [],
};
@@ -142,6 +146,26 @@ function EnveloppeModal({ row, onClose, onSaved }) {
auto-conservée, objets de collection…).
</p>
</div>
<div>
<label>Regroupement patrimonial</label>
<select value={form.regroupement_patrimonial_id} onChange={e => set('regroupement_patrimonial_id', e.target.value)}
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
<option value="">— Aucun —</option>
{regroupements.filter(r => !r.parent_id).map(root => (
<optgroup key={root.id} label={root.nom}>
<option value={root.id}>{root.nom}</option>
{regroupements.filter(r => r.parent_id === root.id).map(child => (
<option key={child.id} value={child.id}>{root.nom} › {child.nom}</option>
))}
</optgroup>
))}
</select>
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Famille utilisée pour une future vue patrimoine consolidée (Immobilier, Comptes
courants, Crowdlending…) — distincte des catégories ci-dessous, qui organisent le
catalogue admin.
</p>
</div>
<div>
<label>Catégories</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
@@ -299,12 +323,13 @@ function EnveloppesSection() {
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Régime fiscal</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Supports</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Regroupement</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune enveloppe dans le référentiel. Créez-en une pour commencer.'
: 'Aucun résultat pour ces critères.'}
@@ -339,6 +364,13 @@ function EnveloppesSection() {
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{(row.supports || []).length === 0 ? 'Cash (aucun)' : `${row.supports.length} support${row.supports.length > 1 ? 's' : ''}`}
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{row.regroupement_patrimonial
? (row.regroupement_patrimonial.parent_nom
? `${row.regroupement_patrimonial.parent_nom} › ${row.regroupement_patrimonial.nom}`
: row.regroupement_patrimonial.nom)
: '—'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
@@ -754,10 +786,278 @@ function CategoriesEnveloppesSection() {
);
}
function IconGroup() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>;
}
/* ── Modale de création/édition d'un regroupement patrimonial ─────
Hiérarchie générique à 2 niveaux (parent_id) — un regroupement racine (Immobilier, Comptes
courants…) ou un sous-regroupement rattaché à un parent (ex. Private Equity sous Comptes
d'investissement). Le type (actif/passif) est libre pour un regroupement racine, et suit
automatiquement celui de son parent pour un sous-regroupement — pas de mélange actif/passif
dans une même branche. */
function RegroupementModal({ row, allRegroupements, onClose, onSaved }) {
const isNew = !row?.id;
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const hasChildren = (row?.nb_enfants || 0) > 0;
const parentOptions = allRegroupements.filter(r => !r.parent_id && r.id !== row?.id);
const [form, setForm] = useState({
nom: row?.nom || '',
parent_id: row?.parent_id || '',
type: row?.type || 'actif',
ordre_affichage: row?.ordre_affichage != null ? String(row.ordre_affichage) : '0',
});
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const selectedParent = form.parent_id ? allRegroupements.find(r => String(r.id) === String(form.parent_id)) : null;
const effectiveType = selectedParent ? selectedParent.type : form.type;
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
setSaving(true); setErr(null);
try {
const payload = {
nom: form.nom.trim(),
parent_id: form.parent_id ? Number(form.parent_id) : null,
type: effectiveType,
ordre_affichage: Number(form.ordre_affichage) || 0,
};
if (isNew) await api.post('/regroupements-patrimoniaux', payload);
else await api.put(`/regroupements-patrimoniaux/${row.id}`, payload);
onSaved(); onClose();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
return (
<Modal
open
title={isNew ? 'Ajouter un regroupement' : `Modifier — ${row.nom}`}
onClose={onClose}
width={480}
footer={
<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button type="submit" form="regroupement-form" className="primary" disabled={saving}>
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
</button>
</>
}
>
<form id="regroupement-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{err && <div className="error">{err}</div>}
<div>
<label>Nom *</label>
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Immobilier, Crowdlending, Private Equity…" />
</div>
<div>
<label>Regroupement parent</label>
<select value={form.parent_id} onChange={e => set('parent_id', e.target.value)} disabled={hasChildren}
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
<option value="">— Aucun (regroupement racine) —</option>
{parentOptions.map(r => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
{hasChildren ? (
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Ce regroupement a déjà des sous-regroupements — il ne peut pas devenir lui-même un enfant.
</p>
) : (
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Laisser vide pour un regroupement racine (ex. Immobilier) — choisir un parent pour
une sous-catégorie (ex. Private Equity sous Comptes d'investissement).
</p>
)}
</div>
<div>
<label>Type</label>
{selectedParent ? (
<p style={{ margin: '4px 0 0', fontSize: 13 }}>
{effectiveType === 'passif' ? 'Passif' : 'Actif'} <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(hérité du parent « {selectedParent.nom} »)</span>
</p>
) : (
<select value={form.type} onChange={e => set('type', e.target.value)}
style={{ width: '100%', padding: '7px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
<option value="actif">Actif</option>
<option value="passif">Passif</option>
</select>
)}
</div>
<div>
<label>Ordre d'affichage</label>
<input type="number" value={form.ordre_affichage} onChange={e => set('ordre_affichage', e.target.value)} style={{ width: 120 }} />
</div>
</form>
</Modal>
);
}
/* ── Section "Regroupements patrimoniaux" ──────────────────────────
Vue consolidée par grandes familles d'actifs (chantier du 28/09/26 (suite), demande Olivier —
cf. claude/plan_regroupements_patrimoniaux.md). Orthogonal aux catégories : une même catégorie
du catalogue (ex. "Financement participatif / private equity") peut se scinder entre plusieurs
regroupements ici (Crowdlending vs Private Equity). */
function RegroupementsSection() {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [editing, setEditing] = useState(null);
const [openMenu, setOpenMenu] = useState(null);
const [confirm, setConfirm] = useState(null);
const load = useCallback(() => {
setLoading(true);
api.get('/regroupements-patrimoniaux')
.then(data => { setRows(data); setErr(null); })
.catch(e => setErr(e.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
// Racines triées par ordre_affichage, chaque enfant juste après son parent.
const ordered = useMemo(() => {
const roots = rows.filter(r => !r.parent_id).sort((a, b) => a.ordre_affichage - b.ordre_affichage);
const out = [];
for (const root of roots) {
out.push(root);
rows.filter(r => r.parent_id === root.id).sort((a, b) => a.ordre_affichage - b.ordre_affichage)
.forEach(child => out.push(child));
}
return out;
}, [rows]);
const handleDelete = (row) => {
setConfirm({
title: 'Supprimer ce regroupement ?',
message: `Supprimer "${row.nom}" ? Cette action est définitive.`,
confirmLabel: 'Supprimer',
onConfirm: async () => {
setConfirm(null);
try { await api.del(`/regroupements-patrimoniaux/${row.id}`); load(); }
catch (ex) { setErr(ex.message); }
},
});
};
const openContextMenu = (e, row) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ row, x: rect.right - 160, y: rect.bottom + 4 });
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>Regroupements patrimoniaux</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Grandes familles pour une vue patrimoine consolidée (Immobilier, Comptes courants,
Crowdlending…), et le passif (Emprunts) — distinct des catégories du catalogue.
</p>
</div>
<button className="primary" onClick={() => setEditing('new')}>+ Ajouter</button>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Type</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Enveloppes</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Emprunts</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{ordered.length === 0 ? (
<tr><td colSpan={5} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun regroupement.</td></tr>
) : ordered.map((row, i) => (
<tr key={row.id} style={{ borderBottom: i < ordered.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td style={{ padding: '10px 16px', fontWeight: row.parent_id ? 400 : 600 }}>
{row.parent_id ? <span style={{ color: 'var(--text-muted)', marginRight: 4 }}>›</span> : null}
{row.nom}
</td>
<td style={{ padding: '10px 8px' }}>
<span style={{ fontSize: 11, fontWeight: 600, padding: '1px 7px', borderRadius: 10,
background: row.type === 'passif' ? 'rgba(239,68,68,.12)' : 'rgba(34,197,94,.12)',
color: row.type === 'passif' ? '#ef4444' : '#22c55e' }}>
{row.type === 'passif' ? 'Passif' : 'Actif'}
</span>
</td>
<td style={{ padding: '10px 8px', textAlign: 'center', color: 'var(--text-muted)' }}>{row.nb_enveloppes}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', color: 'var(--text-muted)' }}>{row.nb_emprunts}</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onClick={e => openContextMenu(e, row)}
>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { const r = openMenu.row; setOpenMenu(null); setEditing(r); }}>
<IconEdit /> Modifier
</button>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }}>
<IconTrash /> Supprimer
</button>
</div>
</>
)}
{editing && (
<RegroupementModal
row={editing === 'new' ? null : editing}
allRegroupements={rows}
onClose={() => setEditing(null)}
onSaved={load}
/>
)}
<ConfirmModal open={!!confirm} title={confirm?.title} message={confirm?.message} confirmLabel={confirm?.confirmLabel}
onConfirm={confirm?.onConfirm} onCancel={() => setConfirm(null)} />
</div>
);
}
const NAV = [
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
{ id: 'enveloppes', label: 'Enveloppes', icon: <IconWallet /> },
{ id: 'supports', label: 'Supports', icon: <IconLayers /> },
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
{ id: 'regroupements', label: 'Regroupements', icon: <IconGroup /> },
];
/* ── Page principale ──────────────────────────────────────────── */
@@ -794,9 +1094,10 @@ export default function AdminEnveloppes() {
</div>
</aside>
<div className="account-content">
{section === 'enveloppes' && <EnveloppesSection />}
{section === 'supports' && <SupportsSection />}
{section === 'categories' && <CategoriesEnveloppesSection />}
{section === 'enveloppes' && <EnveloppesSection />}
{section === 'supports' && <SupportsSection />}
{section === 'categories' && <CategoriesEnveloppesSection />}
{section === 'regroupements' && <RegroupementsSection />}
</div>
</div>
);