diff --git a/backend/src/db/index.js b/backend/src/db/index.js index cdfaae4..77b1ea8 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1870,6 +1870,13 @@ console.log('[DB] Migrations 2FA OK'); db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8"); console.log('[DB] Colonne smtp_config.min_password_length ajoutée'); } + + // ── Migration : email sur investisseurs ─────────────────────────────── + const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name); + if (!invColsEmail.includes('email')) { + db.exec('ALTER TABLE investisseurs ADD COLUMN email TEXT'); + console.log('[DB] Colonne investisseurs.email ajoutée'); + } } export default db; diff --git a/backend/src/routes/investisseurs.js b/backend/src/routes/investisseurs.js index 9c8b337..c8ff0b9 100644 --- a/backend/src/routes/investisseurs.js +++ b/backend/src/routes/investisseurs.js @@ -11,12 +11,13 @@ const Schema = z.object({ type: z.enum(['famille', 'entreprise']).default('famille'), type_fiscal: z.string().optional(), notes: z.string().optional(), + email: z.string().email().optional().or(z.literal('')), }); router.get('/', (req, res) => { const rows = db .prepare( - `SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, created_at + `SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, email, created_at FROM investisseurs WHERE user_id = ? ORDER BY is_principal DESC, type, nom` ) @@ -29,7 +30,7 @@ router.post('/', (req, res, next) => { const body = Schema.parse(req.body); const r = db .prepare( - 'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)' + 'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)' ) .run( req.user.id, @@ -38,6 +39,7 @@ router.post('/', (req, res, next) => { body.type, body.type_fiscal || null, body.notes || null, + body.email || null, ); const invId = r.lastInsertRowid; // Auto-créer un compte courant pour ce nouveau profil @@ -56,7 +58,7 @@ router.put('/:id', (req, res, next) => { const body = Schema.parse(req.body); const r = db .prepare( - `UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, updated_at=datetime('now') + `UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, email=?, updated_at=datetime('now') WHERE id=? AND user_id=?` ) .run( @@ -65,6 +67,7 @@ router.put('/:id', (req, res, next) => { body.type, body.type_fiscal || null, body.notes || null, + body.email || null, req.params.id, req.user.id, ); @@ -115,10 +118,25 @@ router.delete('/:id', (req, res, next) => { .get(req.user.id).n; if (count <= 1) throw new HttpError(400, 'Impossible de supprimer le dernier profil.'); - const r = db - .prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?') - .run(req.params.id, req.user.id); - if (r.changes === 0) throw new HttpError(404, 'Not found'); + // Récupérer le compte principal pour la réassignation + const principal = db + .prepare('SELECT id FROM investisseurs WHERE user_id=? AND is_principal=1') + .get(req.user.id); + if (!principal) throw new HttpError(500, 'Aucun compte principal trouvé pour la réassignation.'); + + const principalId = principal.id; + const targetId = Number(req.params.id); + + // Réassigner toutes les données liées au compte principal avant suppression + const deleteWithReassign = db.transaction(() => { + db.prepare('UPDATE investissements SET investisseur_id=? WHERE investisseur_id=?').run(principalId, targetId); + db.prepare('UPDATE depots_retraits SET investisseur_id=? WHERE investisseur_id=?').run(principalId, targetId); + db.prepare('UPDATE plateformes SET investisseur_id=? WHERE investisseur_id=? AND user_id=?').run(principalId, targetId, req.user.id); + db.prepare('UPDATE comptes SET investisseur_id=? WHERE investisseur_id=? AND user_id=?').run(principalId, targetId, req.user.id); + db.prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?').run(targetId, req.user.id); + }); + + deleteWithReassign(); res.status(204).end(); } catch (e) { next(e); } }); diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 00cde7d..32511ab 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -314,7 +314,7 @@ const zipUpload = multer({ router.get('/export', (req, res, next) => { try { let rows = db.prepare(` - SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.user_id = ? @@ -358,6 +358,7 @@ router.get('/export', (req, res, next) => { icone_filename: r.icone_filename, investisseur_nom: r.investisseur_nom, investisseur_prenom: r.investisseur_prenom, + investisseur_email: r.investisseur_email, categories: legacyMap[r.id] || [], categories_inv: (r.categories_inv || []).map(c => c.nom), secteurs_inv: (r.secteurs_inv || []).map(s => s.nom), @@ -368,7 +369,7 @@ router.get('/export', (req, res, next) => { // Export des investisseurs (détenteurs) liés aux plateformes const invIds = [...new Set(rows.map(r => r.investisseur_id).filter(Boolean))]; const invRows = invIds.length > 0 - ? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id) + ? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id) : []; if (invRows.length > 0) { entries.push({ name: 'investisseurs.json', data: JSON.stringify(invRows, null, 2) }); @@ -394,7 +395,7 @@ router.get('/export', (req, res, next) => { router.get('/:id/export', (req, res, next) => { try { const p = db.prepare(` - SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.id = ? AND p.user_id = ? `).get(req.params.id, req.user.id); @@ -421,7 +422,7 @@ router.get('/:id/export', (req, res, next) => { date_ouverture: withAll.date_ouverture, type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut, logo_filename: withAll.logo_filename, icone_filename: withAll.icone_filename, - investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, + investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, investisseur_email: withAll.investisseur_email, categories: catsLegacy, categories_inv: (withAll.categories_inv || []).map(c => c.nom), secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom), @@ -430,7 +431,7 @@ router.get('/:id/export', (req, res, next) => { // Export du détenteur lié à la plateforme if (withAll.investisseur_id) { - const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id); + const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id); if (inv) entries.push({ name: 'investisseurs.json', data: JSON.stringify([inv], null, 2) }); } @@ -459,26 +460,34 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { const platforms = JSON.parse(dataEntry.data.toString('utf8')); if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau'); - // Importer les investisseurs du ZIP (créer les manquants) + // Importer les investisseurs du ZIP (créer les manquants, dédupliquer par email puis nom) const invEntry = zipEntries.find(e => e.name === 'investisseurs.json'); if (invEntry) { const invList = JSON.parse(invEntry.data.toString('utf8')); for (const inv of (Array.isArray(invList) ? invList : [])) { if (!inv.nom) continue; - const exists = db.prepare('SELECT id FROM investisseurs WHERE nom = ? AND user_id = ?').get(inv.nom, req.user.id); - if (!exists) { - db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)') - .run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null); + // Déduplication : priorité à l'email, puis correspondance insensible à la casse sur le nom + const existsByEmail = inv.email + ? db.prepare('SELECT id FROM investisseurs WHERE LOWER(email) = LOWER(?) AND user_id = ?').get(inv.email, req.user.id) + : null; + const existsByNom = db.prepare('SELECT id FROM investisseurs WHERE LOWER(nom) = LOWER(?) AND user_id = ?').get(inv.nom, req.user.id); + if (!existsByEmail && !existsByNom) { + db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)') + .run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null, inv.email ?? null); } } } - // Résoudre investisseur par nom+prénom (après création éventuelle) + // Résoudre investisseur — priorité email, puis nom+prénom insensible à la casse const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id); - function resolveInvestisseur(nom, prenom) { - if (!nom) return userInvestisseurs[0]?.id ?? null; + function resolveInvestisseur(nom, prenom, email) { + if (!nom && !email) return userInvestisseurs[0]?.id ?? null; + if (email) { + const byEmail = userInvestisseurs.find(i => i.email?.toLowerCase() === email.toLowerCase()); + if (byEmail) return byEmail.id; + } const match = userInvestisseurs.find(i => - i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === prenom?.toLowerCase() + i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === (prenom || '').toLowerCase() ) || userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase()); return match?.id ?? userInvestisseurs[0]?.id ?? null; } @@ -527,7 +536,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (!p.nom) continue; const fiscalite = p.domiciliation === 'FR' ? 'flat_tax' : (p.fiscalite || 'flat_tax'); const taux = fiscalite === 'avec_fiscalite_locale' ? (p.taux_fiscalite_locale ?? null) : null; - const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom); + const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom, p.investisseur_email); const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv'); const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); const legacyCatIds = resolveLegacyCatIds(p.categories); diff --git a/frontend/public/login-bg.jpg b/frontend/public/login-bg-old.jpg similarity index 100% rename from frontend/public/login-bg.jpg rename to frontend/public/login-bg-old.jpg diff --git a/frontend/public/login-bg.png b/frontend/public/login-bg.png new file mode 100644 index 0000000..3867ce4 Binary files /dev/null and b/frontend/public/login-bg.png differ diff --git a/frontend/src/components/AuthBgCol.jsx b/frontend/src/components/AuthBgCol.jsx new file mode 100644 index 0000000..ea24235 --- /dev/null +++ b/frontend/src/components/AuthBgCol.jsx @@ -0,0 +1,21 @@ +/** + * AuthBgCol — colonne image gauche partagée entre toutes les pages d'authentification. + * + * L'image /login-bg.jpg contient les deux thèmes côte à côte (dark à gauche, light à droite). + * La classe CSS .login-bg-col utilise background-position pour focaliser sur la bonne moitié + * selon le thème actif ([data-theme="dark"] sur ). + */ +export default function AuthBgCol({ appInfo = {} }) { + return ( +