Implementation de la notion d'espace d'investissement
This commit is contained in:
1 parent
6b6cad7cd6
commit
4b7df66862
54 files changed
+6782
-288
No files matched your search
@@ -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' }}>
|
||||
|
||||
Reference in new issue
Block a user