1196 lines
57 KiB
React
1196 lines
57 KiB
React
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|