Initial commit
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { 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();
|
||||
|
||||
// ── 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}`)}
|
||||
>
|
||||
Ouvrir le dossier →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user