Modification de l'import avec prompt IA

This commit is contained in:
2026-07-04 23:09:46 +02:00
parent 8834236840
commit 8bcd7e603d
5 changed files with 493 additions and 48 deletions
+43
View File
@@ -459,3 +459,46 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName` 1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName`
2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export default db`) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur `famille` pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent `NOT EXISTS` → sans effet une fois les données corrigées. 2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export default db`) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur `famille` pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent `NOT EXISTS` → sans effet une fois les données corrigées.
- **Règle à retenir** : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de `auth.js` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`. - **Règle à retenir** : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de `auth.js` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`.
---
## Session 9 — Import de données & cohérence mono/multi-détenteur (2026-07-04)
### Ajout de plateforme depuis le référentiel — un seul champ demandé
- `PlateformesSection.jsx` (`PlatPickerModal`) : au clic sur une plateforme du référentiel, une vue de confirmation demande **uniquement** la date d'ouverture du compte (« Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ? »), pré-remplie à la date du jour (`localToday()` — jamais `.toISOString()`, cf. piège timezone ci-dessous). Tout le reste (domiciliation, fiscalité…) vient du référentiel.
### Colonne "Détenteur" — masquée si mono-détenteur
- Pattern à répliquer partout : `const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;` (ou `investisseurs.length > 1` si pas de liste de plateformes disponible)
- Déployé sur : `Investissements.jsx`, `Remboursements.jsx`, `Plateformes.jsx`, `PlateformesSection.jsx`, `ComptesSection.jsx`, `DepotsRetraits.jsx` — colonnes `<th>`/`<td>` conditionnées + `colSpan` ajusté + tfoot spacer
- **Idem sur les exports** (XLS/CSV/JSON) : passer `multiDetenteur` en paramètre à la fonction d'export (ex. `mouvToXLS(rows, multiDetenteur)`) et spreader conditionnellement `...(multiDetenteur ? { 'Détenteur': ... } : {})`
### DepotsRetraits — bug empty-state après premier dépôt (plateforme flat_tax)
- **Cause** : pour une plateforme `flat_tax`, `submit()` ouvre `corrModal` (vérification du solde déclaré) au lieu d'appeler `load()` directement ; l'early-return de l'empty-state (`!loading && !modalOpen && allRows.length === 0`) masquait la `<CorrectionModal>` rendue plus bas dans le JSX.
- **Fix** : ajouter `!corrModal.open` à la condition de l'early-return.
### Import — résolution de noms (plateforme/investissement) au lieu d'ID stricts
- Backend `imports.js` : `resolveRefId(value, idSet, nameMap, label)` accepte un ID numérique OU un nom texte (normalisé accents/casse via `normalizeName`) pour `plateforme_id` (modules `depots_retraits`/`investissements`) et `investissement_id` (module `remboursements`)
- Frontend : auto-mapping des colonnes par synonymes (`FIELD_SYNONYMS` + `normalizeHeader()`) en plus du match exact ; **auto-remplissage** du champ "Valeur par défaut" quand toutes les lignes d'échantillon résolvent vers la même plateforme/investissement (`resolvePreviewMatch`)
### Import — protection anti-doublon
- Clé de détection par module (avant insertion, dans la transaction) :
- `depots_retraits` : investisseur + plateforme + date + type + montant (tolérance 0,005)
- `investissements` : investisseur + plateforme + nom_projet + date_souscription
- `remboursements` : investissement + date_remb + capital + interets_bruts (tolérance 0,005)
- `plateformes` : déjà couvert par `UNIQUE(nom)` → reclassé en doublon plutôt qu'erreur
- Compteur `duplicates` distinct de `skipped` (vraies erreurs), colonne DB `imports.rows_duplicates`, affiché dans l'historique
### Import — anomalie date antérieure à la date d'ouverture de la plateforme
- Pour `depots_retraits`/`investissements` uniquement (pas `remboursements`, lien plateforme trop indirect) : la date la plus ancienne rencontrée par plateforme dans le fichier importé (doublon ou non) est comparée à `plateformes.date_ouverture`
- Si anomalie : réponse `/apply` inclut `anomalies: [{ plateforme_id, plateforme_nom, date_ouverture_actuelle, date_detectee }]`
- Nouvelle route `PATCH /api/plateformes/:id/date-ouverture` (payload minimal `{ date_ouverture }`, ne PAS repasser par le `PUT /:id` complet)
- `ImportsSection.jsx` affiche une bannière par anomalie avec bouton "Corriger la date d'ouverture (JJ/MM/AAAA)"
### Import généré par IA — nouveau bloc dans ImportsSection.jsx
- Bloc "Import généré par IA" : prompt dynamique (`DEFAULT_IA_IMPORT_PROMPT`, template `{{MODULE_LABEL}}`/`{{FIELDS_LIST}}`/`{{REFERENCE_SECTION}}`, éditable et persisté `localStorage`) listant les champs attendus du module cible + les plateformes/investissements existants ; l'utilisateur colle le JSON généré par l'IA (avec ou sans fence ```json```) qui est réinjecté dans le pipeline preview/apply existant via un `Blob`/`File` virtuel (pas de code dupliqué)
- **Règle imposée au prompt** : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
- Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est `investissements`
### Piège outillage — mount bash périmé après édition Edit/Write
- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
+28 -1
View File
@@ -139,6 +139,14 @@ router.post('/apply', (req, res, next) => {
let inserted = 0, skipped = 0, duplicates = 0; let inserted = 0, skipped = 0, duplicates = 0;
const errors = []; const errors = [];
// Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
// — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
const platMinDate = new Map();
const trackPlatDate = (plateformeId, dateStr) => {
if (!plateformeId || !dateStr) return;
const cur = platMinDate.get(plateformeId);
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
};
const tx = db.transaction(() => { const tx = db.transaction(() => {
for (let idx = 0; idx < rows.length; idx++) { for (let idx = 0; idx < rows.length; idx++) {
@@ -157,6 +165,7 @@ router.post('/apply', (req, res, next) => {
const dateOperation = normaliseDate(v('date_operation')); const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type')); const type = normaliseType(v('type'));
const montant = num(v('montant')); const montant = num(v('montant'));
trackPlatDate(plateformeId, dateOperation);
// Anti-doublon : même investisseur + plateforme + date + type + montant // Anti-doublon : même investisseur + plateforme + date + type + montant
const dup = db.prepare(` const dup = db.prepare(`
@@ -186,6 +195,7 @@ router.post('/apply', (req, res, next) => {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'); const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet')); const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription')); const dateSouscription = normaliseDate(v('date_souscription'));
trackPlatDate(plateformeId, dateSouscription);
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription // Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
const dup = db.prepare(` const dup = db.prepare(`
@@ -295,6 +305,23 @@ router.post('/apply', (req, res, next) => {
}); });
tx(); tx();
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
const anomalies = [];
if (platMinDate.size > 0) {
for (const [plateformeId, minDate] of platMinDate) {
const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
.get(plateformeId, req.user.id);
if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
anomalies.push({
plateforme_id: plat.id,
plateforme_nom: plat.nom,
date_ouverture_actuelle: plat.date_ouverture,
date_detectee: minDate,
});
}
}
}
db.prepare(` db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json) INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
VALUES (?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?)
@@ -313,7 +340,7 @@ router.post('/apply', (req, res, next) => {
// Clean up temp file // Clean up temp file
try { fs.unlinkSync(tempPath); } catch { /* */ } try { fs.unlinkSync(tempPath); } catch { /* */ }
res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50) }); res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+12
View File
@@ -661,6 +661,18 @@ router.put('/:id', (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// ── Correction rapide de la date d'ouverture (ex. anomalie détectée à l'import) ──
router.patch('/:id/date-ouverture', (req, res, next) => {
try {
const schema = z.object({ date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
const { date_ouverture } = schema.parse(req.body);
const r = db.prepare('UPDATE plateformes SET date_ouverture = ? WHERE id = ? AND user_id = ?')
.run(date_ouverture, req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), date_ouverture });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => { router.delete('/:id', (req, res, next) => {
try { try {
// Récupère le logo avant suppression pour effacer le fichier // Récupère le logo avant suppression pour effacer le fichier
+32 -13
View File
@@ -15,6 +15,12 @@ import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
/* ── Helpers export ──────────────────────────────────────────── */ /* ── 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) { function dlBlob(content, filename, type) {
const blob = new Blob([content], { type }); const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -375,14 +381,16 @@ export default function DepotsRetraits() {
const [allRows, setAllRows] = useState([]); const [allRows, setAllRows] = useState([]);
const [plats, setPlats] = useState([]); const [plats, setPlats] = useState([]);
const [dashData, setDashData] = useState(null); 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 [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty); const [form, setForm] = useState(empty);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null); const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState(String(new Date().getFullYear())); const [drPlatYear, setDrPlatYear] = useState(currentYear);
const [allRemb, setAllRemb] = useState([]); const [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]); const [allInv, setAllInv] = useState([]);
@@ -401,6 +409,7 @@ export default function DepotsRetraits() {
const platIds = p.get('plat_ids'); const platIds = p.get('plat_ids');
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean); if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
setFilter(newFilter); setFilter(newFilter);
setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
navigate('/depots-retraits', { replace: true }); navigate('/depots-retraits', { replace: true });
}, []); // eslint-disable-line }, []); // eslint-disable-line
const [openMenu, setOpenMenu] = useState(null); // { row, x, y } const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
@@ -905,9 +914,9 @@ export default function DepotsRetraits() {
<> <>
<div className="topbar"> <div className="topbar">
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2> <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" <button className="dr-clear-btn"
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}> onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}>
Effacer les filtres Effacer les filtres
</button> </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 style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>
</div> </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' : ''}`} 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: '' })} onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}
title={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? 'Effacer les filtres' : undefined} title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined}
style={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? { cursor: 'pointer' } : {}} style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}}
> >
<div className="label">Diff. Dépôts vs Retraits</div> <div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}> <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>} {drPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {drPlatYear}</span>}
</h3> </h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <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={{ style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30, fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)', borderRadius: 6, border: '1px solid var(--border)',
@@ -1154,10 +1163,20 @@ export default function DepotsRetraits() {
</button> </button>
<ExportDropdown <ExportDropdown
disabled={rows.length === 0} disabled={rows.length === 0}
onCSV={() => dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')} onCSV={() => dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')} onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')} 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>
</div> </div>
@@ -1217,7 +1236,7 @@ export default function DepotsRetraits() {
</div> </div>
<div style={{ flex: 1, minWidth: 100 }}> <div style={{ flex: 1, minWidth: 100 }}>
<label>Année</label> <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> <option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)} {years.map(y => <option key={y} value={y}>{y}</option>)}
</select> </select>
+378 -34
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; 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 { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js'; import { fmtDate } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx'; import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
@@ -201,6 +201,230 @@ function resolvePreviewMatch(raw, refList, nameKey) {
return refList.find(x => normalizeName(x[nameKey]) === norm) || null; 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 ─────────────────────────────── */ /* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({ function DossierImport({
activeId, navigate, activeId, navigate,
@@ -322,8 +546,12 @@ export default function ImportsSection() {
const { activeId } = useInvestisseur(); const { activeId } = useInvestisseur();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 [file, setFile] = useState(null);
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({}); const [mapping, setMapping] = useState({});
@@ -334,6 +562,8 @@ export default function ImportsSection() {
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
const [anomalies, setAnomalies] = useState([]);
const [fixingPlatId, setFixingPlatId] = useState(null);
const [dossierFile, setDossierFile] = useState(null); const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null); const [dossierPreview, setDossierPreview] = useState(null);
@@ -347,6 +577,15 @@ export default function ImportsSection() {
api.get('/plateformes').then(setPlats).catch(() => {}); 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(() => { useEffect(() => {
if (!activeId) return; if (!activeId) return;
api.get('/investissements').then(setInvestissements).catch(() => {}); api.get('/investissements').then(setInvestissements).catch(() => {});
@@ -356,17 +595,25 @@ export default function ImportsSection() {
const allTargets = def ? [...def.required, ...def.optional] : []; const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId; 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); setBusy(true); setErr(null); setResult(null);
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', uploadFile);
const r = await api.upload('/imports/preview', fd); const r = await api.upload('/imports/preview', fd);
setModule(targetModule);
setPreview(r); setPreview(r);
const targetDef = MODULES[targetModule];
const targets = targetDef ? [...targetDef.required, ...targetDef.optional] : [];
const auto = {}; const auto = {};
const usedHeaders = new Set(); 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") // 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); let col = r.headers.find(h => !usedHeaders.has(h) && h.toLowerCase().replace(/\W/g, '_') === t);
// 2) correspondance par synonyme usuel (ex. "Plateforme", "Date", "Montant (€)") // 2) correspondance par synonyme usuel (ex. "Plateforme", "Date", "Montant (€)")
@@ -397,6 +644,28 @@ export default function ImportsSection() {
finally { setBusy(false); } 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 () => { const apply = async () => {
setBusy(true); setErr(null); setBusy(true); setErr(null);
try { try {
@@ -412,6 +681,7 @@ export default function ImportsSection() {
+ '.' + '.'
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''), + (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''),
}); });
setAnomalies(r.anomalies || []);
setPreview(null); setFile(null); setMapping({}); setDefaults({}); setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {}); api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {}); if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
@@ -419,17 +689,30 @@ export default function ImportsSection() {
finally { setBusy(false); } 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; const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return ( return (
<> <>
<div className="card"> <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)' }}> <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> </p>
<div className="row"> <div className="row">
<div> <div style={{ flex: 1 }}>
<label>Module cible</label> <label>Module cible</label>
<select value={module} onChange={e => { <select value={module} onChange={e => {
setModule(e.target.value); setModule(e.target.value);
@@ -440,22 +723,10 @@ export default function ImportsSection() {
))} ))}
</select> </select>
</div> </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> </div>
{def?.note && ( {def?.note && (
<div className="import-module-note"> <div className="import-module-note" style={{ marginTop: 12 }}>
{def.global && ( {def.global && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" <svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" 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. Sélectionnez un investisseur actif avant d'importer ce module.
</div> </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>} {err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} /> <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> </div>
{preview && ( {preview && (
<> <>
<div className="card"> <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 }}> <p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> feuille <em>{preview.sheetName}</em> {preview.allRowCount} lignes. Fichier : <strong>{preview.filename}</strong> feuille <em>{preview.sheetName}</em> {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires. {' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
@@ -588,17 +906,43 @@ export default function ImportsSection() {
</> </>
)} )}
<DossierImport {/* Le dossier investissement (capital + remboursements + historique) n'a de sens
activeId={activeId} que dans le contexte du module Investissements. */}
navigate={navigate} {module === 'investissements' && (
dossierFile={dossierFile} setDossierFile={setDossierFile} <DossierImport
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview} activeId={activeId}
dossierResult={dossierResult} setDossierResult={setDossierResult} navigate={navigate}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy} dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierErr={dossierErr} setDossierErr={setDossierErr} dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierInputRef={dossierInputRef} dossierResult={dossierResult} setDossierResult={setDossierResult}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})} 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"> <div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3> <h3 style={{ marginTop: 0 }}>Historique des imports</h3>