Implementation de la notion d'espace d'investissement

This commit is contained in:
ocroguennec committed 2026-09-17 18:25:42 +02:00
1 parent 6b6cad7cd6
commit 4b7df66862
54 files changed
+6782 -288

No files matched your search

+67 -19
View File
@@ -46,13 +46,14 @@ const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code
const FISCALITE_LABELS = { flat_tax: 'Flat tax (30%)', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale' };
const CATEGORIE_PLATEFORME_LABELS = { psfp: 'Prestataire de Services de Financement Participatif', psan: 'Prestataire de Services sur Actifs Numériques' };
const FRAIS_PLATEFORME_LABELS = { aucun: 'Aucun frais', entree_gestion_directe: "Frais d'entrée + Frais de gestion (Prélèvement direct)" };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [] };
const EMPTY_REF = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', categorie_plateforme: 'psfp', frais_plateforme: 'aucun', methode_remboursement: 'portefeuille', type_pret_defaut: '', freq_interets_defaut: '', description: '', categories: [], categories_inv_ids: [], secteurs_inv_ids: [], workspace_ids: [] };
/* ── Modal création / édition référentiel ─────────────────────── */
function RefModal({ row, onClose, onSaved }) {
const isNew = !row?.id;
const [catsInv, setCatsInv] = useState([]);
const [secteursInv, setSecteursInv] = useState([]);
const [workspaces, setWorkspaces] = useState([]);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
@@ -70,15 +71,30 @@ function RefModal({ row, onClose, onSaved }) {
categories: [],
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
workspace_ids: (row.workspaces || []).map(w => w.id),
});
useEffect(() => {
Promise.all([
api.get('/ref-categories'),
api.get('/ref-secteurs'),
]).then(([cats, sects]) => {
api.get('/admin/workspaces'),
]).then(([cats, sects, ws]) => {
setCatsInv(cats);
setSecteursInv(sects);
setWorkspaces(ws);
// À la création, présélectionne le workspace "crowdlending" (seul
// workspace réel aujourd'hui) — l'utilisateur reste libre d'ajuster ;
// au moins une affectation reste exigée à l'enregistrement.
if (isNew) {
setForm(f => (f.workspace_ids && f.workspace_ids.length > 0) ? f : {
...f,
workspace_ids: (() => {
const cl = ws.find(w => w.slug === 'crowdlending');
return cl ? [cl.id] : ws.map(w => w.id);
})(),
});
}
}).catch(() => {});
}, []); // eslint-disable-line
@@ -88,6 +104,7 @@ function RefModal({ row, onClose, onSaved }) {
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
if (!form.workspace_ids || form.workspace_ids.length === 0) { setErr('Sélectionnez au moins un espace de travail.'); return; }
setSaving(true); setErr(null);
try {
// Champs gérés par RefModal
@@ -105,6 +122,7 @@ function RefModal({ row, onClose, onSaved }) {
description: form.description.trim() || null,
categories_inv_ids: form.categories_inv_ids || [],
secteurs_inv_ids: form.secteurs_inv_ids || [],
workspace_ids: form.workspace_ids || [],
};
if (isNew) {
@@ -165,6 +183,26 @@ function RefModal({ row, onClose, onSaved }) {
<option value="psan">{CATEGORIE_PLATEFORME_LABELS.psan}</option>
</select>
</div>
<div>
<label>Espaces de travail *</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{workspaces.map(w => {
const checked = (form.workspace_ids || []).includes(w.id);
return (
<label key={w.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => set('workspace_ids', e.target.checked
? [...(form.workspace_ids || []), w.id]
: (form.workspace_ids || []).filter(id => id !== w.id))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{w.nom}</span>
</label>
);
})}
</div>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Cette entrée du référentiel ne sera visible/utilisable que dans le(s) espace(s) de travail coché(s).
</div>
</div>
<div><label>Description</label>
<textarea value={form.description} onChange={e => set('description', e.target.value)}
style={{ minHeight: 180, resize: 'vertical' }} placeholder="Informations générales sur la plateforme…" /></div>
@@ -327,6 +365,7 @@ function ReferentielSection() {
const { search: urlSearch } = useLocation();
const [search, setSearch] = useState('');
const [filterDomicil, setFilterDomicil] = useState('');
const [filterWorkspace, setFilterWorkspace] = useState('');
const [filterCategorie, setFilterCategorie] = useState(
() => new URLSearchParams(urlSearch).get('filterCat') || ''
);
@@ -337,6 +376,12 @@ function ReferentielSection() {
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
}, [rows]);
const allWorkspaces = useMemo(() => {
const map = new Map();
rows.forEach(r => (r.workspaces || []).forEach(w => map.set(w.id, w.nom)));
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'fr'));
}, [rows]);
const allDomiciliations = useMemo(() => {
const set = new Set();
rows.forEach(r => { if (r.domiciliation) set.add(r.domiciliation); });
@@ -346,16 +391,17 @@ function ReferentielSection() {
const filteredRows = useMemo(() => {
let r = rows;
if (filterDomicil) r = r.filter(x => x.domiciliation === filterDomicil);
if (filterWorkspace) r = r.filter(x => (x.workspaces || []).some(w => String(w.id) === filterWorkspace));
if (filterCategorie) r = r.filter(x => (x.categories_inv || []).some(c => c.nom === filterCategorie));
if (search.trim()) {
const q = search.trim().toLowerCase();
r = r.filter(x => x.nom.toLowerCase().includes(q));
}
return r;
}, [rows, search, filterDomicil, filterCategorie]);
}, [rows, search, filterDomicil, filterWorkspace, filterCategorie]);
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterCategorie]);
usePagination(filteredRows, 'cl_pagesize_referentiel', [search, filterDomicil, filterWorkspace, filterCategorie]);
const load = useCallback(async () => {
try {
@@ -486,6 +532,16 @@ function ReferentielSection() {
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
/>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={filterWorkspace}
onChange={e => setFilterWorkspace(e.target.value)}
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
>
<option value="">Tous les espaces de travail</option>
{allWorkspaces.map(([id, nom]) => (
<option key={id} value={String(id)}>{nom}</option>
))}
</select>
<select
value={filterDomicil}
onChange={e => setFilterDomicil(e.target.value)}
@@ -504,9 +560,9 @@ function ReferentielSection() {
<option value="">Toutes les catégories</option>
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
{(search || filterDomicil || filterCategorie) && (
{(search || filterDomicil || filterWorkspace || filterCategorie) && (
<button
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterCategorie(''); }}
onClick={() => { setSearch(''); setFilterDomicil(''); setFilterWorkspace(''); setFilterCategorie(''); }}
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>Effacer les filtres</button>
)}
@@ -537,15 +593,14 @@ function ReferentielSection() {
<th style={{ padding: '10px 8px', width: 40 }}></th>
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Workspaces</th>
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Héritage</th>
<th style={{ padding: '10px 16px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td colSpan={7} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
<tr><td colSpan={6} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
{rows.length === 0
? 'Aucune entrée dans le référentiel. Créez-en une ou importez une plateforme orpheline.'
: 'Aucun résultat pour ces critères.'}
@@ -573,16 +628,9 @@ function ReferentielSection() {
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}><span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}</span></td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.categories_inv || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{(row.categories_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories_inv.length - 2}</span>}
{(row.categories_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(row.secteurs_inv || []).slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{(row.secteurs_inv || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.secteurs_inv.length - 2}</span>}
{(row.secteurs_inv || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
{(row.workspaces || []).slice(0, 2).map(w => <span key={w.id} className="chip-workspace" style={{ fontSize: 11 }}>{w.nom}</span>)}
{(row.workspaces || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.workspaces.length - 2}</span>}
{(row.workspaces || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
</div>
</td>
<td style={{ padding: '10px 8px', textAlign: 'center' }}>