Améliorationde l'import

This commit is contained in:
2026-07-04 18:55:40 +02:00
parent 11790c63fe
commit b138bc53ee
3 changed files with 180 additions and 8 deletions
+3 -3
View File
@@ -60,11 +60,11 @@ function mouvToCSV(rows) {
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function mouvToXLS(rows) {
function mouvToXLS(rows, multiDetenteur) {
const data = rows.map(r => ({
'Date': r.date_operation,
'Plateforme': r.plateforme_nom || '',
'Détenteur': r.plateforme_detenteur_nom || '',
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait',
'Montant (€)': r.montant,
}));
@@ -1155,7 +1155,7 @@ export default function DepotsRetraits() {
<ExportDropdown
disabled={rows.length === 0}
onCSV={() => dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(rows), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')}
/>
</div>
+130 -2
View File
@@ -133,6 +133,74 @@ const MODULE_LABEL = {
taux_pfu: 'Flat Tax — Taux PFU',
};
/** Normalise un en-tête de colonne pour reconnaissance auto : accents, casse, ponctuation ignorés */
function normalizeHeader(s) {
return String(s)
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]/g, '');
}
/** Synonymes usuels d'en-têtes de fichier reconnus pour chaque champ cible (comparés via normalizeHeader) */
const FIELD_SYNONYMS = {
date_operation: ['date'],
date_souscription: ['date'],
date_remb: ['date', 'dateremboursement'],
date_premiere_echeance:['datepremiereecheance', 'premiereecheance'],
date_cible: ['datecible'],
type: ['type'],
montant: ['montant'],
montant_investi: ['montant', 'montantinvesti', 'montantinvestissement'],
plateforme_id: ['plateforme'],
investissement_id: ['investissement', 'projet', 'nomprojet'],
nom_projet: ['projet', 'nomprojet', 'nom'],
nom: ['nom', 'plateforme'],
emetteur: ['emetteur'],
taux_interet: ['taux', 'tauxinteret'],
duree_mois: ['duree', 'dureemois'],
type_remb: ['typeremb', 'typederemboursement'],
freq_interets: ['frequence', 'freqinterets', 'periodicite'],
statut: ['statut'],
libelle: ['libelle', 'label', 'description'],
reference: ['reference', 'ref'],
notes: ['notes', 'commentaire', 'commentaires'],
url: ['url', 'siteweb', 'site'],
capital: ['capital'],
cashback: ['cashback'],
interets_bruts: ['interetsbruts', 'interets'],
prelev_sociaux: ['prelevementssociaux', 'prelevsociaux', 'ps'],
prelev_forfaitaire: ['prelevementforfaitaire', 'impotrevenu', 'ir'],
net_recu: ['netrecu', 'montantnet'],
annee: ['annee', 'year'],
pfu_total: ['pfutotal', 'pfu'],
impot_revenu: ['impotrevenu', 'impot'],
};
/** Champs pour lesquels la colonne mappée peut contenir un ID *ou* un nom texte (résolu côté serveur) */
const NAME_RESOLVABLE_FIELDS = new Set(['plateforme_id', 'investissement_id']);
/** Normalise un nom pour comparaison insensible casse/accents — miroir de normalizeName (backend) */
function normalizeName(s) {
return String(s || '')
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
}
/**
* Résout localement (aperçu) une valeur de colonne (ID ou nom) vers l'enregistrement correspondant,
* pour prévisualiser à l'utilisateur l'association qui sera faite côté serveur à l'import.
*/
function resolvePreviewMatch(raw, refList, nameKey) {
const rawStr = String(raw).trim();
if (/^\d+$/.test(rawStr)) {
return refList.find(x => String(x.id) === rawStr) || null;
}
const norm = normalizeName(rawStr);
return refList.find(x => normalizeName(x[nameKey]) === norm) || null;
}
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
@@ -297,11 +365,34 @@ export default function ImportsSection() {
const r = await api.upload('/imports/preview', fd);
setPreview(r);
const auto = {};
const usedHeaders = new Set();
for (const t of allTargets) {
const col = r.headers.find(h => h.toLowerCase().replace(/\W/g, '_') === t);
if (col) auto[t] = col;
// 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);
// 2) correspondance par synonyme usuel (ex. "Plateforme", "Date", "Montant (€)")
if (!col) {
const syns = FIELD_SYNONYMS[t] || [];
col = r.headers.find(h => !usedHeaders.has(h) && syns.includes(normalizeHeader(h)));
}
if (col) { auto[t] = col; usedHeaders.add(col); }
}
setMapping(auto);
// Pré-remplit la valeur par défaut quand toutes les lignes de l'échantillon
// se résolvent vers la même plateforme/investissement (confirme visuellement l'association).
const autoDefaults = {};
for (const t of Object.keys(auto)) {
if (!NAME_RESOLVABLE_FIELDS.has(t)) continue;
const refList = t === 'plateforme_id' ? plats : investissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set(
r.sampleRows.map(row => row[auto[t]]).filter(v => v !== null && v !== undefined && String(v).trim() !== '')
)];
if (rawValues.length !== 1) continue;
const match = resolvePreviewMatch(rawValues[0], refList, nameKey);
if (match) autoDefaults[t] = String(match.id);
}
if (Object.keys(autoDefaults).length > 0) setDefaults(d => ({ ...d, ...autoDefaults }));
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
@@ -404,12 +495,49 @@ export default function ImportsSection() {
<td>
<code style={{ fontSize: 11 }}>{t}</code>
{isReq && <span style={{ color: 'var(--danger)' }}> *</span>}
{NAME_RESOLVABLE_FIELDS.has(t) && (
<div className="text-muted" style={{ fontSize: 10, marginTop: 2 }}>
ID ou nom accepté (reconnu automatiquement)
</div>
)}
</td>
<td>
<select value={mapping[t] || ''} onChange={e => setMapping({ ...mapping, [t]: e.target.value })}>
<option value=""> ignorer </option>
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
{NAME_RESOLVABLE_FIELDS.has(t) && mapping[t] && (() => {
const refList = t === 'plateforme_id' ? plats : investissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set(
preview.sampleRows
.map(r => r[mapping[t]])
.filter(v => v !== null && v !== undefined && String(v).trim() !== '')
)];
if (rawValues.length === 0) return null;
return (
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
{rawValues.slice(0, 5).map(raw => {
const rawStr = String(raw).trim();
const match = resolvePreviewMatch(rawStr, refList, nameKey);
return (
<div key={rawStr} style={{ fontSize: 11, lineHeight: 1.3 }}>
{match ? (
<span style={{ color: 'var(--success)' }}> « {rawStr} » {match[nameKey]}</span>
) : (
<span style={{ color: 'var(--danger)' }}> « {rawStr} » introuvable ligne ignorée</span>
)}
</div>
);
})}
{rawValues.length > 5 && (
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
+{rawValues.length - 5} autre(s) valeur(s) dans le fichier
</div>
)}
</div>
);
})()}
</td>
<td>
{t === 'plateforme_id' ? (