diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index d80ee7e..dff96b1 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -344,6 +344,43 @@ router.post('/apply', (req, res, next) => { } catch (e) { next(e); } }); +/** + * POST /api/imports/template + * Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis, + * un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles), + * y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur + * peut ainsi compléter/enrichir ce fichier avant de le réimporter. + */ +router.post('/template', (req, res, next) => { + try { + const { tempId, module, mapping = {}, defaults = {} } = req.body || {}; + if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis'); + const def = MODULES[module]; + if (!def) throw new HttpError(400, 'Unknown module'); + + const tempPath = path.join(UPLOAD_DIR, tempId); + if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired'); + + const origName = req.body.originalFilename || ''; + const { rows } = parseFile(tempPath, origName || 'file.xlsx'); + + const allTargets = [...def.requiredTargets, ...def.optionalTargets]; + const v = (row, target) => { + const col = mapping[target]; + if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col]; + return defaults[target] !== undefined ? defaults[target] : ''; + }; + + const templateRows = rows.map(row => { + const out = {}; + for (const t of allTargets) out[t] = v(row, t); + return out; + }); + + res.json({ headers: allTargets, rows: templateRows }); + } catch (e) { next(e); } +}); + /** * POST /api/imports/dossier * Importe un dossier investissement complet (format d'export natif). diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index 7920913..78a367b 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import * as XLSX from 'xlsx'; import { api } from '../../api.js'; import { fmtDate } from '../../utils/format.js'; import { useInvestisseur } from '../../context/InvestisseurContext.jsx'; @@ -13,6 +14,79 @@ function dlBlob(content, filename, type) { URL.revokeObjectURL(url); } +/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */ +function timestampSuffix() { + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; +} + +function templateToCSV(headers, rows) { + const BOM = ''; const sep = ';'; + const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`; + const data = rows.map(r => headers.map(h => r[h] ?? '')); + return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n'); +} +function templateToXLS(headers, rows) { + const data = rows.map(r => { + const obj = {}; + headers.forEach(h => { obj[h] = r[h] ?? ''; }); + return obj; + }); + const ws = XLSX.utils.json_to_sheet(data, { header: headers }); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Import'); + return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); +} +function templateToJSON(rows) { + return JSON.stringify(rows, null, 2); +} + +/** Popup de choix de format, déclenché par un bouton texte (pas une icône) */ +function TemplateDropdown({ disabled, busy, onCSV, onXLS, onJSON }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, [open]); + const choose = fn => { setOpen(false); fn(); }; + return ( +
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
.json exporté par cette application.
@@ -552,6 +644,9 @@ export default function ImportsSection() {
const m = searchParams.get('module');
return MODULES[m] ? m : 'depots_retraits';
});
+ // null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement.
+ // Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source".
+ const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
@@ -564,6 +659,7 @@ export default function ImportsSection() {
const [err, setErr] = useState(null);
const [anomalies, setAnomalies] = useState([]);
const [fixingPlatId, setFixingPlatId] = useState(null);
+ const [templateBusy, setTemplateBusy] = useState(false);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
@@ -652,6 +748,7 @@ export default function ImportsSection() {
/** Parse le JSON collé (issu d'un assistant IA) et l'envoie dans le même pipeline d'analyse. */
const [iaJson, setIaJson] = useState('');
const [iaErr, setIaErr] = useState(null);
+ const [iaPromptOpen, setIaPromptOpen] = useState(false);
const analyzeIaJson = async () => {
setIaErr(null);
let arr;
@@ -661,6 +758,7 @@ export default function ImportsSection() {
} catch (e) { setIaErr('JSON invalide : ' + e.message); return; }
if (!Array.isArray(arr)) { setIaErr("Le JSON doit être un tableau d'objets (une entrée par ligne)."); return; }
if (arr.length === 0) { setIaErr('Le tableau est vide.'); return; }
+ setIaPromptOpen(false); // replie le bloc "Prompt IA" une fois l'analyse lancée
const blob = new Blob([JSON.stringify(arr)], { type: 'application/json' });
const iaFile = new File([blob], 'import-ia.json', { type: 'application/json' });
await runPreview(iaFile, module);
@@ -701,22 +799,40 @@ export default function ImportsSection() {
finally { setFixingPlatId(null); }
};
+ const generateTemplate = async (format) => {
+ if (!preview) return;
+ setTemplateBusy(true);
+ try {
+ const r = await api.post('/imports/template', {
+ tempId: preview.tempId, module, mapping, defaults,
+ originalFilename: file?.name ?? preview.filename,
+ });
+ const base = `Import ${def?.label ?? module} - modele a completer ${timestampSuffix()}`;
+ if (format === 'csv') dlBlob(templateToCSV(r.headers, r.rows), `${base}.csv`, 'text/csv;charset=utf-8');
+ if (format === 'xls') dlBlob(templateToXLS(r.headers, r.rows), `${base}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ if (format === 'json') dlBlob(templateToJSON(r.rows), `${base}.json`, 'application/json');
+ } catch (e) { setErr(e.message); }
+ finally { setTemplateBusy(false); }
+ };
+
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
Choisissez le type de données à importer : les blocs ci-dessous s'adaptent automatiquement (champs attendus, prompt IA, dossier investissement…).
- Importez des données depuis un fichier Excel, CSV ou JSON pour le module {def?.label ?? module}. -
-+ Importez des données depuis un fichier Excel, CSV ou JSON pour le module {def?.label ?? module}. +
++ Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF…) en données structurées, + sans mappage manuel des colonnes. Module cible actuel : {def?.label ?? module} + {' '}(modifiable en section 1 ci-dessus). +
+
Fichier : {preview.filename} — feuille {preview.sheetName} — {preview.allRowCount} lignes.
{' '}Champs marqués * obligatoires.
@@ -887,6 +1078,13 @@ export default function ImportsSection() {
+
- Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF…) en données structurées, - sans mappage manuel des colonnes. Module cible actuel : {def?.label ?? module} - {' '}(modifiable en section 1 ci-dessus). -
-