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 ( +
+ + {open && ( +
+ + + +
+ )} +
+ ); +} + const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—'; const FISCALITE_LABELS = { flat_tax: 'Flat Tax', @@ -245,6 +319,25 @@ const FIELD_HINTS_OVERRIDE = { taux_pfu: { prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)', }, + investissements: { + nom_projet: 'trouve le nom du projet naturellement dans le fichier, puis FORMATE-le ainsi : ' + + '"NOM DE L\'ÉMETTEUR EN MAJUSCULES - nom du projet trouvé" (ex: "SCI DUPONT - Résidence Les Tilleuls"). ' + + 'Anti-doublon RENFORCÉ, à appliquer en 2 temps : ' + + '1) Avant de préfixer, cherche dans TOUT le nom du projet trouvé (pas seulement en tête) une occurrence du nom ' + + 'de l\'émetteur — y compris partielle ou abrégée (ex. "HMC" est une abréviation de "HMC GROUP", "MULTIPRINT" est ' + + 'le premier mot de "MULTIPRINT SAM") ou une variante proche (casse différente, "&" à la place de "ET", ' + + 'singulier/pluriel, ponctuation différente, ex. "QUALI PARTS & SERVICE" proche de "QUALI PARTS ET SERVICES"). ' + + '2) Si une telle occurrence existe n\'importe où dans le nom du projet trouvé, ne rajoute PAS le préfixe et ' + + 'SUPPRIME cette occurrence redondante du nom du projet (garde uniquement la partie descriptive restante, ex. ' + + '"HMC GROUP - HMC - Commande CSH25" devient "HMC GROUP - Commande CSH25", ' + + '"MULTIPRINT SAM - MULTIPRINT - ACM 06/2025" devient "MULTIPRINT SAM - ACM 06/2025", ' + + '"QUALI PARTS ET SERVICES - QUALI PARTS & SERVICE - Fact 981124" devient "QUALI PARTS ET SERVICES - Fact 981124"). ' + + 'Ne garde JAMAIS deux mentions (même partielles ou orthographiées différemment) du même émetteur dans le résultat final. ' + + 'Correction typographique : si le nom du projet contient des sigles ou des noms composés de segments courts ' + + 'séparés par des virgules (ex. "A,I,E,", "B,L,M,F,", "TWELVE,COM"), il s\'agit presque toujours d\'un point ' + + 'mal interprété (OCR) — remplace ces virgules par des points ("A.I.E.", "B.L.M.F.", "TWELVE.COM"). ' + + 'Si l\'émetteur est introuvable, laisse simplement le nom du projet tel quel, sans préfixe.', + }, }; function buildFieldsList(mod) { @@ -347,9 +440,8 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o } /** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */ -function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) { +function IaImportPromptBlock({ module, moduleLabel, plats, investissements, open, setOpen }) { const LS_KEY = 'cl_import_ia_prompt_template'; - const [open, setOpen] = useState(false); const [editing, setEditing] = useState(false); const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT); const [draft, setDraft] = useState(''); @@ -474,7 +566,7 @@ function DossierImport({ return (
-

Import — Dossier investissement

+

Étape 2 - Précisez le fichier source de votre dossier d'investissement

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 ( <>

-

1. Contexte de l'import

+

Étape 1 - Précisez le contexte de l'import

Choisissez le type de données à importer : les blocs ci-dessous s'adaptent automatiquement (champs attendus, prompt IA, dossier investissement…).

-
+
{ - setFile(e.target.files[0]); - setPreview(null); setResult(null); setErr(null); - }} /> -
-
- + {activeMode === 'fichier' && ( +
+

Étape 2 - Précisez le fichier source

+

+ Importez des données depuis un fichier Excel, CSV ou JSON pour le module {def?.label ?? module}. +

+
+
+ + { + setFile(e.target.files[0]); + setPreview(null); setResult(null); setErr(null); + }} /> +
+
+ +
+ )} - {err &&
{err}
} - setResult(null)} style={{ marginTop: 12 }} /> + {activeMode === 'dossier' && module === 'investissements' && ( + api.get('/imports/history').then(setHistory).catch(() => {})} + /> + )} - {anomalies.length > 0 && ( -
- {anomalies.map(a => ( -
- - ⚠ {a.plateforme_nom} : des données importées remontent au{' '} - {fmtDate(a.date_detectee)}, avant la date d'ouverture déclarée - ({fmtDate(a.date_ouverture_actuelle)}). - - -
- ))} -
- )} -
+ {activeMode === 'ia' && ( +
+

Étape 2 - Aidez-vous d'une IA pour préparer les données à importer

+

+ 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). +

+ + +
+ )} + + {(err || result || anomalies.length > 0) && ( +
+ {err &&
{err}
} + setResult(null)} /> + + {anomalies.length > 0 && ( +
+ {anomalies.map(a => ( +
+ + ⚠ {a.plateforme_nom} : des données importées remontent au{' '} + {fmtDate(a.date_detectee)}, avant la date d'ouverture déclarée + ({fmtDate(a.date_ouverture_actuelle)}). + + +
+ ))} +
+ )} +
+ )} {preview && ( <>
-

3. Mappage des colonnes

+

Mappage des colonnes

Fichier : {preview.filename} — feuille {preview.sheetName} — {preview.allRowCount} lignes. {' '}Champs marqués * obligatoires. @@ -887,6 +1078,13 @@ export default function ImportsSection() { + generateTemplate('csv')} + onXLS={() => generateTemplate('xls')} + onJSON={() => generateTemplate('json')} + />

@@ -906,44 +1104,6 @@ export default function ImportsSection() { )} - {/* Le dossier investissement (capital + remboursements + historique) n'a de sens - que dans le contexte du module Investissements. */} - {module === 'investissements' && ( - api.get('/imports/history').then(setHistory).catch(() => {})} - /> - )} - -
-

Import généré par IA

-

- 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). -

- - -
-

Historique des imports