Files
crowdlending-app/frontend/src/pages/settings/PlateformesSection.jsx
T
2026-07-03 20:01:43 +02:00

1196 lines
57 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const cell = (v, t = 'String') => `<Cell><Data ss:Type="${t}">${esc(v)}</Data></Cell>`;
const mkRow = cells => ` <Row>${cells.join('')}</Row>`;
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 `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Styles><Style ss:ID="h"><Font ss:Bold="1"/></Style></Styles>
<Worksheet ss:Name="Plateformes">
<Table>
${header}
${dataRows}
</Table>
</Worksheet>
</Workbook>`;
}
/* ── Icônes utilitaires ──────────────────────────────────────── */
function IconExport() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
}
function IconCSV() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="10" y1="9" x2="14" y2="9"/></svg>;
}
function IconXLS() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>;
}
function IconJSON() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1.5 1.5 0 0 1 0 3H8v-3z"/><path d="M14 13h2v1.5a1.5 1.5 0 0 1-3 0V13z"/></svg>;
}
function IconImport() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 14 12 9 17 14"/><line x1="12" y1="9" x2="12" y2="21"/></svg>;
}
/* ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<button type="button" className="icon-btn" disabled={disabled}
onClick={() => setOpen(o => !o)} title={title}
aria-haspopup="menu" aria-expanded={open}>
<IconExport />
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<IconCSV /><span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<IconXLS /><span><strong>Format Excel</strong><small>Fichier .xls natif Microsoft</small></span>
</button>
{onJSON && (
<button role="menuitem" onClick={() => choose(onJSON)}>
<IconJSON /><span><strong>Format JSON</strong><small>Données structurées</small></span>
</button>
)}
</div>
)}
</div>
);
}
/* ── 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 (
<div className="dr-detail dr-detail-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25, marginBottom: 8 }}>
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span>Sélectionnez une plateforme</span>
</div>
);
const inv = platInvestisseur(plat);
const fields = [
{ label: 'Plateforme', value: plat.nom },
plat.url && {
label: 'Site web',
value: <a href={plat.url} target="_blank" rel="noreferrer"
style={{ wordBreak: 'break-all', color: 'var(--primary)' }}>{plat.url}</a>,
},
{ label: 'Détenteur', value: inv ? memberLabel(inv) : '—' },
plat.date_ouverture && { label: "Date d'ouverture", value: fmtDate(plat.date_ouverture) },
{ label: 'Domiciliation', value: plat.domiciliation ? <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code={plat.domiciliation} size={16} />{countryLabel(plat.domiciliation)}</span> : '—' },
{ 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: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platCatsInv.map(c => <span key={c.id} className="chip-cat">{c.nom}</span>)}</div>,
},
platSectsInv.length > 0 && {
label: "Secteurs d'investissement",
value: <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>{platSectsInv.map(s => <span key={s.id} className="chip-sect">{s.nom}</span>)}</div>,
},
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 (
<div className="dr-detail">
{logo && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
<img src={logo} alt={`Logo ${plat.nom}`} className="logo-plateforme"
style={{ maxHeight: 56, maxWidth: 160, objectFit: 'contain' }}
onError={e => { e.currentTarget.style.display = 'none'; }}
/>
</div>
)}
<div className="dr-detail-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Détail de la plateforme</span>
{plat.referentiel_id && (
<button
type="button"
className="chip-cat"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
title="Voir le profil de la plateforme"
style={{ cursor: 'pointer' }}
>
Profil
</button>
)}
</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
<span className="dr-detail-label">{f.label}</span>
<span className="dr-detail-value">{f.value}</span>
</div>
))}
</div>
<div className="dr-detail-footer">
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
</div>
</div>
);
}
/* ── Panneau de détail PFU ───────────────────────────────────── */
/* ── Picker référentiel ──────────────────────────────────────── */
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
const [search, setSearch] = useState('');
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
if (!open) return null;
const q = search.trim().toLowerCase();
const filtered = referentiel.filter(r => {
if (domFilter === 'fr' && r.domiciliation !== 'FR') return false;
if (domFilter === 'etr' && r.domiciliation === 'FR') return false;
if (q && !r.nom.toLowerCase().includes(q)) return false;
return true;
});
const imgFor = (r) => {
const f = r.icone_filename || r.logo_filename;
return f ? (LOGO_BASE + f) : null;
};
return (
<Modal
open={open}
title="Ajouter une plateforme"
onClose={onClose}
width={700}
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<button type="button" className="ghost" onClick={() => { onClose(); onManual(); }}>
✏️ Définir manuellement
</button>
<button type="button" onClick={onClose}>Fermer</button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
Sélectionnez une plateforme depuis le référentiel pour l'ajouter à votre portefeuille.
Les informations (domiciliation, fiscalité…) seront pré-remplies automatiquement.
</p>
{/* Barre de recherche + chips filtre */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="search"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Rechercher une plateforme…"
autoFocus
style={{ flex: 1, boxSizing: 'border-box', padding: '9px 14px', borderRadius: 8,
border: '1.5px solid var(--border)', fontSize: 'var(--fs-sm)', background: 'var(--surface)',
color: 'var(--text)', outline: 'none' }}
/>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{[
{ key: 'all', content: 'Toutes' },
{ key: 'fr', content: <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}><FlagIcon code="FR" size={14} /> Françaises</span> },
{ key: 'etr', content: <span style={{ display:'inline-flex', alignItems:'center', gap:5 }}>🌍 Étrangères</span> },
].map(({ key, content }) => (
<button key={key} type="button" onClick={() => setDomFilter(key)} style={{
padding: '5px 14px', borderRadius: 20, fontSize: 'var(--fs-sm)', fontWeight: 500,
cursor: 'pointer', border: '1.5px solid',
borderColor: domFilter === key ? 'var(--primary)' : 'var(--border)',
background: domFilter === key ? 'var(--primary)' : 'var(--surface)',
color: domFilter === key ? '#fff' : 'var(--text-muted)',
transition: 'all .15s',
display: 'inline-flex', alignItems: 'center',
}}>{content}</button>
))}
</div>
{/* Grille des plateformes */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 10,
maxHeight: 380,
overflowY: 'auto',
padding: '2px 2px 4px',
}}>
{filtered.length === 0 && (
<div style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-muted)',
padding: '32px 0', fontSize: 'var(--fs-sm)' }}>
Aucune plateforme trouvée pour « {search} »
</div>
)}
{filtered.map(r => {
const img = imgFor(r);
return (
<button
key={r.id}
type="button"
onClick={() => onSelect(r)}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 8, padding: '16px 10px 12px',
background: 'var(--surface)', border: '1.5px solid var(--border)',
borderRadius: 12, cursor: 'pointer', textAlign: 'center',
transition: 'border-color .15s, box-shadow .15s, transform .1s',
minHeight: 110,
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--primary)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.10)';
e.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}}
>
{/* Logo / Placeholder */}
{img
? <img src={img} alt={r.nom}
style={{ width: 48, height: 48, objectFit: 'contain', borderRadius: 8, flexShrink: 0 }} />
: <div style={{
width: 48, height: 48, borderRadius: 8, flexShrink: 0,
background: 'var(--surface-2)', border: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: 'var(--text-muted)',
}}>🏦</div>
}
{/* Nom */}
<span style={{
fontSize: 'var(--fs-sm)', fontWeight: 600, color: 'var(--text)',
lineHeight: 1.2, wordBreak: 'break-word',
}}>
{r.nom}
</span>
</button>
);
})}
</div>
{referentiel.length > 0 && (
<p style={{ margin: 0, fontSize: 11, color: 'var(--text-muted)', textAlign: 'center' }}>
{referentiel.length} plateforme{referentiel.length > 1 ? 's' : ''} dans le référentiel
</p>
)}
</div>
</Modal>
);
}
/* ── Formulaire plateforme partagé (création + édition) ─────────── */
function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPreview,
investisseurs, referentiel, categoriesInv, secteursInv, isEdit = false, onDelLogo }) {
const isFrance = state.domiciliation === 'FR';
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (!file) return;
setLogoFile(file);
const reader = new FileReader();
reader.onload = ev => setLogoPreview(ev.target.result);
reader.readAsDataURL(file);
};
const row = (label, content, hint) => (
<div>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 'var(--fs-sm)' }}>{label}</label>
{content}
{hint && <p style={{ margin: '3px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>{hint}</p>}
</div>
);
return (
<>
{/* Nom */}
{row('Nom *', (
<input required value={state.nom} onChange={e => setter(s => ({ ...s, nom: e.target.value }))}
placeholder="Ex: Lendosphere" style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Référentiel */}
{referentiel.length > 0 && row('Lier au référentiel', (
<select value={state.referentiel_id ?? ''} onChange={e => {
const rid = e.target.value ? Number(e.target.value) : null;
const ref = referentiel.find(r => r.id === rid);
setter(s => ({ ...s, referentiel_id: rid, ...(ref && !s.nom ? { nom: ref.nom } : {}) }));
}} style={{ width: '100%' }}>
<option value="">— Aucun —</option>
{referentiel.map(r => <option key={r.id} value={r.id}>{r.nom}</option>)}
</select>
))}
{/* URL */}
{row('URL', (
<input type="url" value={state.url || ''} onChange={e => setter(s => ({ ...s, url: e.target.value }))}
placeholder="https://..." style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Domiciliation */}
{row('Domiciliation', (
<CountrySelect
value={state.domiciliation || ''}
onChange={code => setter(s => applyDomiciliationChange(s, code))}
placeholder="Pays de la plateforme"
/>
))}
{/* Fiscalité */}
{row('Fiscalité', (
<select value={state.fiscalite || 'flat_tax'} disabled={isFrance}
onChange={e => setter(s => applyFiscaliteChange(s, e.target.value))}
style={{ width: '100%' }}>
{isFrance
? <option value="flat_tax">Flat Tax (France)</option>
: <>
<option value="sans_fiscalite_locale">Sans fiscalité locale</option>
<option value="avec_fiscalite_locale">Avec fiscalité locale</option>
</>
}
</select>
), isFrance ? 'Forcée à Flat Tax pour les plateformes françaises.' : null)}
{/* Taux fiscalité locale */}
{state.fiscalite === 'avec_fiscalite_locale' && row('Taux de fiscalité locale (%)', (
<input type="number" min="0" max="100" step="0.1"
value={state.taux_fiscalite_locale || ''}
onChange={e => setter(s => ({ ...s, taux_fiscalite_locale: e.target.value }))}
placeholder="Ex: 15" style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Type produit fiscal */}
{row('Case fiscale (déclaration)', (
<select value={state.type_produit_fiscal || '2TT'} onChange={e => setter(s => ({ ...s, type_produit_fiscal: e.target.value }))}
style={{ width: '100%' }}>
<option value="2TT">2TT — Intérêts soumis PFL/PFU</option>
<option value="2TR">2TR — Intérêts soumis au barème</option>
<option value="2BH">2BH — Intérêts bruts avant PFL</option>
<option value="2CK">2CK — PFL déjà prélevé</option>
<option value="2TY">2TY — Revenus de capitaux (autre)</option>
</select>
))}
{/* Méthode de remboursement */}
{row('Versement des remboursements', (
<select value={state.methode_remboursement || 'portefeuille'} onChange={e => setter(s => ({ ...s, methode_remboursement: e.target.value }))}
style={{ width: '100%' }}>
<option value="portefeuille">Porte-monnaie de la plateforme</option>
<option value="compte_courant">Compte courant de l'investisseur</option>
<option value="choix_investisseur">Au choix de l'investisseur</option>
</select>
))}
{/* Type prêt par défaut */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{row('Type de prêt par défaut', (
<select value={state.type_pret_defaut || ''} onChange={e => setter(s => ({ ...s, type_pret_defaut: e.target.value }))}
style={{ width: '100%' }}>
<option value="">— Non défini —</option>
<option value="in_fine">In fine</option>
<option value="amortissable">Amortissable</option>
<option value="differe">Différé</option>
</select>
))}
{row('Fréquence intérêts par défaut', (
<select value={state.freq_interets_defaut || ''} onChange={e => setter(s => ({ ...s, freq_interets_defaut: e.target.value }))}
style={{ width: '100%' }}>
<option value="">— Non défini —</option>
<option value="mensuel">Mensuel</option>
<option value="trimestriel">Trimestriel</option>
<option value="in_fine">In fine</option>
</select>
))}
</div>
{/* Date d'ouverture */}
{row("Date d'ouverture du compte", (
<input type="date" value={state.date_ouverture || ''} onChange={e => setter(s => ({ ...s, date_ouverture: e.target.value }))}
style={{ width: '100%', boxSizing: 'border-box' }} />
))}
{/* Détenteur */}
{investisseurs.length > 1 && row('Détenteur', (
<InvSelect
value={state.investisseur_id}
onChange={id => setter(s => ({ ...s, investisseur_id: id }))}
investisseurs={investisseurs}
placeholder="— Sélectionner —"
/>
))}
{/* Catégories d'investissement (édition seulement) */}
{isEdit && categoriesInv.length > 0 && row('Catégories', (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{categoriesInv.map(c => {
const inherited = (state.inherited_cat_ids || []).includes(c.id);
const checked = (state.categories_inv_ids || []).includes(c.id);
return (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
onChange={e => setter(s => ({
...s,
categories_inv_ids: e.target.checked
? [...(s.categories_inv_ids || []), c.id]
: (s.categories_inv_ids || []).filter(id => id !== c.id),
}))} />
<span className="chip-cat" style={{ fontSize: 11 }}>{c.nom}{inherited ? ' ↗' : ''}</span>
</label>
);
})}
</div>
))}
{/* Secteurs (édition seulement) */}
{isEdit && secteursInv.length > 0 && row('Secteurs', (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{secteursInv.map(s => {
const inherited = (state.inherited_sect_ids || []).includes(s.id);
const checked = (state.secteurs_inv_ids || []).includes(s.id);
return (
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
onChange={e => setter(prev => ({
...prev,
secteurs_inv_ids: e.target.checked
? [...(prev.secteurs_inv_ids || []), s.id]
: (prev.secteurs_inv_ids || []).filter(id => id !== s.id),
}))} />
<span className="chip-sect" style={{ fontSize: 11 }}>{s.nom}{inherited ? ' ↗' : ''}</span>
</label>
);
})}
</div>
))}
{/* Logo */}
{row('Logo', (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{(logoPreview || (isEdit && state.logo_filename)) && (
<img src={logoPreview || (LOGO_BASE + state.logo_filename)} alt="logo"
style={{ width: 40, height: 40, objectFit: 'contain', borderRadius: 4, border: '1px solid var(--border)' }} />
)}
<input type="file" accept="image/*" onChange={handleLogoChange} style={{ flex: 1 }} />
{isEdit && state.logo_filename && !logoFile && (
<button type="button" className="danger" style={{ padding: '4px 10px', fontSize: 12 }} onClick={onDelLogo}>Supprimer</button>
)}
</div>
))}
</>
);
}
export default function PlateformesSection() {
const [searchParams, setSearchParams] = useSearchParams();
// ── 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 [showAddPicker, setShowAddPicker] = useState(false);
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
// Ouverture automatique du picker d'ajout (venant de l'état vide "Aucune plateforme configurée")
useEffect(() => {
if (searchParams.get('openAdd') === '1') {
setErr(null);
setShowAddPicker(true);
const next = new URLSearchParams(searchParams);
next.delete('openAdd');
setSearchParams(next, { replace: true });
}
}, [searchParams]); // 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);
};
/* ── Ajouter depuis le référentiel ──────────────────────────── */
const addPlatFromRef = async (ref) => {
try {
await api.post('/plateformes', {
nom: ref.nom,
domiciliation: ref.domiciliation || 'FR',
fiscalite: ref.fiscalite || 'flat_tax',
taux_fiscalite_locale: ref.taux_fiscalite_locale ?? null,
type_produit_fiscal: ref.type_produit_fiscal || '2TT',
methode_remboursement: 'portefeuille',
referentiel_id: ref.id,
});
setShowAddPicker(false);
await load();
} catch (e) { setErr(e.message); }
};
/* ── 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 && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
<div className="dr-mouvements-layout">
{/* Colonne gauche — liste */}
<div className="dr-mouvements-list">
<div className="card" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h3 style={{ margin: 0 }}>Mes plateformes</h3>
<p className="text-muted" style={{ margin: '4px 0 0', fontSize: 'var(--fs-sm)' }}>
Plateformes de crowdlending que vous utilisez.
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<ExportDropdown
disabled={plats.length === 0}
onCSV={() => dlBlob(platsToCSV(plats), 'plateformes.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(platsToXLS(plats), 'plateformes.xls', 'application/vnd.ms-excel')}
/>
<button onClick={handlePlatExportAll} disabled={platExporting || plats.length === 0}
title="Exporter toutes les plateformes en ZIP"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{platExporting ? '…' : 'Export ZIP'}
</button>
<button onClick={() => platImportRef.current?.click()}
title="Importer un fichier ZIP de plateformes"
style={{ padding: '7px 12px', borderRadius: 6, border: '1px solid var(--border)', fontSize: 13, fontWeight: 600, cursor: 'pointer', background: 'var(--surface-2)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import ZIP
</button>
<input ref={platImportRef} type="file" accept=".zip" style={{ display: 'none' }}
onChange={e => { const f = e.target.files[0]; e.target.value = ''; if (f) handlePlatImportZip(f); }} />
<button className="primary" type="button"
onClick={() => { setErr(null); setShowAddPicker(true); }}>
+ Ajouter
</button>
</div>
</div>
{platImportResult && <ResultBanner result={platImportResult} onDismiss={() => setPlatImportResult(null)} style={{ marginBottom: 12 }} />}
<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 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' }}>Détenteur</th>
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{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 (
<tr key={p.id}
onClick={() => 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'; }}>
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
{imgSrc
? <img src={imgSrc} alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
: <div style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
}
</td>
<td style={{ padding: '10px 16px' }}>
<div style={{ fontWeight: 600 }}>{p.nom}</div>
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{p.domiciliation
? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<FlagIcon code={p.domiciliation} size={15} />{countryLabel(p.domiciliation)}
</span>
: '—'}
</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{cats.slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
{cats.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{cats.length - 2}</span>}
{cats.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 }}>
{sects.slice(0, 2).map(s => <span key={s.id} className="chip-sect" style={{ fontSize: 11 }}>{s.nom}</span>)}
{sects.length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{sects.length - 2}</span>}
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : '—'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{p.nb_investissements ?? 0}
</td>
<td style={{ padding: '10px 8px', textAlign: 'right' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setPlatOpenMenu({ plat: p, x: rect.right, y: rect.bottom }); }}
></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
{/* Colonne droite — détail */}
<div className="dr-mouvements-detail">
<PlatDetailPanel
plat={selectedPlat}
onEdit={openEditPlat}
/>
</div>
</div>
{/* Menu ⋮ plateformes */}
{platOpenMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setPlatOpenMenu(null)} />
<div style={{ position: 'fixed', left: platOpenMenu.x, top: platOpenMenu.y,
transform: 'translateX(-100%) translateY(4px)', 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: 170 }}>
{[
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>, label: 'Modifier',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); openEditPlat(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>, label: 'Exporter',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); handlePlatExportOne(p); } },
{ icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>, label: 'Supprimer',
onClick: () => { const p = platOpenMenu.plat; setPlatOpenMenu(null); delPlat(p.id); }, color: 'var(--danger)' },
].map(({ icon, label, onClick, color }) => (
<button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}>
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
{label}
</button>
))}
</div>
</>
)}
{/* ── Picker : choisir depuis le référentiel ou manuellement ── */}
<PlatPickerModal
open={showAddPicker}
onClose={() => setShowAddPicker(false)}
referentiel={referentiel}
onSelect={addPlatFromRef}
onManual={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(true); }}
err={err}
/>
{/* ── Modal : Ajouter une plateforme ── */}
<Modal
open={showNewPlat}
title="Nouvelle plateforme"
onClose={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}
width={600}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%', gap: 8 }}>
<button type="button" onClick={() => { setShowNewPlat(false); setNewPlat(EMPTY_PLAT); setNewPlatLogoFile(null); setNewPlatLogoPreview(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-new-plat" type="submit">Créer</button>
</div>
}
>
<form id="form-new-plat" onSubmit={addPlat} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{err && <div className="error">{err}</div>}
<PlatForm
state={newPlat} setter={setNewPlat}
logoFile={newPlatLogoFile} setLogoFile={setNewPlatLogoFile}
logoPreview={newPlatLogoPreview} setLogoPreview={setNewPlatLogoPreview}
investisseurs={investisseurs} referentiel={referentiel}
categoriesInv={[]} secteursInv={[]}
/>
</form>
</Modal>
{/* ── Modal : Modifier une plateforme ── */}
<Modal
open={!!editPlat}
title="Modifier la plateforme"
onClose={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}
width={600}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button className="danger" type="button" onClick={() => { const p = editPlat; setEditPlat(null); delPlat(p.id); }}>Supprimer</button>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={() => { setEditPlat(null); setEditPlatLogoFile(null); setEditPlatLogoPreview(null); setErr(null); }}>Annuler</button>
<button className="primary" form="form-edit-plat" type="submit">Enregistrer</button>
</div>
</div>
}
>
{editPlat && (
<form id="form-edit-plat" onSubmit={saveEditPlat} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{err && <div className="error">{err}</div>}
<PlatForm
state={editPlat} setter={setEditPlat}
logoFile={editPlatLogoFile} setLogoFile={setEditPlatLogoFile}
logoPreview={editPlatLogoPreview} setLogoPreview={setEditPlatLogoPreview}
investisseurs={investisseurs} referentiel={referentiel}
categoriesInv={categoriesInv} secteursInv={secteursInv}
isEdit
onDelLogo={() => delLogoPlat(editPlat.id)}
/>
</form>
)}
</Modal>
<ConfirmModal
open={!!confirmDelete}
title="Supprimer la plateforme"
message={confirmDelete?.message}
onConfirm={confirmDelete?.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}