diff --git a/MEMORY.md b/MEMORY.md index 33802b7..a932f6e 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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` 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`. + +--- + +## 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 ``/`` 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 `` 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`). diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index 0a5c6fc..d80ee7e 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -139,6 +139,14 @@ router.post('/apply', (req, res, next) => { let inserted = 0, skipped = 0, duplicates = 0; 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(() => { 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 type = normaliseType(v('type')); const montant = num(v('montant')); + trackPlatDate(plateformeId, dateOperation); // Anti-doublon : même investisseur + plateforme + date + type + montant const dup = db.prepare(` @@ -186,6 +195,7 @@ router.post('/apply', (req, res, next) => { const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme'); const nomProjet = String(v('nom_projet')); const dateSouscription = normaliseDate(v('date_souscription')); + trackPlatDate(plateformeId, dateSouscription); // Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription const dup = db.prepare(` @@ -295,6 +305,23 @@ router.post('/apply', (req, res, next) => { }); 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(` INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json) VALUES (?,?,?,?,?,?,?,?,?) @@ -313,7 +340,7 @@ router.post('/apply', (req, res, next) => { // Clean up temp file 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); } }); diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 98c2bd9..1826901 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -661,6 +661,18 @@ router.put('/:id', (req, res, next) => { } 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) => { try { // Récupère le logo avant suppression pour effacer le fichier diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx index 853e10e..64769d2 100644 --- a/frontend/src/pages/DepotsRetraits.jsx +++ b/frontend/src/pages/DepotsRetraits.jsx @@ -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() { <>

Dépôts / Retraits

- {(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) && ( + {(filter.type || filter.plateforme_ids.length > 0 || filter.month) && ( )} @@ -946,10 +955,10 @@ export default function DepotsRetraits() {
{fmtEUR(prevTotals.retraits)} en {prevYear}
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' } : {}} >
Diff. Dépôts vs Retraits
@@ -988,7 +997,7 @@ export default function DepotsRetraits() { {drPlatYear && pour l'année {drPlatYear}}
- { 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() { 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')} /> +
@@ -1217,7 +1236,7 @@ export default function DepotsRetraits() {
- { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}> {years.map(y => )} diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx index c2db01d..7920913 100644 --- a/frontend/src/pages/settings/ImportsSection.jsx +++ b/frontend/src/pages/settings/ImportsSection.jsx @@ -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 ( +
+ + {open && ( +
+

+ Collez ici le tableau JSON retourné par l'IA pour le module {moduleLabel} (avec ou sans bloc ```json```). +

+