Initial commit
This commit is contained in:
@@ -0,0 +1,736 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../api.js';
|
||||
import InvSelect from '../../components/InvSelect.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../../components/CountrySelect.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
import { memberLabel, fmtDate } from '../../utils/format.js';
|
||||
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
|
||||
const FISCALITE_LABELS = {
|
||||
flat_tax: 'Flat Tax',
|
||||
sans_fiscalite_locale: 'Sans fiscalité locale',
|
||||
avec_fiscalite_locale: 'Avec fiscalité locale',
|
||||
};
|
||||
|
||||
const METHODE_REMB_LABELS = {
|
||||
portefeuille: 'Porte-monnaie de la plateforme',
|
||||
compte_courant: "Compte courant de l'investisseur",
|
||||
choix_investisseur: "Au choix de l'investisseur (sur la plateforme)",
|
||||
};
|
||||
|
||||
/** Reconstruit un objet investisseur minimal depuis les colonnes dénormalisées de la plateforme */
|
||||
function platInvestisseur(p) {
|
||||
if (!p.investisseur_id) return null;
|
||||
return { id: p.investisseur_id, nom: p.investisseur_nom, prenom: p.investisseur_prenom, type: p.investisseur_type, type_fiscal: p.investisseur_type_fiscal };
|
||||
}
|
||||
|
||||
function fmtFiscalite(p) {
|
||||
if (!p.fiscalite) return '—';
|
||||
const base = FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite;
|
||||
if (p.fiscalite === 'avec_fiscalite_locale' && p.taux_fiscalite_locale != null) {
|
||||
return `${base} (${p.taux_fiscalite_locale} %)`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const EMPTY_PLAT = { nom: '', url: '', domiciliation: 'france', fiscalite: 'flat_tax', taux_fiscalite_locale: '', type_produit_fiscal: '2TT', methode_remboursement: 'portefeuille', investisseur_id: null, date_ouverture: '', logo_filename: null, type_pret_defaut: '', freq_interets_defaut: '', referentiel_id: null };
|
||||
|
||||
const LOGO_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/logos/';
|
||||
const logoUrl = (filename) => filename ? LOGO_BASE + filename : null;
|
||||
|
||||
function applyDomiciliationChange(state, newDomicil) {
|
||||
const next = { ...state, domiciliation: newDomicil };
|
||||
if (newDomicil === 'FR') {
|
||||
next.fiscalite = 'flat_tax';
|
||||
next.taux_fiscalite_locale = '';
|
||||
} else if (state.fiscalite === 'flat_tax') {
|
||||
next.fiscalite = 'sans_fiscalite_locale';
|
||||
next.taux_fiscalite_locale = '';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyFiscaliteChange(state, newFiscalite) {
|
||||
const next = { ...state, fiscalite: newFiscalite };
|
||||
if (newFiscalite !== 'avec_fiscalite_locale') next.taux_fiscalite_locale = '';
|
||||
return next;
|
||||
}
|
||||
|
||||
function platsToCSV(plats) {
|
||||
const BOM = '';
|
||||
const sep = ';';
|
||||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const headers = ['ID', 'Nom', 'URL', 'Domiciliation', 'Fiscalité', 'Taux fiscal local (%)', 'Créé le'];
|
||||
const rows = plats.map(p => [
|
||||
p.id, p.nom, p.url || '',
|
||||
countryLabel(p.domiciliation),
|
||||
FISCALITE_LABELS[p.fiscalite] ?? p.fiscalite ?? '',
|
||||
p.taux_fiscalite_locale ?? '',
|
||||
p.created_at ? new Date(p.created_at).toLocaleDateString('fr-FR') : '',
|
||||
]);
|
||||
return BOM + [headers, ...rows].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
|
||||
function platsToXLS(plats) {
|
||||
const esc = v => String(v ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').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">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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user