Compare commits
2 Commits
8bcd7e603d
...
1f75878d87
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f75878d87 | |||
| 5e775da711 |
@@ -499,6 +499,12 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
||||
- **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`
|
||||
|
||||
### Bug — suppression de compte cassée par plateforme_id ON DELETE RESTRICT
|
||||
- **Symptôme** : `DELETE /api/auth/me` échouait avec `SqliteError: FOREIGN KEY constraint failed` (code `SQLITE_CONSTRAINT_TRIGGER`) à `auth.js:319`.
|
||||
- **Cause** : `depots_retraits.plateforme_id` et `investissements.plateforme_id` étaient en `ON DELETE RESTRICT` (seuls FK RESTRICT du schéma) — lors du `DELETE FROM users`, les branches de cascade `users→plateformes` et `users→investisseurs→depots_retraits/investissements` sont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT.
|
||||
- **Fix** : migration `fixPlateformeCascade` dans `db/index.js` (recréation table via `__repair_*`, RESTRICT→CASCADE) + `schema.sql` mis à jour + protection déplacée côté application dans `DELETE /api/plateformes/:id` (vérification explicite du nombre d'investissements/dépôts-retraits avant suppression, message clair).
|
||||
- **Règle à retenir** : toute nouvelle table référençant `plateformes`/`investisseurs`/`users` doit être en `CASCADE` (ou `SET NULL`), jamais `RESTRICT` — sinon la suppression de compte se recasse. Les protections anti-suppression-accidentelle doivent être implémentées côté route, pas côté contrainte FK.
|
||||
|
||||
### 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`).
|
||||
|
||||
@@ -2086,4 +2086,69 @@ console.log('[DB] Migrations 2FA OK');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : plateforme_id en CASCADE (au lieu de RESTRICT) sur
|
||||
// depots_retraits et investissements ────────────────────────────────────
|
||||
// RESTRICT empêchait la suppression complète d'un compte (DELETE FROM users) :
|
||||
// la cascade users→plateformes (user_id CASCADE) et users→investisseurs→
|
||||
// depots_retraits/investissements (CASCADE) sont deux branches indépendantes
|
||||
// de l'arbre de suppression ; SQLite peut supprimer une plateforme avant les
|
||||
// lignes qui la référencent encore sur l'autre branche, ce qui déclenche le
|
||||
// RESTRICT (erreur SQLITE_CONSTRAINT_TRIGGER). La protection "impossible de
|
||||
// supprimer une plateforme qui a encore des données" est déplacée dans la
|
||||
// route DELETE /api/plateformes/:id (vérification explicite avec message clair).
|
||||
{
|
||||
const fixPlateformeCascade = (tableName, indexStatements) => {
|
||||
const row = db.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
|
||||
).get(tableName);
|
||||
if (!row || !/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i.test(row.sql)) return;
|
||||
|
||||
const tempName = `__repair_${tableName}`;
|
||||
const nameRe = new RegExp(
|
||||
`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["'\`\\[]?${tableName}["'\`\\]]?`, 'i'
|
||||
);
|
||||
if (!nameRe.test(row.sql)) {
|
||||
console.error(`[DB] migration plateforme_id CASCADE : nom de table non reconnu dans le DDL de "${tableName}", migration ignorée.`);
|
||||
return;
|
||||
}
|
||||
const fixedDdl = row.sql
|
||||
.replace(nameRe, `CREATE TABLE "${tempName}"`)
|
||||
.replace(/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i, 'REFERENCES plateformes(id) ON DELETE CASCADE');
|
||||
|
||||
const colDefs = db.prepare(`PRAGMA table_info("${tableName}")`).all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
|
||||
const idxs = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL`
|
||||
).all(tableName);
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec(`DROP TABLE "${tableName}"`);
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
for (const stmt of indexStatements) db.exec(stmt);
|
||||
console.log(`[DB] migration : plateforme_id passé en ON DELETE CASCADE sur "${tableName}".`);
|
||||
};
|
||||
|
||||
fixPlateformeCascade('depots_retraits', [
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_inv ON depots_retraits(investisseur_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_plat ON depots_retraits(plateforme_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation)',
|
||||
]);
|
||||
|
||||
fixPlateformeCascade('investissements', [
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
|
||||
]);
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -70,7 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id);
|
||||
CREATE TABLE IF NOT EXISTS depots_retraits (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
date_operation TEXT NOT NULL, -- ISO YYYY-MM-DD
|
||||
type TEXT NOT NULL CHECK(type IN ('depot','retrait')),
|
||||
montant REAL NOT NULL CHECK(montant >= 0),
|
||||
@@ -92,7 +92,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
|
||||
CREATE TABLE IF NOT EXISTS investissements (
|
||||
id INTEGER PRIMARY KEY,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT,
|
||||
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
|
||||
nom_projet TEXT NOT NULL,
|
||||
emetteur TEXT, -- nom de la société emprunteuse
|
||||
date_souscription TEXT NOT NULL,
|
||||
|
||||
@@ -344,6 +344,43 @@ router.post('/apply', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/imports/template
|
||||
* Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis,
|
||||
* un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles),
|
||||
* y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur
|
||||
* peut ainsi compléter/enrichir ce fichier avant de le réimporter.
|
||||
*/
|
||||
router.post('/template', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping = {}, defaults = {} } = req.body || {};
|
||||
if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis');
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
const tempPath = path.join(UPLOAD_DIR, tempId);
|
||||
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
|
||||
|
||||
const origName = req.body.originalFilename || '';
|
||||
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
||||
|
||||
const allTargets = [...def.requiredTargets, ...def.optionalTargets];
|
||||
const v = (row, target) => {
|
||||
const col = mapping[target];
|
||||
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
|
||||
return defaults[target] !== undefined ? defaults[target] : '';
|
||||
};
|
||||
|
||||
const templateRows = rows.map(row => {
|
||||
const out = {};
|
||||
for (const t of allTargets) out[t] = v(row, t);
|
||||
return out;
|
||||
});
|
||||
|
||||
res.json({ headers: allTargets, rows: templateRows });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/imports/dossier
|
||||
* Importe un dossier investissement complet (format d'export natif).
|
||||
|
||||
@@ -679,6 +679,16 @@ router.delete('/:id', (req, res, next) => {
|
||||
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!plat) throw new HttpError(404, 'Not found');
|
||||
|
||||
// plateforme_id est en ON DELETE CASCADE sur investissements/depots_retraits (nécessaire pour
|
||||
// permettre la suppression complète d'un compte) — on protège donc ici, explicitement, contre
|
||||
// la suppression accidentelle d'une plateforme qui a encore des données rattachées.
|
||||
const { n: nbInv } = db.prepare('SELECT COUNT(*) AS n FROM investissements WHERE plateforme_id = ?').get(req.params.id);
|
||||
const { n: nbDr } = db.prepare('SELECT COUNT(*) AS n FROM depots_retraits WHERE plateforme_id = ?').get(req.params.id);
|
||||
if (nbInv > 0 || nbDr > 0) {
|
||||
throw new HttpError(400, `Impossible de supprimer cette plateforme : elle a encore ${nbInv} investissement(s) et ${nbDr} mouvement(s) de dépôt/retrait enregistrés. Supprimez-les d'abord.`);
|
||||
}
|
||||
|
||||
const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?')
|
||||
.run(req.params.id, req.user.id);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { api } from '../../api.js';
|
||||
import { fmtDate } from '../../utils/format.js';
|
||||
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
|
||||
@@ -13,6 +14,79 @@ function dlBlob(content, filename, type) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
|
||||
function timestampSuffix() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function templateToCSV(headers, rows) {
|
||||
const BOM = ''; const sep = ';';
|
||||
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const data = rows.map(r => headers.map(h => r[h] ?? ''));
|
||||
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
function templateToXLS(headers, rows) {
|
||||
const data = rows.map(r => {
|
||||
const obj = {};
|
||||
headers.forEach(h => { obj[h] = r[h] ?? ''; });
|
||||
return obj;
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet(data, { header: headers });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Import');
|
||||
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||||
}
|
||||
function templateToJSON(rows) {
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}
|
||||
|
||||
/** Popup de choix de format, déclenché par un bouton texte (pas une icône) */
|
||||
function TemplateDropdown({ disabled, busy, onCSV, onXLS, onJSON }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
const choose = fn => { setOpen(false); fn(); };
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button type="button" disabled={disabled || busy} onClick={() => setOpen(o => !o)}>
|
||||
{busy ? '…' : 'Générer un fichier pour compléter votre importation'}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="export-dropdown" role="menu">
|
||||
<button role="menuitem" onClick={() => choose(onCSV)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
|
||||
</svg>
|
||||
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onXLS)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
|
||||
</svg>
|
||||
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => choose(onJSON)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
|
||||
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
|
||||
</svg>
|
||||
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
|
||||
const FISCALITE_LABELS = {
|
||||
flat_tax: 'Flat Tax',
|
||||
@@ -245,6 +319,25 @@ const FIELD_HINTS_OVERRIDE = {
|
||||
taux_pfu: {
|
||||
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
|
||||
},
|
||||
investissements: {
|
||||
nom_projet: 'trouve le nom du projet naturellement dans le fichier, puis FORMATE-le ainsi : '
|
||||
+ '"NOM DE L\'ÉMETTEUR EN MAJUSCULES - nom du projet trouvé" (ex: "SCI DUPONT - Résidence Les Tilleuls"). '
|
||||
+ 'Anti-doublon RENFORCÉ, à appliquer en 2 temps : '
|
||||
+ '1) Avant de préfixer, cherche dans TOUT le nom du projet trouvé (pas seulement en tête) une occurrence du nom '
|
||||
+ 'de l\'émetteur — y compris partielle ou abrégée (ex. "HMC" est une abréviation de "HMC GROUP", "MULTIPRINT" est '
|
||||
+ 'le premier mot de "MULTIPRINT SAM") ou une variante proche (casse différente, "&" à la place de "ET", '
|
||||
+ 'singulier/pluriel, ponctuation différente, ex. "QUALI PARTS & SERVICE" proche de "QUALI PARTS ET SERVICES"). '
|
||||
+ '2) Si une telle occurrence existe n\'importe où dans le nom du projet trouvé, ne rajoute PAS le préfixe et '
|
||||
+ 'SUPPRIME cette occurrence redondante du nom du projet (garde uniquement la partie descriptive restante, ex. '
|
||||
+ '"HMC GROUP - HMC - Commande CSH25" devient "HMC GROUP - Commande CSH25", '
|
||||
+ '"MULTIPRINT SAM - MULTIPRINT - ACM 06/2025" devient "MULTIPRINT SAM - ACM 06/2025", '
|
||||
+ '"QUALI PARTS ET SERVICES - QUALI PARTS & SERVICE - Fact 981124" devient "QUALI PARTS ET SERVICES - Fact 981124"). '
|
||||
+ 'Ne garde JAMAIS deux mentions (même partielles ou orthographiées différemment) du même émetteur dans le résultat final. '
|
||||
+ 'Correction typographique : si le nom du projet contient des sigles ou des noms composés de segments courts '
|
||||
+ 'séparés par des virgules (ex. "A,I,E,", "B,L,M,F,", "TWELVE,COM"), il s\'agit presque toujours d\'un point '
|
||||
+ 'mal interprété (OCR) — remplace ces virgules par des points ("A.I.E.", "B.L.M.F.", "TWELVE.COM"). '
|
||||
+ 'Si l\'émetteur est introuvable, laisse simplement le nom du projet tel quel, sans préfixe.',
|
||||
},
|
||||
};
|
||||
|
||||
function buildFieldsList(mod) {
|
||||
@@ -347,9 +440,8 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
|
||||
}
|
||||
|
||||
/** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */
|
||||
function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
|
||||
function IaImportPromptBlock({ module, moduleLabel, plats, investissements, open, setOpen }) {
|
||||
const LS_KEY = 'cl_import_ia_prompt_template';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT);
|
||||
const [draft, setDraft] = useState('');
|
||||
@@ -474,7 +566,7 @@ function DossierImport({
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Import — Dossier investissement</h3>
|
||||
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source de votre dossier d'investissement</h3>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
|
||||
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
|
||||
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
|
||||
@@ -552,6 +644,9 @@ export default function ImportsSection() {
|
||||
const m = searchParams.get('module');
|
||||
return MODULES[m] ? m : 'depots_retraits';
|
||||
});
|
||||
// null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement.
|
||||
// Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source".
|
||||
const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [mapping, setMapping] = useState({});
|
||||
@@ -564,6 +659,7 @@ export default function ImportsSection() {
|
||||
const [err, setErr] = useState(null);
|
||||
const [anomalies, setAnomalies] = useState([]);
|
||||
const [fixingPlatId, setFixingPlatId] = useState(null);
|
||||
const [templateBusy, setTemplateBusy] = useState(false);
|
||||
|
||||
const [dossierFile, setDossierFile] = useState(null);
|
||||
const [dossierPreview, setDossierPreview] = useState(null);
|
||||
@@ -652,6 +748,7 @@ export default function ImportsSection() {
|
||||
/** Parse le JSON collé (issu d'un assistant IA) et l'envoie dans le même pipeline d'analyse. */
|
||||
const [iaJson, setIaJson] = useState('');
|
||||
const [iaErr, setIaErr] = useState(null);
|
||||
const [iaPromptOpen, setIaPromptOpen] = useState(false);
|
||||
const analyzeIaJson = async () => {
|
||||
setIaErr(null);
|
||||
let arr;
|
||||
@@ -661,6 +758,7 @@ export default function ImportsSection() {
|
||||
} catch (e) { setIaErr('JSON invalide : ' + e.message); return; }
|
||||
if (!Array.isArray(arr)) { setIaErr("Le JSON doit être un tableau d'objets (une entrée par ligne)."); return; }
|
||||
if (arr.length === 0) { setIaErr('Le tableau est vide.'); return; }
|
||||
setIaPromptOpen(false); // replie le bloc "Prompt IA" une fois l'analyse lancée
|
||||
const blob = new Blob([JSON.stringify(arr)], { type: 'application/json' });
|
||||
const iaFile = new File([blob], 'import-ia.json', { type: 'application/json' });
|
||||
await runPreview(iaFile, module);
|
||||
@@ -701,22 +799,40 @@ export default function ImportsSection() {
|
||||
finally { setFixingPlatId(null); }
|
||||
};
|
||||
|
||||
const generateTemplate = async (format) => {
|
||||
if (!preview) return;
|
||||
setTemplateBusy(true);
|
||||
try {
|
||||
const r = await api.post('/imports/template', {
|
||||
tempId: preview.tempId, module, mapping, defaults,
|
||||
originalFilename: file?.name ?? preview.filename,
|
||||
});
|
||||
const base = `Import ${def?.label ?? module} - modele a completer ${timestampSuffix()}`;
|
||||
if (format === 'csv') dlBlob(templateToCSV(r.headers, r.rows), `${base}.csv`, 'text/csv;charset=utf-8');
|
||||
if (format === 'xls') dlBlob(templateToXLS(r.headers, r.rows), `${base}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
if (format === 'json') dlBlob(templateToJSON(r.rows), `${base}.json`, 'application/json');
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setTemplateBusy(false); }
|
||||
};
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>1. Contexte de l'import</h3>
|
||||
<h3 style={{ margin: '0 0 4px' }}>Étape 1 - Précisez le contexte de l'import</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
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 style={{ flex: 1 }}>
|
||||
<div style={{ flex: '0 0 auto', width: 280 }}>
|
||||
<label>Module cible</label>
|
||||
<select value={module} onChange={e => {
|
||||
setModule(e.target.value);
|
||||
const v = e.target.value;
|
||||
setModule(v);
|
||||
setPreview(null); setMapping({}); setResult(null); setErr(null);
|
||||
if (v !== 'investissements' && activeMode === 'dossier') setActiveMode(null);
|
||||
}}>
|
||||
{Object.entries(MODULES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
@@ -725,6 +841,35 @@ export default function ImportsSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'fichier' ? 'primary' : 'secondary'}
|
||||
style={{ flex: '0 0 auto', width: 'auto' }}
|
||||
onClick={() => setActiveMode(m => m === 'fichier' ? null : 'fichier')}
|
||||
>
|
||||
Import par fichier source
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'ia' ? 'primary' : 'secondary'}
|
||||
style={{ flex: '0 0 auto', width: 'auto' }}
|
||||
onClick={() => setActiveMode(m => m === 'ia' ? null : 'ia')}
|
||||
>
|
||||
Import avec l'aide de l'IA
|
||||
</button>
|
||||
{module === 'investissements' && (
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'dossier' ? 'primary' : 'secondary'}
|
||||
style={{ flex: '0 0 auto', width: 'auto' }}
|
||||
onClick={() => setActiveMode(m => m === 'dossier' ? null : 'dossier')}
|
||||
>
|
||||
Import par dossier d'investissement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{def?.note && (
|
||||
<div className="import-module-note" style={{ marginTop: 12 }}>
|
||||
{def.global && (
|
||||
@@ -746,8 +891,9 @@ export default function ImportsSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeMode === 'fichier' && (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>2. Fichier source</h3>
|
||||
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le 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>
|
||||
@@ -765,12 +911,56 @@ export default function ImportsSection() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
|
||||
{activeMode === 'dossier' && 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(() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeMode === 'ia' && (
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Aidez-vous d'une IA pour préparer les données à importer</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}
|
||||
open={iaPromptOpen}
|
||||
setOpen={setIaPromptOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(err || result || anomalies.length > 0) && (
|
||||
<div className="card">
|
||||
{err && <div className="error">{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} />
|
||||
|
||||
{anomalies.length > 0 && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ marginTop: err || result ? 12 : 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{anomalies.map(a => (
|
||||
<div key={a.plateforme_id} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
@@ -795,11 +985,12 @@ export default function ImportsSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>3. Mappage des colonnes</h3>
|
||||
<h3 style={{ marginTop: 0 }}>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.
|
||||
@@ -887,6 +1078,13 @@ export default function ImportsSection() {
|
||||
<button className="primary" onClick={apply} disabled={busy || missingInv}>
|
||||
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
|
||||
</button>
|
||||
<TemplateDropdown
|
||||
disabled={missingInv}
|
||||
busy={templateBusy}
|
||||
onCSV={() => generateTemplate('csv')}
|
||||
onXLS={() => generateTemplate('xls')}
|
||||
onJSON={() => generateTemplate('json')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -906,44 +1104,6 @@ export default function ImportsSection() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Le dossier investissement (capital + remboursements + historique) n'a de sens
|
||||
que dans le contexte du module Investissements. */}
|
||||
{module === 'investissements' && (
|
||||
<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>
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user