import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../api.js'; import InvSelect from '../../components/InvSelect.jsx'; import Modal from '../../components/Modal.jsx'; import ConfirmModal from '../../components/ConfirmModal.jsx'; import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx'; import ResultBanner from '../../components/ResultBanner.jsx'; import { useInvestisseur } from '../../context/InvestisseurContext.jsx'; import { memberLabel, fmtDate } from '../../utils/format.js'; function dlBlob(content, filename, type) { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—'; const FISCALITE_LABELS = { flat_tax: 'Flat Tax', sans_fiscalite_locale: 'Sans fiscalité locale', avec_fiscalite_locale: 'Avec fiscalité locale', }; const METHODE_REMB_LABELS = { portefeuille: 'Porte-monnaie de la plateforme', compte_courant: "Compte courant de l'investisseur", choix_investisseur: "Au choix de l'investisseur (sur la plateforme)", }; /** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */ function platInvestisseur(p) { if (!p.investisseur_id) return null; return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal }; } function fmtFiscalite(p) { if (!p.fiscalite) return '—'; const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite; if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) { return `${base} (${p.taux_fiscalite_locale} %)`; } return base; } const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null }; const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/'; const logoUrl = (filename) => filename ? LOGO_BASE + filename : null; function applyDomiciliationChange(state, newDomicil) { const next = { ...state, domiciliation: newDomicil }; if (newDomicil === 'FR') { next.fiscalite = 'flat_tax'; next.taux_fiscalite_locale = ''; } else if (state.fiscalite === 'flat_tax') { next.fiscalite = 'sans_fiscalite_locale'; next.taux_fiscalite_locale = ''; } return next; } function applyFiscaliteChange(state, newFiscalite) { const next = { ...state, fiscalite: newFiscalite }; if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = ''; return next; } function platsToCSV(plats) { const BOM = ''; const sep = ';'; const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le']; const rows = plats.map(p => [ p.id, p.nom, p.url || '', countryLabel(p.domiciliation), FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '', p.taux_fiscalite_locale ?? '', p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '', ]); return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n'); } function platsToXLS(plats) { const esc = v => String(v ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); const cell = (v, t = 'String') => `${esc(v)}`; const mkRow = cells => ` ${cells.join('')}`; const header = mkRow(['ID','Nom','URL','Domiciliation','Fiscalité','Taux fiscal local (%)','Créé le'].map(h => cell(h))); const dataRows = plats.map(p => mkRow([ cell(p.id, 'Number'), cell(p.nom), cell(p.url || ''), cell(countryLabel(p.domiciliation)), cell(FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? ''), cell(p.taux_fiscalite_locale ?? ''), cell(p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : ''), ])).join('\n'); return ` ${header} ${dataRows}
`; } /* ── Icônes utilitaires ──────────────────────────────────────── */ function IconExport() { return ; } function IconCSV() { return ; } function IconXLS() { return ; } function IconJSON() { return ; } function IconImport() { return ; } /* ── ExportDropdown ──────────────────────────────────────────── */ function ExportDropdown({ disabled, onCSV, onXLS, onJSON, title = 'Exporter' }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const handler = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); const choose = (fn) => { setOpen(false); fn(); }; return (
{open && (
{onJSON && ( )}
)}
); } /* ── Helpers PFU ─────────────────────────────────────────────── */ function PlatDetailPanel({ plat, onEdit }) { const navigate = useNavigate(); const [platCatsInv, setPlatCatsInv] = useState([]); const [platSectsInv, setPlatSectsInv] = useState([]); useEffect(() => { if (!plat) { setPlatCatsInv([]); setPlatSectsInv([]); return; } Promise.all([ api.get(`/plateformes/${plat.id}/categories-inv`).catch(() => []), api.get(`/plateformes/${plat.id}/secteurs-inv`).catch(() => []), ]).then(([cats, sects]) => { setPlatCatsInv(cats); setPlatSectsInv(sects); }); }, [plat?.id]); if (!plat) return (
Sélectionnez une plateforme
); const inv = platInvestisseur(plat); const fields = [ { label: 'Plateforme', value: plat.nom }, plat.url && { label: 'Site web', value: {plat.url}, }, { label: 'Détenteur', value: inv ? memberLabel(inv) : '—' }, plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) }, { label: 'Domiciliation', value: plat.domiciliation ? {countryLabel(plat.domiciliation)} : '—' }, { label: 'Fiscalité', value: fmtFiscalite(plat) }, plat.domiciliation === 'FR' && { label: 'Déclaration 2561', value: (plat.type_produit_fiscal ?? '2TT') === '2TR' ? 'Case 2TR — Produits de placement à revenu fixe' : 'Case 2TT — Produits des minibons et prêts participatifs', }, { label: 'Méthode de remboursement', value: METHODE_REMB_LABELS[plat.methode_remboursement] ?? '—' }, platCatsInv.length > 0 && { label: "Catégories d'investissement", value:
{platCatsInv.map(c => {c.nom})}
, }, platSectsInv.length > 0 && { label: "Secteurs d'investissement", value:
{platSectsInv.map(s => {s.nom})}
, }, plat.type_pret_defaut && { label: 'Type de prêt (défaut)', value: { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }[plat.type_pret_defaut] ?? plat.type_pret_defaut }, plat.freq_interets_defaut && { label: 'Périodicité (défaut)', value: { mensuel: 'Mensuelle', trimestriel: 'Trimestrielle', in_fine: 'In fine' }[plat.freq_interets_defaut] ?? plat.freq_interets_defaut }, { label: 'Investissements', value: plat.nb_investissements != null ? `${plat.nb_investissements} investissement${plat.nb_investissements !== 1 ? 's' : ''}` : '—', }, plat.notes && { label: 'Notes', value: plat.notes }, ].filter(Boolean); const logo = logoUrl(plat.logo_filename); return (
{logo && (
{`Logo { e.currentTarget.style.display = 'none'; }} />
)}
Détail de la plateforme
{fields.map(f => (
{f.label} {f.value}
))}
{plat.referentiel_id && ( )}
); } /* ── Panneau de détail PFU ───────────────────────────────────── */ export default function PlateformesSection() { // ── State ────────────────────────────────────────────────────── const [plats, setPlats] = useState([]); const [investisseurs, setInvestisseurs] = useState([]); const [referentiel, setReferentiel] = useState([]); const [newPlat, setNewPlat] = useState(EMPTY_PLAT); const [newPlatLogoFile, setNewPlatLogoFile] = useState(null); const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null); const [editPlat, setEditPlat] = useState(null); const [editPlatLogoFile, setEditPlatLogoFile] = useState(null); const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null); const [selectedPlat, setSelectedPlat] = useState(null); const [showNewPlat, setShowNewPlat] = useState(false); const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y } const [platExporting, setPlatExporting] = useState(false); const [platImportResult, setPlatImportResult] = useState(null); // Catégories d'investissement (globales + privées) const [categoriesInv, setCategoriesInv] = useState([]); const [selectedCatInv, setSelectedCatInv] = useState(null); const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition const [editingNomCatInv, setEditingNomCatInv] = useState(''); const [newCatInvNom, setNewCatInvNom] = useState(''); const [showNewCatInv, setShowNewCatInv] = useState(false); // Secteurs d'investissement (globaux + privés) const [secteursInv, setSecteursInv] = useState([]); const [selectedSectInv, setSelectedSectInv] = useState(null); const [editingSectInv, setEditingSectInv] = useState(null); const [editingNomSectInv, setEditingNomSectInv] = useState(''); const [newSectInvNom, setNewSectInvNom] = useState(''); const [showNewSectInv, setShowNewSectInv] = useState(false); // PFU const [showPfoDetail, setShowPfoDetail] = useState(false); const [err, setErr] = useState(null); const [msg, setMsg] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const platImportRef = useRef(null); const load = async () => { const [p, invs, ref, catInv, sectInv] = await Promise.all([ api.get('/plateformes'), api.get('/investisseurs'), api.get('/plateformes/referentiel-list'), api.get('/categories-inv'), api.get('/secteurs-inv'), ]); setPlats(p); setInvestisseurs(invs); setReferentiel(ref); setCategoriesInv(catInv); setSecteursInv(sectInv); setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null); }; useEffect(() => { load(); }, []); // eslint-disable-line useEffect(() => { if (plats.length === 0) return; setSelectedPlat(prev => prev ? prev : plats[0]); }, [plats]); // eslint-disable-line const handleLogoFile = (file, setFile, setPreview) => { if (!file) { setFile(null); setPreview(null); return; } setFile(file); const reader = new FileReader(); reader.onload = (ev) => setPreview(ev.target.result); reader.readAsDataURL(file); }; /* ── Catégories d'investissement (privées) ──────────────────────── */ const saveCatInv = async (nom) => { if (!nom.trim()) return; try { const row = await api.post('/categories-inv', { nom: nom.trim() }); setCategoriesInv(prev => [...prev, row].sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom))); setShowNewCatInv(false); setNewCatInvNom(''); setErr(null); } catch (e) { setErr(e.message || 'Erreur'); } }; const renameCatInv = async (id, nom) => { if (!nom.trim()) return; try { await api.put(`/categories-inv/${id}`, { nom: nom.trim() }); setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c)); setEditingCatInv(null); } catch (e) { setErr(e.message || 'Erreur'); } }; const delCatInv = async (id) => { try { await api.del(`/categories-inv/${id}`); setCategoriesInv(prev => prev.filter(c => c.id !== id)); if (selectedCatInv?.id === id) setSelectedCatInv(null); } catch (e) { setErr(e.message || 'Erreur'); } }; /* ── Secteurs d'investissement (privés) ──────────────────────── */ const saveSectInv = async (nom) => { if (!nom.trim()) return; try { const row = await api.post('/secteurs-inv', { nom: nom.trim() }); setSecteursInv(prev => [...prev, row].sort((a, b) => b.is_global - a.is_global || a.nom.localeCompare(b.nom))); setShowNewSectInv(false); setNewSectInvNom(''); setErr(null); } catch (e) { setErr(e.message || 'Erreur'); } }; const renameSectInv = async (id, nom) => { if (!nom.trim()) return; try { await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() }); setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s)); setEditingSectInv(null); } catch (e) { setErr(e.message || 'Erreur'); } }; const delSectInv = async (id) => { try { await api.del(`/secteurs-inv/${id}`); setSecteursInv(prev => prev.filter(s => s.id !== id)); if (selectedSectInv?.id === id) setSelectedSectInv(null); } catch (e) { setErr(e.message || 'Erreur'); } }; /** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */ const uploadLogo = async (platId, file) => { if (!file) return null; const fd = new FormData(); fd.append('logo', file); return api.upload(`/plateformes/${platId}/logo`, fd); }; /* ── Plateformes ─────────────────────────────────────────────── */ const addPlat = async (e) => { e.preventDefault(); setErr(null); setMsg(null); try { const created = await api.post('/plateformes', { ...newPlat, taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== '' ? Number(newPlat.taux_fiscalite_locale) : null, methode_remboursement: newPlat.methode_remboursement || 'portefeuille', investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null, date_ouverture: newPlat.date_ouverture || null, type_pret_defaut: newPlat.type_pret_defaut || null, freq_interets_defaut: newPlat.freq_interets_defaut || null, referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null, }); if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setShowNewPlat(false); await load(); } catch (e) { setErr(e.message); } }; const delPlat = (id) => { setConfirmDelete({ message: 'Supprimer cette plateforme ?', onConfirm: async () => { try { await api.del(`/plateformes/${id}`); setSelectedPlat(null); await load(); } catch (e) { setErr(e.message); } finally { setConfirmDelete(null); } }, }); }; // ── Export/Import ZIP plateformes ───────────────────────────────────── const handlePlatExportAll = async () => { try { setPlatExporting(true); const blob = await api.blob('/plateformes/export'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { setPlatImportResult({ ok: false, msg: e.message }); } finally { setPlatExporting(false); } }; const handlePlatExportOne = async (plat) => { try { const blob = await api.blob(`/plateformes/${plat.id}/export`); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { setPlatImportResult({ ok: false, msg: e.message }); } }; const handlePlatImportZip = async (file) => { try { const fd = new FormData(); fd.append('file', file); const r = await api.upload('/plateformes/import-zip', fd); setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` }); load(); } catch (e) { setPlatImportResult({ ok: false, msg: e.message }); } }; const openEditPlat = async (p) => { setEditPlatLogoFile(null); setEditPlatLogoPreview(null); // Charger les associations catégories/secteurs d'investissement de la plateforme const [platCatsInv, platSectsInv] = await Promise.all([ api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []), api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []), ]); setEditPlat({ id: p.id, nom: p.nom, url: p.url || '', categories_inv_ids: platCatsInv.map(c => c.id), secteurs_inv_ids: platSectsInv.map(s => s.id), inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id), inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id), domiciliation: p.domiciliation || 'france', fiscalite: p.fiscalite || 'flat_tax', taux_fiscalite_locale: p.taux_fiscalite_locale ?? '', type_produit_fiscal: p.type_produit_fiscal || '2TT', methode_remboursement: p.methode_remboursement || 'portefeuille', investisseur_id: p.investisseur_id ?? null, date_ouverture: p.date_ouverture || '', logo_filename: p.logo_filename || null, type_pret_defaut: p.type_pret_defaut || '', freq_interets_defaut: p.freq_interets_defaut || '', referentiel_id: p.referentiel_id ?? null, referentiel_nom: p.referentiel_nom ?? null, overridden_fields: p.overridden_fields ?? [], }); }; const saveEditPlat = async (e) => { e.preventDefault(); setErr(null); setMsg(null); try { await api.put(`/plateformes/${editPlat.id}`, { nom: editPlat.nom, url: editPlat.url || '', domiciliation: editPlat.domiciliation, fiscalite: editPlat.fiscalite, taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== '' ? Number(editPlat.taux_fiscalite_locale) : null, type_produit_fiscal: editPlat.type_produit_fiscal || '2TT', methode_remboursement: editPlat.methode_remboursement || 'portefeuille', investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null, date_ouverture: editPlat.date_ouverture || null, type_pret_defaut: editPlat.type_pret_defaut || null, freq_interets_defaut: editPlat.freq_interets_defaut || null, }); if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile); // Sauvegarder les associations catégories/secteurs d'investissement await Promise.all([ api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }), api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }), ]); setMsg('Plateforme mise à jour.'); setTimeout(() => setMsg(null), 3000); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setEditPlat(null); await load(); } catch (e) { setErr(e.message); } }; const delLogoPlat = async (id) => { try { await api.del(`/plateformes/${id}/logo`); setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep); await load(); } catch (e) { setErr(e.message); } }; /* ── PFU CRUD ────────────────────────────────────────────────── */ return ( <> {err &&
{err}
} {msg &&
{msg}
}
{/* Colonne gauche — liste */}

Mes plateformes

Plateformes de crowdlending que vous utilisez.

dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')} onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')} /> { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
{platImportResult && setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
{plats.length === 0 && ( )} {plats.map((p, i) => { const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null; const inv = platInvestisseur(p); const cats = p.categories_inv || []; const sects = p.secteurs_inv || []; return ( setSelectedPlat(selectedPlat?.id === p.id ? null : p)} style={{ borderBottom: i < plats.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer', background: selectedPlat?.id === p.id ? 'var(--surface-2)' : 'none', }} onMouseEnter={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'var(--surface-2)'; }} onMouseLeave={e => { if (selectedPlat?.id !== p.id) e.currentTarget.style.background = 'none'; }}> ); })}
Nom Domiciliation Catégories Secteurs Détenteur Invest.
Aucune plateforme
{imgSrc ? :
×
}
{p.nom}
{p.domiciliation ? {countryLabel(p.domiciliation)} : '—'}
{cats.slice(0, 2).map(c => {c.nom})} {cats.length > 2 && +{cats.length - 2}} {cats.length === 0 && }
{sects.slice(0, 2).map(s => {s.nom})} {sects.length > 2 && +{sects.length - 2}} {sects.length === 0 && }
{inv ? memberLabel(inv) : '—'} 0 ? 'var(--text)' : 'var(--text-muted)' }}> {p.nb_investissements ?? 0}
{/* Colonne droite — détail */}
{/* Menu ⋮ plateformes */} {platOpenMenu && ( <>
setPlatOpenMenu(null)} />
{[ { icon: , label: 'Modifier', onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } }, { icon: , label: 'Exporter', onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } }, { icon: , label: 'Supprimer', onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' }, ].map(({ icon, label, onClick, color }) => ( ))}
)} setConfirmDelete(null)} /> ); }