Amélioration de l'importation de donnée

This commit is contained in:
2026-07-14 14:10:27 +02:00
parent 86a047a0c5
commit 4ef7cbee04
5 changed files with 729 additions and 49 deletions
@@ -1,10 +1,29 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
function IconBroom() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
}
const PURGE_SCOPES = [
{ value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements)' },
{ value: 'depots_retraits', label: 'Dépôts / Retraits uniquement' },
{ value: 'investissements', label: "Investissements (et leurs remboursements liés)" },
{ value: 'remboursements', label: 'Remboursements uniquement (les investissements sont conservés)' },
];
function purgeSummary(counts, scope) {
const parts = [];
if (counts.depots_retraits > 0) parts.push(`${counts.depots_retraits} dépôt(s)/retrait(s)`);
if (counts.investissements > 0) parts.push(`${counts.investissements} investissement(s)`);
if (counts.remboursements > 0) parts.push(`${counts.remboursements} remboursement(s)`);
if (scope === 'investissements' && counts.simul_remboursements > 0) {
parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`);
}
if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.';
return `Supprimé : ${parts.join(', ')}.`;
}
export default function DataCleanupSection() {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
@@ -20,6 +39,46 @@ export default function DataCleanupSection() {
const [successMsg, setSuccessMsg] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const [plats, setPlats] = useState([]);
const [purgePlatId, setPurgePlatId] = useState('');
const [purgeScope, setPurgeScope] = useState('all');
const [showPurgeModal, setShowPurgeModal] = useState(false);
const [purgeConfirmText, setPurgeConfirmText] = useState('');
const [loadingPurge, setLoadingPurge] = useState(false);
useEffect(() => {
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
const purgePlat = plats.find(p => String(p.id) === String(purgePlatId)) || null;
const openPurgeModal = () => {
if (!purgePlat) return;
setPurgeConfirmText('');
setShowPurgeModal(true);
};
const handlePurge = async () => {
if (!purgePlat || purgeConfirmText.trim() !== purgePlat.nom) return;
setLoadingPurge(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const r = await api.post(`/plateformes/${purgePlat.id}/purge-donnees`, {
scope: purgeScope,
confirmNom: purgeConfirmText.trim(),
});
setSuccessMsg(`"${r.plateforme}" — ${purgeSummary(r.counts, purgeScope)}`);
setShowPurgeModal(false);
setPurgeConfirmText('');
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowPurgeModal(false);
} finally {
setLoadingPurge(false);
}
};
const handleReprocess = async () => {
setLoadingReprocess(true);
setErrorMsg(null);
@@ -226,6 +285,41 @@ export default function DataCleanupSection() {
</button>
</div>
<div style={{ padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Supprimer les données d'une plateforme
</div>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 12 }}>
Efface les données rattachées à une plateforme (utile pour repartir d'une base propre avant
une réimportation). La fiche plateforme elle-même n'est jamais supprimée par cette action.
</div>
<div className="row" style={{ gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
<label style={{ fontSize: 12 }}>Plateforme</label>
<select value={purgePlatId} onChange={e => setPurgePlatId(e.target.value)}>
<option value="">Sélectionnez une plateforme…</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}</option>)}
</select>
</div>
<div style={{ flex: '1 1 280px', minWidth: 240 }}>
<label style={{ fontSize: 12 }}>Données à supprimer</label>
<select value={purgeScope} onChange={e => setPurgeScope(e.target.value)}>
{PURGE_SCOPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<button
className="danger"
style={{ flexShrink: 0 }}
onClick={openPurgeModal}
disabled={!purgePlatId || loadingPurge}
>
Supprimer
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
@@ -373,7 +467,50 @@ export default function DataCleanupSection() {
</div>
)}
{showPurgeModal && purgePlat && (
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}> Suppression de données action irréversible</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Vous vous apprêtez à supprimer, pour la plateforme <strong>{purgePlat.nom}</strong> :
</p>
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
border: '1px solid var(--border)' }}>
{PURGE_SCOPES.find(s => s.value === purgeScope)?.label}
</p>
<p style={{ margin: '0 0 16px', lineHeight: 1.6 }} className="text-muted">
La fiche plateforme "{purgePlat.nom}" est conservée. Cette opération ne peut pas être annulée.
</p>
<div style={{ margin: '0 0 20px' }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 6 }}>
Pour confirmer, retapez le nom exact de la plateforme : <strong>{purgePlat.nom}</strong>
</label>
<input
type="text"
value={purgeConfirmText}
onChange={e => setPurgeConfirmText(e.target.value)}
placeholder={purgePlat.nom}
disabled={loadingPurge}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowPurgeModal(false)} disabled={loadingPurge}>Annuler</button>
<button
className="danger"
onClick={handlePurge}
disabled={loadingPurge || purgeConfirmText.trim() !== purgePlat.nom}
>
{loadingPurge ? 'Suppression en cours…' : 'Confirmer la suppression'}
</button>
</div>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
+298 -31
View File
@@ -2,7 +2,7 @@ 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 { fmtDate, fmtEUR } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
@@ -319,6 +319,14 @@ const FIELD_HINTS_OVERRIDE = {
taux_pfu: {
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
},
remboursements: {
capital: 'part de capital remboursée, en euros (nombre). Voir la section RÈGLE CAPITAL vs INTÉRÊTS ci-dessous : '
+ 'ne déduis JAMAIS ce champ par simple lecture d\'une colonne "capital" si le fichier n\'en a pas — reconstitue-le '
+ 'si nécessaire à partir du montant total de la ligne et des prélèvements associés.',
interets_bruts: 'intérêts bruts perçus, en euros (nombre, avant prélèvements). ⚠️ Voir la section RÈGLE CAPITAL vs '
+ 'INTÉRÊTS ci-dessous avant de remplir ce champ : ne recopie JAMAIS aveuglément le montant total d\'une ligne de '
+ 'remboursement dans ce champ sans vérifier sa cohérence avec les prélèvements sociaux/IR associés.',
},
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"). '
@@ -363,23 +371,47 @@ function buildReferenceSection(mod, plats, investissements, scopePlateforme) {
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCet import concerne EXCLUSIVEMENT la plateforme "${scopePlateforme.nom}". `
+ `N'inclus PAS de champ plateforme_id dans le JSON généré (il est déjà connu et sera appliqué automatiquement à chaque ligne).\n\n`;
}
if (plats.length > 0) {
if (plats.length === 1) {
const only = plats[0].nom;
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME (une seule existante)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUne seule plateforme est enregistrée à ce jour : "${only}". Il n'y a donc AUCUNE ambiguïté possible : utilise "${only}" comme valeur de plateforme_id pour TOUTES les lignes générées, sans me poser de question — même si le fichier ne mentionne aucun nom de plateforme, aucun logo ni en-tête identifiable.\nNe me pose une question QUE dans le cas précis suivant : le fichier mentionne EXPLICITEMENT un nom de plateforme différent de "${only}" (texte, logo ou en-tête clairement identifiable). Dans ce seul cas, NE GÉNÈRE PAS le JSON tout de suite et indique-moi le nom trouvé en me demandant s'il s'agit d'une nouvelle plateforme à créer ou d'une erreur de fichier.\n\n`;
}
if (plats.length > 1) {
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(' | ');
const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : '';
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE `
+ `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme `
+ `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et `
+ `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n`
+ `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec `
+ `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée `
+ `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : `
+ `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n`
+ `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`;
if (mod === 'remboursements') {
let out = `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nRÈGLE CAPITAL vs INTÉRÊTS\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
+ `De nombreux exports de plateformes affichent UNE SEULE ligne "Remboursement" / "Remboursement mensualité" par échéance, `
+ `dont le montant peut mélanger CAPITAL et INTÉRÊTS (prêts amortissables), accompagnée de une ou deux lignes de prélèvement `
+ `fiscal séparées juste avant/après dans le fichier (souvent nommées "CSG/CRDS", "Prélèvement IR/PFU", "Prélèvements sociaux", etc.). `
+ `Ne recopie JAMAIS le montant total d'une ligne de remboursement dans interets_bruts sans avoir vérifié ceci :\n`
+ `1. Le taux global de prélèvement français sur des intérêts est d'environ 30 % (≈17,2 % de prélèvements sociaux + ≈12,8 % de prélèvement forfaitaire/IR).\n`
+ `2. Calcule le ratio (prélèvements sociaux + prélèvement forfaitaire trouvés) ÷ (montant total de la ligne de remboursement). `
+ `Si ce ratio est PROCHE de 30 % (tolérance large, ex. 20 à 35 %), la ligne est un remboursement d'INTÉRÊTS PUR : interets_bruts = montant total de la ligne, n'inclus PAS de champ capital.\n`
+ `3. Si ce ratio est NETTEMENT inférieur (quelques % voire < 1 %), la ligne mélange capital et intérêts : reconstitue le VRAI montant `
+ `d'intérêts bruts à partir des prélèvements eux-mêmes — interets_bruts ≈ prélèvement_sociaux ÷ 0,172, à recouper avec prélèvement_forfaitaire ÷ 0,128 `
+ `(les deux estimations doivent être proches ; en cas d'écart, privilégie leur moyenne) — puis capital = montant total de la ligne interets_bruts ainsi recalculé. `
+ `N'utilise JAMAIS le montant total brut de la ligne comme interets_bruts dans ce cas.\n`
+ `4. Si AUCUNE ligne de prélèvement n'est associée à la ligne de remboursement (aucun CSG/CRDS ni prélèvement IR/PFU adjacent), il s'agit d'un remboursement `
+ `de capital PUR (souvent l'échéance finale d'un prêt in fine/différé) : capital = montant total de la ligne, n'inclus ni interets_bruts, ni prelev_sociaux, ni prelev_forfaitaire.\n`
+ `5. Dans tous les cas, net_recu = montant total réellement reçu (capital + intérêts nets). Si tu inclus capital ET interets_bruts sur la même ligne (mensualité mixte), `
+ `assure-toi que net_recu = capital + (interets_bruts prelev_sociaux prelev_forfaitaire).\n\n`;
if (investissements.length > 0) {
const names = investissements.map(i => i.nom_projet).join(' | ');
const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : '';
out += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE `
+ `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme `
+ `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et `
+ `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n`
+ `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec `
+ `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée `
+ `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : `
+ `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n`
+ `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`;
}
return out;
}
return '';
}
@@ -447,9 +479,18 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
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 style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy}>
{busy ? '' : 'Analyser les données '}
</button>
<button
type="button"
onClick={() => { setIaJson(''); setIaErr(null); }}
disabled={!iaJson || busy}
>
Vider
</button>
</div>
</div>
)}
</div>
@@ -534,6 +575,173 @@ function IaImportPromptBlock({ module, moduleLabel, plats, investissements, scop
);
}
/* ══════════════════════════════════════════════════════════════
Modale de doublons — présente, ligne par ligne, la correspondance
trouvée en base en regard de la ligne importée, pour accepter ou
refuser chaque import en doublon (tous modules confondus).
══════════════════════════════════════════════════════════════ */
/** Libellés humains des champs pouvant apparaître dans incoming/existing */
const DUP_FIELD_LABELS = {
plateforme: 'Plateforme', date_operation: 'Date', type: 'Type', montant: 'Montant',
libelle: 'Libellé', reference: 'Référence',
nom_projet: 'Projet', date_souscription: 'Date souscription', montant_investi: 'Montant investi',
taux_interet: "Taux d'intérêt", duree_mois: 'Durée (mois)', statut: 'Statut',
investissement: 'Investissement', date_remb: 'Date remboursement', capital: 'Capital',
cashback: 'Cashback', interets_bruts: 'Intérêts bruts', prelev_sociaux: 'Prélèv. sociaux',
prelev_forfaitaire: 'Prélèv. forfaitaire',
nom: 'Nom', url: 'URL', notes: 'Notes',
annee: 'Année', pfu_total: 'PFU total (%)', impot_revenu: 'Impôt revenu (%)',
};
/** Formatage léger d'une valeur selon le nom du champ (date / montant / brut) */
function fmtDupValue(field, value) {
if (value === null || value === undefined || value === '') return '—';
if (field.startsWith('date_') || field === 'date_operation') return fmtDate(value);
if (['montant', 'montant_investi', 'capital', 'cashback', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire'].includes(field)) {
return fmtEUR(Number(value));
}
if (['taux_interet', 'pfu_total', 'impot_revenu'].includes(field)) return `${value} %`;
return String(value);
}
/** true si les deux valeurs diffèrent (comparaison souple, insensible au type) */
function dupValuesDiffer(a, b) {
if (a === null || a === undefined) a = '';
if (b === null || b === undefined) b = '';
return String(a) !== String(b);
}
/** Une ligne "doublon" repliable : case à cocher en tête de ligne (comme titre), détail masqué par défaut */
function DuplicateRow({ d, decision, onDecisionChange, busy }) {
const [detailOpen, setDetailOpen] = useState(false);
const fields = [...new Set([...Object.keys(d.existing || {}), ...Object.keys(d.incoming || {})])];
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', background: 'var(--surface-2, var(--bg))',
}}>
<label style={{
display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0, cursor: 'pointer',
// Neutralise le style global des <label> (légendes de champ en majuscules) :
// ici c'est le titre cliquable d'une ligne à cocher, pas une légende de formulaire.
textTransform: 'none', letterSpacing: 'normal', margin: 0, fontWeight: 400,
fontSize: 13, color: 'var(--text)',
}}>
<input
type="checkbox"
checked={decision === 'accept'}
disabled={busy}
// Le style global `input { width: 100%; padding: 7px 10px; }` (prévu pour les champs
// texte) s'applique aussi aux cases à cocher — on le neutralise ici explicitement
// (même correctif que .cat-select-item / .plat-multiselect-item dans ce fichier CSS).
style={{ width: 14, height: 14, padding: 0, flexShrink: 0, accentColor: 'var(--primary)' }}
onChange={e => onDecisionChange(e.target.checked ? 'accept' : 'skip')}
/>
<span style={{ fontWeight: 600 }}>
Doublon de données repéré en ligne {d.row} avec {d.matchedRow ? `celles de la ligne ${d.matchedRow}` : 'un enregistrement déjà en base'}
</span>
</label>
<button
type="button"
onClick={() => setDetailOpen(o => !o)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex', color: 'var(--text-muted)' }}
title={detailOpen ? 'Masquer la comparaison' : 'Voir la comparaison'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: detailOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
{detailOpen && (
<div style={{ padding: '10px 12px', borderTop: '1px solid var(--border)' }}>
{d.matchedRow && (
<div className="text-muted" style={{ fontSize: 11, marginBottom: 6 }}>
Doublon avec la ligne {d.matchedRow} du même fichier (pas encore en base)
</div>
)}
<table style={{ margin: 0, fontSize: 12 }}>
<thead>
<tr><th>Champ</th><th>{d.matchedRow ? `Ligne ${d.matchedRow} du fichier` : 'Existant en base'}</th><th>Ligne importée</th></tr>
</thead>
<tbody>
{fields.map(f => {
const diff = dupValuesDiffer(d.existing?.[f], d.incoming?.[f]);
return (
<tr key={f}>
<td className="text-muted">{DUP_FIELD_LABELS[f] || f}</td>
<td>{fmtDupValue(f, d.existing?.[f])}</td>
<td style={diff ? { color: 'var(--warning)', fontWeight: 600 } : undefined}>
{fmtDupValue(f, d.incoming?.[f])}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function DuplicatesModal({ open, module, moduleLabel, duplicates, decisions, setDecisions, onConfirm, onCancel, busy }) {
if (!open) return null;
const isTauxPfu = module === 'taux_pfu';
const setAll = (decision) => {
const next = {};
for (const d of duplicates) next[d.row] = decision;
setDecisions(next);
};
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>⚠ {duplicates.length} doublon{duplicates.length > 1 ? 's' : ''} détecté{duplicates.length > 1 ? 's' : ''} — {moduleLabel}</h3>
</div>
<p className="text-muted" style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.5 }}>
Ces lignes du fichier correspondent à des enregistrements déjà présents en base.
{isTauxPfu
? ' Par défaut, le taux existant sera mis à jour avec les nouvelles valeurs (comme aujourd\'hui) — décochez pour le conserver tel quel.'
: ' Par défaut, elles sont ignorées — cochez pour les importer quand même.'}
{' '}Les autres lignes du fichier (non listées ici) seront importées normalement.
{' '}Cliquez sur la flèche pour voir le détail de la comparaison.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('accept')} disabled={busy}>
Cocher tous les doublons
</button>
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('skip')} disabled={busy}>
Décocher tous les doublons
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
{duplicates.map(d => (
<DuplicateRow
key={d.row}
d={d}
decision={decisions[d.row] ?? (isTauxPfu ? 'accept' : 'skip')}
onDecisionChange={(val) => setDecisions({ ...decisions, [d.row]: val })}
busy={busy}
/>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<button onClick={onCancel} disabled={busy}>Annuler l'import</button>
<button className="primary" onClick={onConfirm} disabled={busy}>
{busy ? 'Import en cours…' : 'Confirmer et importer'}
</button>
</div>
</div>
</div>
);
}
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
@@ -692,6 +900,16 @@ export default function ImportsSection() {
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
// Ré-affiche le résultat du dernier import juste après le rechargement automatique de la
// page qui suit un import réussi (voir runApply) — sessionStorage survit au reload.
useEffect(() => {
const msg = sessionStorage.getItem('cl_import_last_result');
if (msg) {
setResult({ ok: true, msg });
sessionStorage.removeItem('cl_import_last_result');
}
}, []);
// Nettoie le paramètre ?module= une fois consommé (évite de le reproposer au refresh)
useEffect(() => {
if (!searchParams.get('module')) return;
@@ -799,27 +1017,64 @@ export default function ImportsSection() {
await runPreview(iaFile, module);
};
const apply = async () => {
// Modale de doublons — se déclenche automatiquement au clic sur "Importer" (voir apply()).
const [dupModalOpen, setDupModalOpen] = useState(false);
const [dupList, setDupList] = useState([]);
const [dupDecisions, setDupDecisions] = useState({});
const [dupBusy, setDupBusy] = useState(false);
const runApply = async (duplicateDecisions = {}) => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
duplicateDecisions,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
+ (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '')
+ (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '')
+ '.'
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''),
});
setAnomalies(r.anomalies || []);
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
const msg = `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
+ (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '')
+ (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '')
+ '.'
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : '');
// Le résultat est gardé en session pour être ré-affiché juste après le rechargement
// complet de la page (voir useEffect au montage), le temps que l'historique/les listes
// se rafraîchissent dans un état propre.
sessionStorage.setItem('cl_import_last_result', msg);
setIaJson(''); setIaErr(null);
window.location.reload();
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
finally { setBusy(false); setDupBusy(false); }
};
const apply = async () => {
if (!preview) return;
setBusy(true); setErr(null);
try {
const check = await api.post('/imports/check-duplicates', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
if (check.duplicates && check.duplicates.length > 0) {
// Par défaut : ignorer (comportement historique), sauf pour taux_pfu où le comportement
// historique est d'écraser le taux existant.
const initial = {};
for (const d of check.duplicates) initial[d.row] = module === 'taux_pfu' ? 'accept' : 'skip';
setDupList(check.duplicates);
setDupDecisions(initial);
setDupModalOpen(true);
setBusy(false);
return;
}
await runApply({});
} catch (e) {
setErr(e.message);
setBusy(false);
}
};
const confirmDuplicatesAndApply = async () => {
setDupBusy(true);
await runApply(dupDecisions);
};
const fixDateOuverture = async (anomaly) => {
@@ -1183,6 +1438,18 @@ export default function ImportsSection() {
</tbody>
</table>
</div>
<DuplicatesModal
open={dupModalOpen}
module={module}
moduleLabel={def?.label ?? module}
duplicates={dupList}
decisions={dupDecisions}
setDecisions={setDupDecisions}
onConfirm={confirmDuplicatesAndApply}
onCancel={() => { setDupModalOpen(false); setDupList([]); setDupDecisions({}); }}
busy={dupBusy}
/>
</>
);
}
+14 -1
View File
@@ -724,11 +724,24 @@ tr:hover td { background: var(--surface-2); }
}
/* Modal */
.modal-backdrop {
.modal-backdrop, .modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
}
/* Boîte de contenu des modales "ad hoc" (celles qui n'utilisent pas le composant
partagé Modal.jsx) : mêmes fondations visuelles que .card, avec gestion du
débordement pour les contenus longs (ex. listes de doublons). */
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 20px;
width: 100%;
overflow: auto;
}
.modal-header { display: flex; justify-content: space-between; align-items: center; }
/* Login */
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }