Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+455
View File
@@ -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>
);
}