Modification de l'import avec prompt IA
This commit is contained in:
@@ -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`).
|
||||||
|
|||||||
@@ -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); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +906,9 @@ export default function ImportsSection() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Le dossier investissement (capital + remboursements + historique) n'a de sens
|
||||||
|
que dans le contexte du module Investissements. */}
|
||||||
|
{module === 'investissements' && (
|
||||||
<DossierImport
|
<DossierImport
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
@@ -599,6 +920,29 @@ export default function ImportsSection() {
|
|||||||
dossierInputRef={dossierInputRef}
|
dossierInputRef={dossierInputRef}
|
||||||
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user