Files
crowdlending-app/frontend/src/pages/settings/PlateformesSection.jsx
T
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

737 lines
37 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 } 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">Détail de la plateforme</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">
{plat.referentiel_id && (
<button
className="ghost"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
>
Voir le profil de la plateforme
</button>
)}
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
</div>
</div>
);
}
/* ── Panneau de détail PFU ───────────────────────────────────── */
export default function PlateformesSection() {
// ── State ──────────────────────────────────────────────────────
const [plats, setPlats] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
const [referentiel, setReferentiel] = useState([]);
const [newPlat, setNewPlat] = useState(EMPTY_PLAT);
const [newPlatLogoFile, setNewPlatLogoFile] = useState(null);
const [newPlatLogoPreview, setNewPlatLogoPreview] = useState(null);
const [editPlat, setEditPlat] = useState(null);
const [editPlatLogoFile, setEditPlatLogoFile] = useState(null);
const [editPlatLogoPreview, setEditPlatLogoPreview] = useState(null);
const [selectedPlat, setSelectedPlat] = useState(null);
const [showNewPlat, setShowNewPlat] = useState(false);
const [platOpenMenu, setPlatOpenMenu] = useState(null); // { plat, x, y }
const [platExporting, setPlatExporting] = useState(false);
const [platImportResult, setPlatImportResult] = useState(null);
// Catégories d'investissement (globales + privées)
const [categoriesInv, setCategoriesInv] = useState([]);
const [selectedCatInv, setSelectedCatInv] = useState(null);
const [editingCatInv, setEditingCatInv] = useState(null); // id en cours d'édition
const [editingNomCatInv, setEditingNomCatInv] = useState('');
const [newCatInvNom, setNewCatInvNom] = useState('');
const [showNewCatInv, setShowNewCatInv] = useState(false);
// Secteurs d'investissement (globaux + privés)
const [secteursInv, setSecteursInv] = useState([]);
const [selectedSectInv, setSelectedSectInv] = useState(null);
const [editingSectInv, setEditingSectInv] = useState(null);
const [editingNomSectInv, setEditingNomSectInv] = useState('');
const [newSectInvNom, setNewSectInvNom] = useState('');
const [showNewSectInv, setShowNewSectInv] = useState(false);
// PFU
const [showPfoDetail, setShowPfoDetail] = useState(false);
const [err, setErr] = useState(null);
const [msg, setMsg] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const platImportRef = useRef(null);
const load = async () => {
const [p, invs, ref, catInv, sectInv] = await Promise.all([
api.get('/plateformes'),
api.get('/investisseurs'),
api.get('/plateformes/referentiel-list'),
api.get('/categories-inv'),
api.get('/secteurs-inv'),
]);
setPlats(p);
setInvestisseurs(invs);
setReferentiel(ref);
setCategoriesInv(catInv);
setSecteursInv(sectInv);
setSelectedPlat(prev => prev ? (p.find(x => x.id === prev.id) ?? null) : null);
};
useEffect(() => { load(); }, []); // eslint-disable-line
useEffect(() => {
if (plats.length === 0) return;
setSelectedPlat(prev => prev ? prev : plats[0]);
}, [plats]); // eslint-disable-line
const handleLogoFile = (file, setFile, setPreview) => {
if (!file) { setFile(null); setPreview(null); return; }
setFile(file);
const reader = new FileReader();
reader.onload = (ev) => setPreview(ev.target.result);
reader.readAsDataURL(file);
};
/* ── Catégories d'investissement (privées) ──────────────────────── */
const saveCatInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/categories-inv', { nom: nom.trim() });
setCategoriesInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewCatInv(false); setNewCatInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameCatInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/categories-inv/${id}`, { nom: nom.trim() });
setCategoriesInv(prev => prev.map(c => c.id === id ? { ...c, nom: nom.trim() } : c));
setEditingCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delCatInv = async (id) => {
try {
await api.del(`/categories-inv/${id}`);
setCategoriesInv(prev => prev.filter(c => c.id !== id));
if (selectedCatInv?.id === id) setSelectedCatInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/* ── Secteurs d'investissement (privés) ──────────────────────── */
const saveSectInv = async (nom) => {
if (!nom.trim()) return;
try {
const row = await api.post('/secteurs-inv', { nom: nom.trim() });
setSecteursInv(prev => [...prev, row].sort((a, b) =>
b.is_global - a.is_global || a.nom.localeCompare(b.nom)));
setShowNewSectInv(false); setNewSectInvNom(''); setErr(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const renameSectInv = async (id, nom) => {
if (!nom.trim()) return;
try {
await api.put(`/secteurs-inv/${id}`, { nom: nom.trim() });
setSecteursInv(prev => prev.map(s => s.id === id ? { ...s, nom: nom.trim() } : s));
setEditingSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
const delSectInv = async (id) => {
try {
await api.del(`/secteurs-inv/${id}`);
setSecteursInv(prev => prev.filter(s => s.id !== id));
if (selectedSectInv?.id === id) setSelectedSectInv(null);
} catch (e) { setErr(e.message || 'Erreur'); }
};
/** Upload le logo vers le serveur après que la plateforme a été créée/sauvée */
const uploadLogo = async (platId, file) => {
if (!file) return null;
const fd = new FormData();
fd.append('logo', file);
return api.upload(`/plateformes/${platId}/logo`, fd);
};
/* ── Plateformes ─────────────────────────────────────────────── */
const addPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
const created = await api.post('/plateformes', {
...newPlat,
taux_fiscalite_locale: newPlat.fiscalite === 'avec_fiscalite_locale' && newPlat.taux_fiscalite_locale !== ''
? Number(newPlat.taux_fiscalite_locale) : null,
methode_remboursement: newPlat.methode_remboursement || 'portefeuille',
investisseur_id: newPlat.investisseur_id ? Number(newPlat.investisseur_id) : null,
date_ouverture: newPlat.date_ouverture || null,
type_pret_defaut: newPlat.type_pret_defaut || null,
freq_interets_defaut: newPlat.freq_interets_defaut || null,
referentiel_id: newPlat.referentiel_id ? Number(newPlat.referentiel_id) : null,
});
if (newPlatLogoFile) await uploadLogo(created.id, newPlatLogoFile);
setNewPlat(EMPTY_PLAT);
setNewPlatLogoFile(null);
setNewPlatLogoPreview(null);
setShowNewPlat(false);
await load();
} catch (e) { setErr(e.message); }
};
const delPlat = (id) => {
setConfirmDelete({
message: 'Supprimer cette plateforme ?',
onConfirm: async () => {
try {
await api.del(`/plateformes/${id}`);
setSelectedPlat(null);
await load();
} catch (e) { setErr(e.message); }
finally { setConfirmDelete(null); }
},
});
};
// ── Export/Import ZIP plateformes ─────────────────────────────────────
const handlePlatExportAll = async () => {
try {
setPlatExporting(true);
const blob = await api.blob('/plateformes/export');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `plateformes-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
finally { setPlatExporting(false); }
};
const handlePlatExportOne = async (plat) => {
try {
const blob = await api.blob(`/plateformes/${plat.id}/export`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${plat.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const handlePlatImportZip = async (file) => {
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.upload('/plateformes/import-zip', fd);
setPlatImportResult({ ok: true, msg: `Import terminé : ${r.created} créée(s), ${r.updated} mise(s) à jour sur ${r.total} entrée(s).` });
load();
} catch (e) { setPlatImportResult({ ok: false, msg: e.message }); }
};
const openEditPlat = async (p) => {
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
// Charger les associations catégories/secteurs d'investissement de la plateforme
const [platCatsInv, platSectsInv] = await Promise.all([
api.get(`/plateformes/${p.id}/categories-inv`).catch(() => []),
api.get(`/plateformes/${p.id}/secteurs-inv`).catch(() => []),
]);
setEditPlat({
id: p.id, nom: p.nom, url: p.url || '',
categories_inv_ids: platCatsInv.map(c => c.id),
secteurs_inv_ids: platSectsInv.map(s => s.id),
inherited_cat_ids: platCatsInv.filter(c => c.is_inherited).map(c => c.id),
inherited_sect_ids: platSectsInv.filter(s => s.is_inherited).map(s => s.id),
domiciliation: p.domiciliation || 'france',
fiscalite: p.fiscalite || 'flat_tax',
taux_fiscalite_locale: p.taux_fiscalite_locale ?? '',
type_produit_fiscal: p.type_produit_fiscal || '2TT',
methode_remboursement: p.methode_remboursement || 'portefeuille',
investisseur_id: p.investisseur_id ?? null,
date_ouverture: p.date_ouverture || '',
logo_filename: p.logo_filename || null,
type_pret_defaut: p.type_pret_defaut || '',
freq_interets_defaut: p.freq_interets_defaut || '',
referentiel_id: p.referentiel_id ?? null,
referentiel_nom: p.referentiel_nom ?? null,
overridden_fields: p.overridden_fields ?? [],
});
};
const saveEditPlat = async (e) => {
e.preventDefault(); setErr(null); setMsg(null);
try {
await api.put(`/plateformes/${editPlat.id}`, {
nom: editPlat.nom, url: editPlat.url || '',
domiciliation: editPlat.domiciliation,
fiscalite: editPlat.fiscalite,
taux_fiscalite_locale: editPlat.fiscalite === 'avec_fiscalite_locale' && editPlat.taux_fiscalite_locale !== ''
? Number(editPlat.taux_fiscalite_locale) : null,
type_produit_fiscal: editPlat.type_produit_fiscal || '2TT',
methode_remboursement: editPlat.methode_remboursement || 'portefeuille',
investisseur_id: editPlat.investisseur_id ? Number(editPlat.investisseur_id) : null,
date_ouverture: editPlat.date_ouverture || null,
type_pret_defaut: editPlat.type_pret_defaut || null,
freq_interets_defaut: editPlat.freq_interets_defaut || null,
});
if (editPlatLogoFile) await uploadLogo(editPlat.id, editPlatLogoFile);
// Sauvegarder les associations catégories/secteurs d'investissement
await Promise.all([
api.put(`/plateformes/${editPlat.id}/categories-inv`, { ids: editPlat.categories_inv_ids || [] }),
api.put(`/plateformes/${editPlat.id}/secteurs-inv`, { ids: editPlat.secteurs_inv_ids || [] }),
]);
setMsg('Plateforme mise à jour.');
setTimeout(() => setMsg(null), 3000);
setEditPlatLogoFile(null);
setEditPlatLogoPreview(null);
setEditPlat(null);
await load();
} catch (e) { setErr(e.message); }
};
const delLogoPlat = async (id) => {
try {
await api.del(`/plateformes/${id}/logo`);
setEditPlat(ep => ep ? { ...ep, logo_filename: null } : ep);
await load();
} catch (e) { setErr(e.message); }
};
/* ── PFU CRUD ────────────────────────────────────────────────── */
return (
<>
{err && <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={() => { setNewPlat(EMPTY_PLAT); setErr(null); setShowNewPlat(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>
</>
)}
<ConfirmModal
open={!!confirmDelete}
title="Supprimer la plateforme"
message={confirmDelete?.message}
onConfirm={confirmDelete?.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
);
}