457 lines
18 KiB
React
457 lines
18 KiB
React
import { useEffect, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { api } from '../api.js';
|
|
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
|
import { fmtDate } from '../utils/format.js';
|
|
import ResultBanner from '../components/ResultBanner.jsx';
|
|
|
|
const MODULES = {
|
|
depots_retraits: {
|
|
label: 'Dépôts / Retraits',
|
|
required: ['date_operation', 'type', 'montant'],
|
|
optional: ['plateforme_id', 'libelle', 'reference'],
|
|
needsInvestisseur: true,
|
|
},
|
|
investissements: {
|
|
label: 'Investissements',
|
|
required: ['nom_projet', 'date_souscription', 'montant_investi'],
|
|
optional: ['plateforme_id', 'emetteur', 'date_premiere_echeance', 'date_cible', 'taux_interet', 'duree_mois', 'type_remb', 'freq_interets', 'statut', 'reference'],
|
|
needsInvestisseur: true,
|
|
},
|
|
remboursements: {
|
|
label: 'Remboursements',
|
|
required: ['investissement_id', 'date_remb'],
|
|
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
|
|
needsInvestisseur: true,
|
|
},
|
|
plateformes: {
|
|
label: 'Plateformes',
|
|
required: ['nom'],
|
|
optional: ['url', 'notes'],
|
|
needsInvestisseur: false,
|
|
note: 'Les plateformes dont le nom existe déjà seront ignorées (pas d\'écrasement).',
|
|
},
|
|
taux_pfu: {
|
|
label: 'Flat Tax — Taux PFU',
|
|
required: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'],
|
|
optional: [],
|
|
needsInvestisseur: false,
|
|
global: true,
|
|
note: 'Table de référence globale. Si une année existe déjà, ses taux seront mis à jour (upsert).',
|
|
},
|
|
};
|
|
|
|
const MODULE_LABEL = {
|
|
depots_retraits: 'Dépôts / Retraits',
|
|
investissements: 'Investissements',
|
|
remboursements: 'Remboursements',
|
|
plateformes: 'Plateformes',
|
|
taux_pfu: 'Flat Tax — Taux PFU',
|
|
};
|
|
|
|
export default function Imports() {
|
|
const { activeId } = useInvestisseur();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
// ── Import classique (xlsx/csv/json) ──────────────────────────
|
|
const [module, setModule] = useState('depots_retraits');
|
|
const [file, setFile] = useState(null);
|
|
const [preview, setPreview] = useState(null);
|
|
const [mapping, setMapping] = useState({});
|
|
const [defaults, setDefaults] = useState({});
|
|
const [plats, setPlats] = useState([]);
|
|
const [investissements,setInvestissements] = useState([]);
|
|
const [history, setHistory] = useState([]);
|
|
const [result, setResult] = useState(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState(null);
|
|
|
|
// ── Import dossier investissement ─────────────────────────────
|
|
const [dossierFile, setDossierFile] = useState(null);
|
|
const [dossierPreview, setDossierPreview] = useState(null); // parsed JSON for review
|
|
const [dossierResult, setDossierResult] = useState(null);
|
|
const [dossierBusy, setDossierBusy] = useState(false);
|
|
const [dossierErr, setDossierErr] = useState(null);
|
|
const dossierInputRef = useRef(null);
|
|
|
|
// History + plateformes sont user-scoped → chargement sans activeId
|
|
useEffect(() => {
|
|
api.get('/imports/history').then(setHistory).catch(() => {});
|
|
api.get('/plateformes').then(setPlats).catch(() => {});
|
|
}, []);
|
|
|
|
// Investissements sont investisseur-scoped → besoin de activeId
|
|
useEffect(() => {
|
|
if (!activeId) return;
|
|
api.get('/investissements').then(setInvestissements).catch(() => {});
|
|
}, [activeId]);
|
|
|
|
const def = MODULES[module];
|
|
const allTargets = def ? [...def.required, ...def.optional] : [];
|
|
const missingInv = def?.needsInvestisseur && !activeId;
|
|
|
|
const onPreview = async () => {
|
|
if (!file) return;
|
|
setBusy(true); setErr(null); setResult(null);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const r = await api.upload('/imports/preview', fd);
|
|
setPreview(r);
|
|
// Auto-map colonnes dont le nom correspond à une cible
|
|
const auto = {};
|
|
for (const t of allTargets) {
|
|
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
|
|
if (col) auto[t] = col;
|
|
}
|
|
setMapping(auto);
|
|
} catch (e) { setErr(e.message); }
|
|
finally { setBusy(false); }
|
|
};
|
|
|
|
const apply = async () => {
|
|
setBusy(true); setErr(null);
|
|
try {
|
|
const r = await api.post('/imports/apply', {
|
|
tempId: preview.tempId, module, mapping, defaults,
|
|
originalFilename: file?.name ?? preview.filename,
|
|
});
|
|
setResult({
|
|
ok: true,
|
|
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées${r.skipped > 0 ? `, ${r.skipped} ignorées` : ''}.${r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''}`,
|
|
});
|
|
setPreview(null); setFile(null); setMapping({}); setDefaults({});
|
|
api.get('/imports/history').then(setHistory).catch(() => {});
|
|
// Recharger les plateformes si c'est ce qui vient d'être importé
|
|
if (module === 'plateformes') {
|
|
api.get('/plateformes').then(setPlats).catch(() => {});
|
|
}
|
|
} catch (e) { setErr(e.message); }
|
|
finally { setBusy(false); }
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="topbar"><h2>Import Données</h2></div>
|
|
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>1. Fichier source</h3>
|
|
<div className="row">
|
|
<div>
|
|
<label>Module cible</label>
|
|
<select value={module} onChange={e => {
|
|
setModule(e.target.value);
|
|
setPreview(null); setMapping({}); setResult(null); setErr(null);
|
|
}}>
|
|
{Object.entries(MODULES).map(([k, v]) => (
|
|
<option key={k} value={k}>{v.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ flex: 2 }}>
|
|
<label>Fichier .xlsx, .csv ou .json</label>
|
|
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
|
|
setFile(e.target.files[0]);
|
|
setPreview(null); setResult(null); setErr(null);
|
|
}} />
|
|
</div>
|
|
<div>
|
|
<button className="primary" onClick={onPreview}
|
|
disabled={!file || busy || missingInv}>
|
|
{busy ? '…' : 'Analyser'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Note contextuelle du module sélectionné */}
|
|
{def?.note && (
|
|
<div className="import-module-note">
|
|
{def.global && (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
style={{ flexShrink: 0, marginTop: 1, color: 'var(--warning)' }}>
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
)}
|
|
{def.note}
|
|
</div>
|
|
)}
|
|
|
|
{/* Avertissement si module investisseur-scoped mais aucun investisseur actif */}
|
|
{missingInv && (
|
|
<div className="error" style={{ marginTop: 10 }}>
|
|
Sélectionnez un investisseur actif avant d'importer ce module.
|
|
</div>
|
|
)}
|
|
|
|
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
|
|
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
|
|
</div>
|
|
|
|
{preview && (
|
|
<>
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
|
|
<p className="text-muted" style={{ fontSize: 12 }}>
|
|
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
|
|
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
|
|
{' '}Si la colonne n'existe pas, fournissez une valeur par défaut.
|
|
</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Champ cible</th>
|
|
<th>Colonne Excel</th>
|
|
<th>Valeur par défaut</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allTargets.map(t => {
|
|
const isReq = def.required.includes(t);
|
|
return (
|
|
<tr key={t}>
|
|
<td>
|
|
<code style={{ fontSize: 11 }}>{t}</code>
|
|
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
|
|
</td>
|
|
<td>
|
|
<select value={mapping[t] || ''}
|
|
onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
|
|
<option value="">— ignorer —</option>
|
|
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
|
|
</select>
|
|
</td>
|
|
<td>
|
|
{t === 'plateforme_id' ? (
|
|
<select value={defaults[t] || ''}
|
|
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
|
<option value="">—</option>
|
|
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}{multiDetenteur && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}</option>)}
|
|
</select>
|
|
) : t === 'investissement_id' ? (
|
|
<select value={defaults[t] || ''}
|
|
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
|
|
<option value="">—</option>
|
|
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
|
|
</select>
|
|
) : (
|
|
<input value={defaults[t] || ''}
|
|
onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
|
|
placeholder={t === 'statut' ? 'ex. en_cours' : t === 'type' ? 'ex. depot' : ''} />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
<button onClick={() => { setPreview(null); setMapping({}); }}>Annuler</button>
|
|
<button className="primary" onClick={apply} disabled={busy || missingInv}>
|
|
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Aperçu (10 premières lignes)</h3>
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table>
|
|
<thead>
|
|
<tr>{preview.headers.map(h => <th key={h}>{h}</th>)}</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.sampleRows.map((r, i) => (
|
|
<tr key={i}>{preview.headers.map(h => <td key={h}>{String(r[h] ?? '')}</td>)}</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Import Dossier Investissement ──────────────────────── */}
|
|
<DossierImport
|
|
activeId={activeId}
|
|
navigate={navigate}
|
|
dossierFile={dossierFile}
|
|
setDossierFile={setDossierFile}
|
|
dossierPreview={dossierPreview}
|
|
setDossierPreview={setDossierPreview}
|
|
dossierResult={dossierResult}
|
|
setDossierResult={setDossierResult}
|
|
dossierBusy={dossierBusy}
|
|
setDossierBusy={setDossierBusy}
|
|
dossierErr={dossierErr}
|
|
setDossierErr={setDossierErr}
|
|
dossierInputRef={dossierInputRef}
|
|
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
|
|
/>
|
|
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Module</th>
|
|
<th>Fichier</th>
|
|
<th className="num">Total</th>
|
|
<th className="num">OK</th>
|
|
<th className="num">KO</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{history.length === 0 && (
|
|
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center' }}>Aucun import</td></tr>
|
|
)}
|
|
{history.map(h => (
|
|
<tr key={h.id}>
|
|
<td>{fmtDate(h.created_at)}</td>
|
|
<td>{MODULE_LABEL[h.module] ?? h.module}</td>
|
|
<td className="text-muted" style={{ fontSize: 11 }}>{h.filename}</td>
|
|
<td className="num">{h.rows_total}</td>
|
|
<td className="num" style={{ color: 'var(--success)' }}>{h.rows_inserted}</td>
|
|
<td className="num" style={{ color: h.rows_skipped > 0 ? 'var(--warning)' : undefined }}>
|
|
{h.rows_skipped}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ── Composant import dossier ──────────────────────────────────── */
|
|
function DossierImport({
|
|
activeId, navigate,
|
|
dossierFile, setDossierFile,
|
|
dossierPreview, setDossierPreview,
|
|
dossierResult, setDossierResult,
|
|
dossierBusy, setDossierBusy,
|
|
dossierErr, setDossierErr,
|
|
dossierInputRef, reloadHistory,
|
|
}) {
|
|
const missingInv = !activeId;
|
|
|
|
const onFileChange = (e) => {
|
|
const f = e.target.files[0];
|
|
setDossierFile(f || null);
|
|
setDossierPreview(null);
|
|
setDossierResult(null);
|
|
setDossierErr(null);
|
|
if (!f) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
try {
|
|
const parsed = JSON.parse(ev.target.result);
|
|
if (parsed.type !== 'dossier_investissement') {
|
|
setDossierErr('Ce fichier n\'est pas un dossier investissement valide (type incorrect).');
|
|
return;
|
|
}
|
|
setDossierPreview(parsed);
|
|
} catch {
|
|
setDossierErr('Fichier JSON invalide — vérifiez la syntaxe.');
|
|
}
|
|
};
|
|
reader.readAsText(f);
|
|
};
|
|
|
|
const onImport = async () => {
|
|
if (!dossierPreview) return;
|
|
setDossierBusy(true); setDossierErr(null); setDossierResult(null);
|
|
try {
|
|
const r = await api.post('/imports/dossier', { dossier: dossierPreview });
|
|
setDossierResult(r);
|
|
setDossierFile(null); setDossierPreview(null);
|
|
if (dossierInputRef.current) dossierInputRef.current.value = '';
|
|
reloadHistory();
|
|
} catch (e) { setDossierErr(e.message); }
|
|
finally { setDossierBusy(false); }
|
|
};
|
|
|
|
const dp = dossierPreview;
|
|
const inv = dp?.investissement;
|
|
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
|
|
|
return (
|
|
<div className="card">
|
|
<h3 style={{ marginTop: 0 }}>Import — Dossier investissement</h3>
|
|
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
|
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
|
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
|
Si le dossier existe déjà, il sera mis à jour ; sinon il sera créé.
|
|
</p>
|
|
|
|
{missingInv && (
|
|
<div className="error" style={{ marginBottom: 10 }}>
|
|
Sélectionnez un investisseur actif avant d'importer un dossier.
|
|
</div>
|
|
)}
|
|
|
|
<div className="row" style={{ gap: 10, alignItems: 'flex-end' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label>Fichier dossier <code>.json</code></label>
|
|
<input
|
|
ref={dossierInputRef}
|
|
type="file" accept=".json"
|
|
disabled={missingInv}
|
|
onChange={onFileChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{dossierErr && <div className="error" style={{ marginTop: 10 }}>{dossierErr}</div>}
|
|
|
|
{/* Aperçu du dossier avant import */}
|
|
{dp && inv && (
|
|
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
|
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>Aperçu du dossier</h4>
|
|
<table style={{ marginBottom: 0 }}>
|
|
<tbody>
|
|
<tr><td style={{ width: 200 }}>Projet</td><td><strong>{inv.nom_projet}</strong></td></tr>
|
|
<tr><td>Plateforme</td><td>{dp.plateforme?.nom}</td></tr>
|
|
<tr><td>Date souscription</td><td>{fmtDate(inv.date_souscription)}</td></tr>
|
|
<tr><td>Montant investi</td><td>{inv.montant_investi} €</td></tr>
|
|
<tr><td>Statut</td><td>{inv.statut}</td></tr>
|
|
<tr><td>Remboursements</td><td>{dp.remboursements?.length ?? 0} enregistrement(s)</td></tr>
|
|
<tr><td>Projections</td><td>{dp.projections?.length ?? 0} échéance(s)</td></tr>
|
|
<tr><td>Historique</td><td>{dp.historique?.length ?? 0} entrée(s)</td></tr>
|
|
<tr><td>Exporté le</td><td className="text-muted" style={{ fontSize: 11 }}>{dp.exported_at}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button onClick={() => { setDossierFile(null); setDossierPreview(null); if (dossierInputRef.current) dossierInputRef.current.value = ''; }}>
|
|
Annuler
|
|
</button>
|
|
<button className="primary" onClick={onImport} disabled={dossierBusy || missingInv}>
|
|
{dossierBusy ? '…' : 'Importer ce dossier'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{dossierResult && (
|
|
<div className="success-msg" style={{ marginTop: 12 }}>
|
|
{dossierResult.action === 'created'
|
|
? '✔ Dossier créé avec succès.'
|
|
: '✔ Dossier mis à jour avec succès.'
|
|
}
|
|
{' '}
|
|
<button
|
|
style={{ marginLeft: 8, fontSize: 'var(--fs-xs)', padding: '2px 8px' }}
|
|
onClick={() => navigate(`/investissements/${dossierResult.investissementId}`, { state: { from: { path: location.pathname, search: location.search, label: 'Imports' } } })}
|
|
>
|
|
Ouvrir le dossier →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|