Amélioration de l'importation de donnée
This commit is contained in:
@@ -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()}>
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user