Modification de l'import avec prompt IA
This commit is contained in:
@@ -15,6 +15,12 @@ import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/* ── Helpers export ──────────────────────────────────────────── */
|
||||
/** 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 dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -375,14 +381,16 @@ export default function DepotsRetraits() {
|
||||
const [allRows, setAllRows] = useState([]);
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [dashData, setDashData] = useState(null);
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
|
||||
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
|
||||
const currentYear = String(new Date().getFullYear());
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState(null);
|
||||
const [drPlatYear, setDrPlatYear] = useState(String(new Date().getFullYear()));
|
||||
const [drPlatYear, setDrPlatYear] = useState(currentYear);
|
||||
const [allRemb, setAllRemb] = useState([]);
|
||||
const [allInv, setAllInv] = useState([]);
|
||||
|
||||
@@ -401,6 +409,7 @@ export default function DepotsRetraits() {
|
||||
const platIds = p.get('plat_ids');
|
||||
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
|
||||
setFilter(newFilter);
|
||||
setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
|
||||
navigate('/depots-retraits', { replace: true });
|
||||
}, []); // eslint-disable-line
|
||||
const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
|
||||
@@ -905,9 +914,9 @@ export default function DepotsRetraits() {
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
|
||||
{(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) && (
|
||||
{(filter.type || filter.plateforme_ids.length > 0 || filter.month) && (
|
||||
<button className="dr-clear-btn"
|
||||
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}>
|
||||
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}>
|
||||
✕ Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
@@ -946,10 +955,10 @@ export default function DepotsRetraits() {
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}
|
||||
title={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? 'Effacer les filtres' : undefined}
|
||||
style={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? { cursor: 'pointer' } : {}}
|
||||
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}
|
||||
title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined}
|
||||
style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Diff. Dépôts vs Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
@@ -988,7 +997,7 @@ export default function DepotsRetraits() {
|
||||
{drPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {drPlatYear}</span>}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select value={drPlatYear} onChange={e => setDrPlatYear(e.target.value)}
|
||||
<select value={drPlatYear} onChange={e => { const v = e.target.value; setDrPlatYear(v); setFilter(f => ({ ...f, year: v })); }}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
@@ -1154,10 +1163,20 @@ export default function DepotsRetraits() {
|
||||
</button>
|
||||
<ExportDropdown
|
||||
disabled={rows.length === 0}
|
||||
onCSV={() => dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')}
|
||||
onCSV={() => dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="Importer des dépôts/retraits"
|
||||
onClick={() => navigate('/settings?section=imports&module=depots_retraits')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1217,7 +1236,7 @@ export default function DepotsRetraits() {
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label>Année</label>
|
||||
<select value={filter.year} onChange={e => setFilter({ ...filter, year: e.target.value })}>
|
||||
<select value={filter.year} onChange={e => { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}>
|
||||
<option value="">Toutes</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api.js';
|
||||
import { fmtDate } from '../../utils/format.js';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
@@ -201,6 +201,230 @@ function resolvePreviewMatch(raw, refList, nameKey) {
|
||||
return refList.find(x => normalizeName(x[nameKey]) === norm) || null;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Import généré par IA — génération dynamique d'un prompt d'extraction
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/** Description humaine de chaque champ cible, réutilisée pour générer le prompt IA */
|
||||
const FIELD_HINTS = {
|
||||
date_operation: 'date du mouvement, format ISO YYYY-MM-DD',
|
||||
type: '"depot" ou "retrait"',
|
||||
montant: 'montant en euros (nombre, ex: 500 ou 500.5 — pas de symbole €, point décimal)',
|
||||
plateforme_id: 'nom EXACT de la plateforme (voir liste ci-dessous) ou son ID numérique',
|
||||
libelle: 'libellé libre du mouvement',
|
||||
reference: 'référence libre (n° de virement, etc.)',
|
||||
nom_projet: 'nom du projet / du prêt',
|
||||
date_souscription: 'date de souscription, format ISO YYYY-MM-DD',
|
||||
montant_investi: 'montant investi en euros (nombre)',
|
||||
emetteur: "nom de l'émetteur du projet, si disponible",
|
||||
date_premiere_echeance: 'date de la première échéance, format ISO YYYY-MM-DD',
|
||||
date_cible: 'date de fin prévue du prêt, format ISO YYYY-MM-DD',
|
||||
taux_interet: "taux d'intérêt annuel en % (nombre, ex: 8.5)",
|
||||
duree_mois: 'durée du prêt en mois (nombre entier)',
|
||||
type_remb: '"in_fine" | "amortissable" | "differe"',
|
||||
freq_interets: '"mensuel" | "trimestriel" | "in_fine"',
|
||||
statut: '"en_cours" | "rembourse" | "en_retard" | "procedure" | "cloture"',
|
||||
investissement_id: 'nom EXACT du projet (voir liste ci-dessous) ou son ID numérique',
|
||||
date_remb: 'date du remboursement, format ISO YYYY-MM-DD',
|
||||
capital: 'part de capital remboursée, en euros (nombre)',
|
||||
cashback: 'montant de cashback perçu, en euros (nombre)',
|
||||
interets_bruts: 'intérêts bruts perçus, en euros (nombre, avant prélèvements)',
|
||||
prelev_sociaux: 'prélèvements sociaux, en euros (nombre)',
|
||||
prelev_forfaitaire: "impôt sur le revenu (prélèvement forfaitaire), en euros (nombre)",
|
||||
net_recu: 'montant net reçu, en euros (nombre)',
|
||||
nom: 'nom de la plateforme',
|
||||
url: 'URL du site de la plateforme',
|
||||
notes: 'notes libres',
|
||||
annee: 'année concernée (nombre entier, ex: 2024)',
|
||||
pfu_total: 'taux PFU total en % (nombre, ex: 30)',
|
||||
impot_revenu: "part impôt sur le revenu du taux PFU, en %",
|
||||
};
|
||||
|
||||
/** Certains champs ont un sens différent selon le module (ex. prelev_sociaux : montant vs taux) */
|
||||
const FIELD_HINTS_OVERRIDE = {
|
||||
taux_pfu: {
|
||||
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
|
||||
},
|
||||
};
|
||||
|
||||
function buildFieldsList(mod) {
|
||||
const def = MODULES[mod];
|
||||
if (!def) return '';
|
||||
const lines = [];
|
||||
for (const key of def.required) {
|
||||
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
|
||||
lines.push(`- ${key} (obligatoire)${hint ? ` : ${hint}` : ''}`);
|
||||
}
|
||||
for (const key of def.optional) {
|
||||
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
|
||||
lines.push(`- ${key} (optionnel)${hint ? ` : ${hint}` : ''}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildReferenceSection(mod, plats, investissements) {
|
||||
if ((mod === 'depots_retraits' || mod === 'investissements') && plats.length > 0) {
|
||||
const names = plats.map(p => p.nom).join(' | ');
|
||||
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`;
|
||||
}
|
||||
if (mod === 'remboursements' && investissements.length > 0) {
|
||||
const names = investissements.map(i => i.nom_projet).join(' | ');
|
||||
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms de projet si le remboursement correspond à l'un d'eux :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude à quel investissement une ligne correspond, NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « À quel investissement correspond ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur d'investissement_id pour TOUTES les lignes générées.\n\n`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const DEFAULT_IA_IMPORT_PROMPT = `Tu es un assistant spécialisé dans la conversion de données financières pour un tracker de portefeuille de crowdlending (financement participatif).
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
FICHIER À ANALYSER
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Analyse le fichier d'export que je vais joindre (relevé téléchargé depuis ma plateforme de crowdlending — CSV, Excel ou PDF), et convertis chaque ligne pertinente en un objet JSON.
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
MODULE CIBLE : {{MODULE_LABEL}}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Chaque objet du tableau doit utiliser EXACTEMENT ces clés (telles quelles, en minuscules) :
|
||||
|
||||
{{FIELDS_LIST}}
|
||||
|
||||
{{REFERENCE_SECTION}}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
FORMAT DE SORTIE
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Retourne UNIQUEMENT un tableau JSON, une entrée par ligne du fichier, sans texte ni explication autour, par exemple :
|
||||
[
|
||||
{ ... },
|
||||
{ ... }
|
||||
]
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
INSTRUCTIONS
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
1. N'invente aucune donnée absente du fichier — omets un champ optionnel si l'information n'est pas disponible.
|
||||
2. Toutes les dates sont au format ISO YYYY-MM-DD.
|
||||
3. Tous les montants sont des nombres (pas de texte, pas de symbole €, point décimal et non virgule).
|
||||
4. N'inclus que les lignes correspondant à de vraies opérations (ignore en-têtes, totaux, lignes vides).
|
||||
5. Si une section ci-dessus t'indique de poser une question avant de générer le JSON, respecte-la strictement : ne produis le tableau JSON qu'après ma réponse.`;
|
||||
|
||||
/** Bloc collapsible : coller le JSON généré par l'IA et lancer l'analyse (réutilise le pipeline existant) */
|
||||
function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, onAnalyze, busy }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', marginBottom: 12 }}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '12px 16px', background: 'var(--surface-2)',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600, fontSize: 13 }}>
|
||||
<IconUpload />
|
||||
Importer les données IA
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<p className="text-muted" style={{ fontSize: 12, margin: '0 0 10px' }}>
|
||||
Collez ici le tableau JSON retourné par l'IA pour le module <strong>{moduleLabel}</strong> (avec ou sans bloc <code>```json```</code>).
|
||||
</p>
|
||||
<textarea value={iaJson} onChange={e => { setIaJson(e.target.value); setIaErr(null); }}
|
||||
placeholder={'[\n { "date_operation": "2026-07-04", "type": "depot", "montant": 500, "plateforme_id": "BienPrêter" },\n ...\n]'}
|
||||
style={{ width: '100%', minHeight: 160, fontFamily: 'monospace', fontSize: 12,
|
||||
resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
borderRadius: 6, padding: 10, color: 'var(--text)', boxSizing: 'border-box' }} />
|
||||
{iaErr && <div className="error" style={{ marginTop: 8 }}>{iaErr}</div>}
|
||||
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy} style={{ marginTop: 10 }}>
|
||||
{busy ? '…' : 'Analyser les données →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */
|
||||
function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
|
||||
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('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const resolvedPrompt = () => prompt
|
||||
.replace('{{MODULE_LABEL}}', moduleLabel)
|
||||
.replace('{{FIELDS_LIST}}', buildFieldsList(module))
|
||||
.replace('{{REFERENCE_SECTION}}', buildReferenceSection(module, plats, investissements));
|
||||
|
||||
const startEdit = () => { setDraft(prompt); setEditing(true); };
|
||||
const saveEdit = () => { setPrompt(draft); localStorage.setItem(LS_KEY, draft); setEditing(false); };
|
||||
const reset = () => { setPrompt(DEFAULT_IA_IMPORT_PROMPT); localStorage.removeItem(LS_KEY); setEditing(false); };
|
||||
|
||||
const copy = async () => {
|
||||
const text = resolvedPrompt();
|
||||
try { await navigator.clipboard.writeText(text); }
|
||||
catch { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); }
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '12px 16px', background: 'var(--surface-2)',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
borderBottom: open ? '1px solid var(--border)' : 'none',
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 600, fontSize: 13 }}>
|
||||
🤖 PROMPT IA — Générer le prompt d'extraction
|
||||
</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<p className="text-muted" style={{ fontSize: 12, margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Le prompt liste les champs attendus pour le module <strong>{moduleLabel}</strong> (et vos plateformes/investissements existants si pertinent).
|
||||
Copiez-le, collez-le dans un assistant IA avec votre fichier d'export, puis collez le JSON obtenu ci-dessus.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<button className="primary" onClick={copy} style={{ fontSize: 12, padding: '6px 14px' }}>
|
||||
{copied ? '✓ Copié !' : '📋 Copier le prompt'}
|
||||
</button>
|
||||
{!editing ? (
|
||||
<button onClick={startEdit} style={{ fontSize: 12, padding: '6px 14px' }}>✏️ Modifier le template</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="primary" onClick={saveEdit} style={{ fontSize: 12, padding: '6px 14px' }}>Enregistrer</button>
|
||||
<button onClick={() => setEditing(false)} style={{ fontSize: 12, padding: '6px 14px' }}>Annuler</button>
|
||||
<button onClick={reset} style={{ fontSize: 12, padding: '6px 14px', color: 'var(--text-muted)', marginLeft: 'auto' }}>Remettre par défaut</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<textarea value={draft} onChange={e => setDraft(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 400, fontFamily: 'monospace', fontSize: 12, lineHeight: 1.5,
|
||||
resize: 'vertical', boxSizing: 'border-box', background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)', borderRadius: 6, padding: 12, color: 'var(--text)' }} />
|
||||
) : (
|
||||
<pre style={{ margin: 0, padding: 14, background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
borderRadius: 6, fontSize: 12, fontFamily: 'monospace', lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)', maxHeight: 400, overflowY: 'auto' }}>
|
||||
{resolvedPrompt()}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Imports — composant dossier ─────────────────────────────── */
|
||||
function DossierImport({
|
||||
activeId, navigate,
|
||||
@@ -322,8 +546,12 @@ export default function ImportsSection() {
|
||||
const { activeId } = useInvestisseur();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [module, setModule] = useState('depots_retraits');
|
||||
const [module, setModule] = useState(() => {
|
||||
const m = searchParams.get('module');
|
||||
return MODULES[m] ? m : 'depots_retraits';
|
||||
});
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [mapping, setMapping] = useState({});
|
||||
@@ -334,6 +562,8 @@ export default function ImportsSection() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
const [anomalies, setAnomalies] = useState([]);
|
||||
const [fixingPlatId, setFixingPlatId] = useState(null);
|
||||
|
||||
const [dossierFile, setDossierFile] = useState(null);
|
||||
const [dossierPreview, setDossierPreview] = useState(null);
|
||||
@@ -347,6 +577,15 @@ export default function ImportsSection() {
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Nettoie le paramètre ?module= une fois consommé (évite de le reproposer au refresh)
|
||||
useEffect(() => {
|
||||
if (!searchParams.get('module')) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('module');
|
||||
setSearchParams(next, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
api.get('/investissements').then(setInvestissements).catch(() => {});
|
||||
@@ -356,17 +595,25 @@ export default function ImportsSection() {
|
||||
const allTargets = def ? [...def.required, ...def.optional] : [];
|
||||
const missingInv = def?.needsInvestisseur && !activeId;
|
||||
|
||||
const onPreview = async () => {
|
||||
if (!file) return;
|
||||
/**
|
||||
* Analyse un fichier (upload classique OU JSON généré par IA) pour le module ciblé :
|
||||
* upload + auto-mapping des colonnes + pré-remplissage des valeurs par défaut résolues.
|
||||
* Partagé entre le bouton "Analyser" classique et le bloc "Import généré par IA".
|
||||
*/
|
||||
const runPreview = async (uploadFile, targetModule) => {
|
||||
setBusy(true); setErr(null); setResult(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('file', uploadFile);
|
||||
const r = await api.upload('/imports/preview', fd);
|
||||
setModule(targetModule);
|
||||
setPreview(r);
|
||||
|
||||
const targetDef = MODULES[targetModule];
|
||||
const targets = targetDef ? [...targetDef.required, ...targetDef.optional] : [];
|
||||
const auto = {};
|
||||
const usedHeaders = new Set();
|
||||
for (const t of allTargets) {
|
||||
for (const t of targets) {
|
||||
// 1) correspondance exacte (ex. en-tête littéralement "plateforme_id")
|
||||
let col = r.headers.find(h => !usedHeaders.has(h) && h.toLowerCase().replace(/\W/g, '_') === t);
|
||||
// 2) correspondance par synonyme usuel (ex. "Plateforme", "Date", "Montant (€)")
|
||||
@@ -397,6 +644,28 @@ export default function ImportsSection() {
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const onPreview = async () => {
|
||||
if (!file) return;
|
||||
await runPreview(file, module);
|
||||
};
|
||||
|
||||
/** 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 analyzeIaJson = async () => {
|
||||
setIaErr(null);
|
||||
let arr;
|
||||
try {
|
||||
const cleaned = iaJson.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
||||
arr = JSON.parse(cleaned);
|
||||
} 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; }
|
||||
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);
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
@@ -412,6 +681,7 @@ export default function ImportsSection() {
|
||||
+ '.'
|
||||
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''),
|
||||
});
|
||||
setAnomalies(r.anomalies || []);
|
||||
setPreview(null); setFile(null); setMapping({}); setDefaults({});
|
||||
api.get('/imports/history').then(setHistory).catch(() => {});
|
||||
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
@@ -419,17 +689,30 @@ export default function ImportsSection() {
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const fixDateOuverture = async (anomaly) => {
|
||||
setFixingPlatId(anomaly.plateforme_id);
|
||||
try {
|
||||
await api.patch(`/plateformes/${anomaly.plateforme_id}/date-ouverture`, {
|
||||
date_ouverture: anomaly.date_detectee,
|
||||
});
|
||||
setAnomalies(list => list.filter(a => a.plateforme_id !== anomaly.plateforme_id));
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setFixingPlatId(null); }
|
||||
};
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>1. Fichier source</h3>
|
||||
<h3 style={{ margin: '0 0 4px' }}>1. Contexte de l'import</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Importez des données depuis un fichier Excel, CSV ou JSON.
|
||||
Choisissez le type de données à importer : les blocs ci-dessous s'adaptent automatiquement
|
||||
(champs attendus, prompt IA, dossier investissement…).
|
||||
</p>
|
||||
<div className="row">
|
||||
<div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Module cible</label>
|
||||
<select value={module} onChange={e => {
|
||||
setModule(e.target.value);
|
||||
@@ -440,22 +723,10 @@ export default function ImportsSection() {
|
||||
))}
|
||||
</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>
|
||||
|
||||
{def?.note && (
|
||||
<div className="import-module-note">
|
||||
<div className="import-module-note" style={{ marginTop: 12 }}>
|
||||
{def.global && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
@@ -473,15 +744,62 @@ export default function ImportsSection() {
|
||||
Sélectionnez un investisseur actif avant d'importer ce module.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>2. Fichier source</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
|
||||
</p>
|
||||
<div className="row">
|
||||
<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>
|
||||
|
||||
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
|
||||
|
||||
{anomalies.length > 0 && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{anomalies.map(a => (
|
||||
<div key={a.plateforme_id} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
background: 'var(--surface-2)', border: '1px solid var(--warning)',
|
||||
}}>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
⚠ <strong>{a.plateforme_nom}</strong> : des données importées remontent au{' '}
|
||||
<strong>{fmtDate(a.date_detectee)}</strong>, avant la date d'ouverture déclarée
|
||||
({fmtDate(a.date_ouverture_actuelle)}).
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => fixDateOuverture(a)}
|
||||
disabled={fixingPlatId === a.plateforme_id}
|
||||
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{fixingPlatId === a.plateforme_id ? '…' : `Corriger la date d'ouverture (${fmtDate(a.date_detectee)})`}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>2. Mappage des colonnes</h3>
|
||||
<h3 style={{ marginTop: 0 }}>3. 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.
|
||||
@@ -588,17 +906,43 @@ export default function ImportsSection() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<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(() => {})}
|
||||
/>
|
||||
{/* Le dossier investissement (capital + remboursements + historique) n'a de sens
|
||||
que dans le contexte du module Investissements. */}
|
||||
{module === 'investissements' && (
|
||||
<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={{ margin: '0 0 4px' }}>Import généré par IA</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)' }}>
|
||||
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 : <strong>{def?.label ?? module}</strong>
|
||||
{' '}(modifiable en section 1 ci-dessus).
|
||||
</p>
|
||||
<IaImportPasteBlock
|
||||
moduleLabel={def?.label ?? module}
|
||||
iaJson={iaJson} setIaJson={setIaJson}
|
||||
iaErr={iaErr} setIaErr={setIaErr}
|
||||
onAnalyze={analyzeIaJson}
|
||||
busy={busy}
|
||||
/>
|
||||
<IaImportPromptBlock
|
||||
module={module}
|
||||
moduleLabel={def?.label ?? module}
|
||||
plats={plats}
|
||||
investissements={investissements}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
|
||||
|
||||
Reference in New Issue
Block a user