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
+223 -15
View File
@@ -93,7 +93,14 @@ router.post('/preview', upload.single('file'), (req, res, next) => {
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
/**
* POST /api/imports/check-duplicates
* Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base :
* rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée,
* les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que
* l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel.
*/
router.post('/check-duplicates', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {} } = req.body || {};
if (!tempId || !module || !mapping) {
@@ -102,6 +109,190 @@ router.post('/apply', (req, res, next) => {
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
if (INVESTISSEUR_SCOPED.includes(module)) {
requireInvestisseur(req, res, () => {});
}
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
const origName = req.body.originalFilename || '';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map();
if (module === 'depots_retraits' || module === 'investissements') {
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
platIdSet = new Set(platRows.map(p => p.id));
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
platNomById = new Map(platRows.map(p => [p.id, p.nom]));
}
let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map();
if (module === 'remboursements') {
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
invIdSet = new Set(invRows.map(i => i.id));
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
invNomById = new Map(invRows.map(i => [i.id, i.nom_projet]));
}
const duplicates = [];
// Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction,
// donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente
// occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base.
// Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire :
// clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée.
const seenDepotsRetraits = new Map();
const seenInvestissements = new Map();
const seenRemboursements = new Map();
const seenPlateformes = new Map();
const seenTauxPfu = new Map();
for (let idx = 0; idx < rows.length; idx++) {
const row = rows[idx];
const v = (target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target];
};
const rowNum = idx + 2;
try {
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation,
type, montant, libelle: v('libelle') || null, reference: v('reference') || null,
};
const existing = db.prepare(`
SELECT * FROM depots_retraits
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`;
const seen = seenDepotsRetraits.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation,
type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenDepotsRetraits.set(key, { row: rowNum, incoming });
}
} else if (module === 'investissements') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription,
montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null,
duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours',
};
const existing = db.prepare(`
SELECT * FROM investissements
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`;
const seen = seenInvestissements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet,
date_souscription: existing.date_souscription, montant_investi: existing.montant_investi,
taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenInvestissements.set(key, { row: rowNum, incoming });
}
} else if (module === 'remboursements') {
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
const dateRemb = normaliseDate(v('date_remb'));
const capital = num(v('capital'));
const bruts = num(v('interets_bruts'));
const incoming = {
investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital,
cashback: num(v('cashback')), interets_bruts: bruts,
prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')),
statut: v('statut') || 'paye',
};
const existing = db.prepare(`
SELECT * FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`;
const seen = seenRemboursements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb,
capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts,
prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire,
statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenRemboursements.set(key, { row: rowNum, incoming });
}
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) continue;
const incoming = { nom, url: v('url') || null, notes: v('notes') || null };
const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
const key = nom;
const seen = seenPlateformes.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenPlateformes.set(key, { row: rowNum, incoming });
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee) continue;
const incoming = {
annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')),
};
const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee);
const key = annee;
const seen = seenTauxPfu.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenTauxPfu.set(key, { row: rowNum, incoming });
}
}
} catch {
// Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur
// par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort.
}
}
res.json({ total: rows.length, duplicates });
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = req.body || {};
if (!tempId || !module || !mapping) {
throw new HttpError(400, 'tempId, module and mapping are required');
}
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
// Require a specific investisseur for transactional modules
if (INVESTISSEUR_SCOPED.includes(module)) {
requireInvestisseur(req, res, () => {});
@@ -160,6 +351,10 @@ router.post('/apply', (req, res, next) => {
}
return defaults[target];
};
// Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) :
// 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement
// historique (la ligne en doublon est ignorée).
const rowDecision = duplicateDecisions[String(idx + 2)];
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
@@ -175,7 +370,7 @@ router.post('/apply', (req, res, next) => {
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO depots_retraits
@@ -204,7 +399,7 @@ router.post('/apply', (req, res, next) => {
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO investissements
@@ -248,7 +443,7 @@ router.post('/apply', (req, res, next) => {
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO remboursements
@@ -266,21 +461,34 @@ router.post('/apply', (req, res, next) => {
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) throw new Error('Le champ nom est vide');
const r = db.prepare(`
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
// changes = 0 means the row was ignored (nom already exists) — doublon, pas une erreur
if (r.changes === 0) { duplicates++; continue; }
const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
if (existingPlat) {
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
if (rowDecision !== 'accept') { duplicates++; continue; }
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
.run(v('url') || null, v('notes') || null, existingPlat.id);
} else {
db.prepare(`
INSERT INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee);
// Comportement historique : un taux existant est toujours écrasé par l'import (upsert).
// On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon
// dans la modale de vérification — dans ce cas seulement, le taux existant est préservé.
if (existingTaux && rowDecision === 'skip') { duplicates++; continue; }
db.prepare(`
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
VALUES (?, ?, ?, ?)
+55
View File
@@ -700,6 +700,61 @@ router.delete('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ──
// La plateforme elle-même n'est jamais supprimée par cette route : seules les données
// rattachées le sont, afin de permettre une réimportation propre derrière.
const PurgeSchema = z.object({
scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']),
confirmNom: z.string().min(1),
});
router.post('/:id/purge-donnees', (req, res, next) => {
try {
const { scope, confirmNom } = PurgeSchema.parse(req.body);
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
// Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection
// contre une confirmation validée par erreur sur la mauvaise plateforme).
if (confirmNom.trim() !== plat.nom) {
throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.');
}
const platId = plat.id;
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
const tx = db.transaction(() => {
if (scope === 'depots_retraits' || scope === 'all') {
counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes;
}
if (scope === 'remboursements') {
// Ne touche que les remboursements réels ; les investissements et leur échéancier restent.
counts.remboursements = db.prepare(`
DELETE FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).run(platId).changes;
}
if (scope === 'investissements' || scope === 'all') {
// Compte avant suppression : les remboursements et l'échéancier simulé sont
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
counts.remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.simul_remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM simul_remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
}
});
tx();
res.json({ ok: true, scope, plateforme: plat.nom, counts });
} catch (e) { next(e); }
});
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
router.post('/:id/reset', (req, res, next) => {
try {
@@ -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); }