diff --git a/backend/src/db/index.js b/backend/src/db/index.js index e2d670f..116fc9a 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -3524,4 +3524,137 @@ db.exec(` `); db.exec('CREATE INDEX IF NOT EXISTS idx_pe_pertes_inv ON investissement_pe_pertes(investissement_pe_id)'); +// ── Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) ────────── +// Catalogue admin de banques/courtiers/assureurs, sur le modèle de plateformes_referentiel +// (cf. project_referentiel_plateformes.md) mais SANS les champs spécifiques aux plateformes +// d'investissement (fiscalité, produit fiscal, frais...) et SANS rattachement workspace : une +// institution financière est transverse à tous les espaces de travail (décision d'Olivier, +// AskUserQuestion 26/09/26). Phase 1 uniquement : catalogue admin (logos/icônes/catégories). +// Le rattachement à comptes.banque (Phase 2) fera l'objet d'un chantier séparé — cf. plan de +// déploiement (doc projet claude/plan_referentiel_institutions_financieres.md). +db.exec(` + CREATE TABLE IF NOT EXISTS institutions_referentiel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + url TEXT, + domiciliation TEXT NOT NULL DEFAULT 'FR', + description TEXT, + logo_filename TEXT, + icone_filename TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_instref_nom ON institutions_referentiel(nom)'); + +// Catégories (axe unique "type d'établissement"), gérées par l'admin — même patron que +// categories_inv/referentiel_categories_inv (cf. plus haut) mais table dédiée : une catégorie +// d'établissement financier (Banque, Assureur...) n'a pas de sens pour une plateforme +// crowdlending/PE, on évite donc de mélanger les deux taxonomies. +db.exec(` + CREATE TABLE IF NOT EXISTS categories_institutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_categories_institutions ( + institution_id INTEGER NOT NULL REFERENCES institutions_referentiel(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_institutions(id) ON DELETE CASCADE, + PRIMARY KEY (institution_id, categorie_id) + ) +`); + +// Seed initial des catégories (idempotent — n'insère que si la table est vide ; l'admin reste +// libre de renommer/supprimer ensuite depuis l'écran dédié). +{ + const catInstCount = db.prepare('SELECT COUNT(*) AS n FROM categories_institutions').get().n; + if (catInstCount === 0) { + const insCatInst = db.prepare('INSERT OR IGNORE INTO categories_institutions (nom) VALUES (?)'); + db.transaction(() => { + for (const nom of ['Banque', 'Néobanque', 'Courtier', 'Assureur', 'Société de gestion', 'Établissement de paiement', 'Autre']) { + insCatInst.run(nom); + } + })(); + } +} + + +// Seed initial des institutions financières (proposition validée par Olivier le 26/09/26, +// via ajustements sur la liste par défaut) — idempotent, n'insère que si la table est vide. +// Chaque institution peut porter plusieurs catégories (ex. Trade Republic : Néobanque + Courtier). +{ + const instRefCount = db.prepare('SELECT COUNT(*) AS n FROM institutions_referentiel').get().n; + if (instRefCount === 0) { + const SEED_INSTITUTIONS = [ + { nom: 'BNP Paribas', categories: ['Banque'] }, + { nom: 'Société Générale', categories: ['Banque'] }, + { nom: 'Crédit Agricole', categories: ['Banque'] }, + { nom: 'Banque Populaire', categories: ['Banque'] }, + { nom: "Caisse d'Épargne", categories: ['Banque'] }, + { nom: 'Crédit Mutuel', categories: ['Banque'] }, + { nom: 'CIC', categories: ['Banque'] }, + { nom: 'La Banque Postale', categories: ['Banque'] }, + { nom: 'LCL', categories: ['Banque'] }, + { nom: 'HSBC Continental Europe (CCF)', categories: ['Banque'] }, + { nom: 'Boursorama Banque', categories: ['Néobanque'] }, + { nom: 'Hello bank!', categories: ['Néobanque'] }, + { nom: 'Fortuneo', categories: ['Néobanque'] }, + { nom: 'BforBank', categories: ['Néobanque'] }, + { nom: 'Monabanq', categories: ['Néobanque'] }, + { nom: 'N26', categories: ['Néobanque'] }, + { nom: 'Revolut', categories: ['Néobanque'] }, + { nom: 'Qonto', categories: ['Néobanque'] }, + { nom: 'Trade Republic', categories: ['Néobanque', 'Courtier'] }, + { nom: 'Saxo Banque France', categories: ['Néobanque'] }, + { nom: 'Wise', categories: ['Néobanque'] }, + { nom: 'Bourse Direct', categories: ['Courtier'] }, + { nom: 'DEGIRO', categories: ['Courtier'] }, + { nom: 'Interactive Brokers', categories: ['Courtier'] }, + { nom: 'EasyBourse', categories: ['Courtier'] }, + { nom: 'AXA France', categories: ['Assureur'] }, + { nom: 'Allianz France', categories: ['Assureur'] }, + { nom: 'Generali France', categories: ['Assureur'] }, + { nom: 'CNP Assurances', categories: ['Assureur'] }, + { nom: 'MAIF', categories: ['Assureur'] }, + { nom: 'MACIF', categories: ['Assureur'] }, + { nom: 'Groupama', categories: ['Assureur'] }, + { nom: 'Crédit Agricole Assurances (Predica)', categories: ['Assureur'] }, + { nom: 'BNP Paribas Cardif', categories: ['Assureur'] }, + { nom: 'Société Générale Assurances (Sogecap)', categories: ['Assureur'] }, + { nom: 'Swiss Life France', categories: ['Assureur'] }, + { nom: 'Abeille Assurances (ex-Aviva France)', categories: ['Assureur'] }, + { nom: 'Suravenir (Crédit Mutuel Arkéa)', categories: ['Assureur'] }, + { nom: 'Spirica', categories: ['Assureur'] }, + { nom: 'Amundi', categories: ['Société de gestion'] }, + { nom: 'Natixis Investment Managers', categories: ['Société de gestion'] }, + { nom: 'BNP Paribas Asset Management', categories: ['Société de gestion'] }, + { nom: 'Lazard Frères Gestion', categories: ['Société de gestion'] }, + { nom: 'Carmignac', categories: ['Société de gestion'] }, + { nom: 'Tikehau Capital', categories: ['Société de gestion'] }, + { nom: "La Financière de l'Échiquier", categories: ['Société de gestion'] }, + { nom: 'Eiffel Investment Group', categories: ['Société de gestion'] }, + { nom: 'Lydia (Sumeria)', categories: ['Établissement de paiement'] }, + { nom: 'PayPal', categories: ['Établissement de paiement'] }, + { nom: 'Nickel', categories: ['Établissement de paiement'] }, + ]; + const insInstRef = db.prepare('INSERT OR IGNORE INTO institutions_referentiel (nom) VALUES (?)'); + const getInstRefId = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?'); + const getCatInstId = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?'); + const linkInstCat = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)'); + db.transaction(() => { + for (const { nom, categories } of SEED_INSTITUTIONS) { + insInstRef.run(nom); + const inst = getInstRefId.get(nom); + if (!inst) continue; + for (const catNom of categories) { + const cat = getCatInstId.get(catNom); + if (cat) linkInstCat.run(inst.id, cat.id); + } + } + })(); + } +} + export default db; diff --git a/backend/src/routes/institutionsReferentiel.js b/backend/src/routes/institutionsReferentiel.js new file mode 100644 index 0000000..03086a4 --- /dev/null +++ b/backend/src/routes/institutionsReferentiel.js @@ -0,0 +1,284 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import sharp from 'sharp'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +/** + * routes/institutionsReferentiel.js — Référentiel admin des institutions financières + * (banques, courtiers, assureurs...) — chantier "Référentiel Institutions financières" + * (26/09/26, demande Olivier). + * + * Phase 1 uniquement (cf. plan de déploiement, doc projet) : catalogue admin (logo, icône, + * catégories) — même patron que routes/referentiel.js (plateformes_referentiel), mais SANS les + * champs spécifiques aux plateformes d'investissement (fiscalité, produit fiscal, frais, + * méthode de remboursement...) et SANS rattachement workspace : une institution financière est + * transverse à tous les espaces de travail (décision d'Olivier, AskUserQuestion 26/09/26). + * Aucun consommateur applicatif pour l'instant (Phase 2, plus tard : comptes.banque) — donc pas + * de mécanique de "poussée" vers des enregistrements liés, contrairement à referentiel.js. + * + * Le dossier de stockage des images est partagé avec le référentiel des plateformes + * (/logos/) plutôt qu'un sous-dossier dédié — même volumétrie modeste, noms de + * fichiers déjà uniques (préfixe inst_ + id + timestamp) donc aucun risque de collision. + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : path.resolve(__dirname, '../../../data'); // fallback dev : 3 niveaux au-dessus de routes/ +const logosDir = path.join(dataDir, 'logos'); +fs.mkdirSync(logosDir, { recursive: true }); + +// ── Traitement image (dupliqué depuis referentiel.js/plateformes.js — même précédent +// déjà en place dans ce fichier : "réutilisé depuis plateformes.js" y est aussi une +// duplication volontaire plutôt qu'un module partagé) ────────────────────────────── +async function processLogoFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.svg') { + try { + let svg = fs.readFileSync(filePath, 'utf8'); + svg = svg.replace(/(]*)\sstyle="([^"]*)"/i, (_, tag, style) => { + const cleaned = style.split(';').filter(s => !/^\s*background(-color)?\s*:/i.test(s)).join(';').replace(/^;+|;+$/g, ''); + return cleaned ? `${tag} style="${cleaned}"` : tag; + }); + svg = svg.replace(/\s+enable-background="[^"]*"/gi, ''); + fs.writeFileSync(filePath, svg, 'utf8'); + } catch (e) { console.warn('[inst-logos] SVG cleanup failed:', e.message); } + return filePath; + } + if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png'); + try { + const { data, info } = await sharp(filePath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) data[i + 3] = 0; + } + await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png({ compressionLevel: 8 }).toFile(pngPath); + if (pngPath !== filePath) fs.unlinkSync(filePath); + return pngPath; + } catch (e) { console.warn('[inst-logos] sharp failed:', e.message); return filePath; } + } + return filePath; +} + +// ── Multer ────────────────────────────────────────────────────────────────── +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, logosDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.png'; + cb(null, `inst_${req.params.id}_${Date.now()}${ext}`); + }, +}); +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp']; + if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); + else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement')); + }, +}); + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +const Schema = z.object({ + nom: z.string().min(1).max(200), + url: z.string().url().optional().or(z.literal('')).nullable(), + domiciliation: z.string().min(1).max(10).default('FR'), + description: z.string().nullable().optional(), + categories_ids: z.array(z.number().int()).optional().default([]), +}); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Catégories (type d'établissement) attachées à chaque entrée */ +function attachCategories(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT rc.institution_id, c.id AS categorie_id, c.nom + FROM referentiel_categories_institutions rc + JOIN categories_institutions c ON c.id = rc.categorie_id + WHERE rc.institution_id IN (${ids.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.institution_id]) map[l.institution_id] = []; + map[l.institution_id].push({ id: l.categorie_id, nom: l.nom }); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + +function saveCategories(institutionId, categoriesIds) { + db.prepare('DELETE FROM referentiel_categories_institutions WHERE institution_id = ?').run(institutionId); + if (categoriesIds && categoriesIds.length > 0) { + const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?,?)'); + for (const id of categoriesIds) ins.run(institutionId, id); + } +} + +// ── GET /api/institutions-referentiel ─────────────────────────────────────── +router.get('/', (_req, res) => { + let rows = db.prepare('SELECT * FROM institutions_referentiel ORDER BY nom').all(); + rows = attachCategories(rows); + res.json(rows); +}); + +// ── GET /api/institutions-referentiel/:id ─────────────────────────────────── +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Institution introuvable'); + const [enriched] = attachCategories([row]); + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── POST /api/institutions-referentiel ────────────────────────────────────── +router.post('/', (req, res, next) => { + try { + const data = Schema.parse(req.body); + const existing = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ?').get(data.nom); + if (existing) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`); + + const r = db.prepare(` + INSERT INTO institutions_referentiel (nom, url, domiciliation, description, updated_at) + VALUES (?,?,?,?, datetime('now')) + `).run(data.nom, data.url || null, data.domiciliation, data.description || null); + + const institutionId = r.lastInsertRowid; + db.transaction(() => saveCategories(institutionId, data.categories_ids))(); + + const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institutionId); + const [enriched] = attachCategories([row]); + res.status(201).json(enriched); + } catch (e) { next(e); } +}); + +// ── PUT /api/institutions-referentiel/:id ─────────────────────────────────── +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Institution introuvable'); + + const data = Schema.parse(req.body); + if (data.nom !== row.nom) { + const dup = db.prepare('SELECT id FROM institutions_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id); + if (dup) throw new HttpError(409, `Une institution "${data.nom}" existe déjà`); + } + + db.prepare(` + UPDATE institutions_referentiel + SET nom=?, url=?, domiciliation=?, description=?, updated_at=datetime('now') + WHERE id=? + `).run(data.nom, data.url || null, data.domiciliation, data.description || null, row.id); + + db.transaction(() => saveCategories(row.id, data.categories_ids))(); + + const updated = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(row.id); + const [enriched] = attachCategories([updated]); + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── DELETE /api/institutions-referentiel/:id ──────────────────────────────── +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Institution introuvable'); + + for (const fname of [row.logo_filename, row.icone_filename]) { + if (!fname) continue; + const fpath = path.join(logosDir, fname); + if (fs.existsSync(fpath)) fs.unlink(fpath, () => {}); + } + + db.prepare('DELETE FROM institutions_referentiel WHERE id = ?').run(row.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +// ── POST /api/institutions-referentiel/:id/logo ───────────────────────────── +router.post('/:id/logo', upload.single('file'), async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Institution introuvable'); + if (!req.file) throw new HttpError(400, 'Fichier requis'); + + const finalPath = await processLogoFile(req.file.path); + const filename = path.basename(finalPath); + + if (ref.logo_filename) { + const old = path.join(logosDir, ref.logo_filename); + if (fs.existsSync(old)) fs.unlink(old, () => {}); + } + + db.prepare(`UPDATE institutions_referentiel SET logo_filename=?, updated_at=datetime('now') WHERE id=?`) + .run(filename, ref.id); + + res.json({ logo_filename: filename }); + } catch (e) { next(e); } +}); + +// ── DELETE /api/institutions-referentiel/:id/logo ─────────────────────────── +router.delete('/:id/logo', (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Institution introuvable'); + + if (ref.logo_filename) { + const filePath = path.join(logosDir, ref.logo_filename); + if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); + } + + db.prepare(`UPDATE institutions_referentiel SET logo_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +// ── POST /api/institutions-referentiel/:id/icone ──────────────────────────── +router.post('/:id/icone', upload.single('file'), async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Institution introuvable'); + if (!req.file) throw new HttpError(400, 'Fichier requis'); + + const finalPath = await processLogoFile(req.file.path); + const filename = path.basename(finalPath); + + if (ref.icone_filename) { + const oldFile = path.join(logosDir, ref.icone_filename); + if (fs.existsSync(oldFile)) fs.unlink(oldFile, () => {}); + } + + db.prepare(`UPDATE institutions_referentiel SET icone_filename=?, updated_at=datetime('now') WHERE id=?`) + .run(filename, ref.id); + + res.json({ icone_filename: filename }); + } catch (e) { next(e); } +}); + +// ── DELETE /api/institutions-referentiel/:id/icone ────────────────────────── +router.delete('/:id/icone', (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Institution introuvable'); + + if (ref.icone_filename) { + const filePath = path.join(logosDir, ref.icone_filename); + if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); + } + + db.prepare(`UPDATE institutions_referentiel SET icone_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/ref-categories-institutions.js b/backend/src/routes/ref-categories-institutions.js new file mode 100644 index 0000000..a9112d2 --- /dev/null +++ b/backend/src/routes/ref-categories-institutions.js @@ -0,0 +1,104 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +/** + * routes/ref-categories-institutions.js — liste globale des catégories du référentiel + * "Institutions financières" (type d'établissement : Banque, Courtier, Assureur...). + * Même patron que routes/ref-categories.js (categories_inv), table dédiée + * (categories_institutions) — cf. institutionsReferentiel.js. + */ + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +/* GET /api/ref-categories-institutions — liste complète */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT c.id, c.nom, + COUNT(rc.institution_id) AS nb_utilises + FROM categories_institutions c + LEFT JOIN referentiel_categories_institutions rc ON rc.categorie_id = c.id + GROUP BY c.id + ORDER BY c.nom + `).all(); + res.json(rows); +}); + +/* POST /api/ref-categories-institutions — créer */ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const exists = db.prepare('SELECT id FROM categories_institutions WHERE nom = ?').get(nom.trim()); + if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + const r = db.prepare('INSERT INTO categories_institutions (nom) VALUES (?)').run(nom.trim()); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 }); + } catch (e) { next(e); } +}); + +/* PUT /api/ref-categories-institutions/:id — renommer (propage automatiquement) */ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const row = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const dup = db.prepare('SELECT id FROM categories_institutions WHERE nom = ? AND id != ?').get(nom.trim(), row.id); + if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE categories_institutions SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n; + res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb }); + } catch (e) { next(e); } +}); + +/* DELETE /api/ref-categories-institutions/:id — supprimer si non utilisée */ +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM categories_institutions WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_institutions WHERE categorie_id = ?').get(row.id).n; + if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} institution(s) — retirez-la d'abord.`); + db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(row.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +/* POST /api/ref-categories-institutions/:id/merge — fusionner dans une autre catégorie */ +router.post('/:id/merge', (req, res, next) => { + try { + const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body); + const source = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(req.params.id); + if (!source) throw new HttpError(404, 'Catégorie source introuvable'); + const target = db.prepare('SELECT id, nom FROM categories_institutions WHERE id = ?').get(target_id); + if (!target) throw new HttpError(404, 'Catégorie cible introuvable'); + if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques'); + + const result = db.transaction(() => { + const refs = db.prepare( + 'SELECT institution_id FROM referentiel_categories_institutions WHERE categorie_id = ?' + ).all(source.id); + let added = 0, skipped = 0; + for (const { institution_id } of refs) { + const exists = db.prepare( + 'SELECT 1 FROM referentiel_categories_institutions WHERE institution_id = ? AND categorie_id = ?' + ).get(institution_id, target.id); + if (!exists) { + db.prepare( + 'INSERT INTO referentiel_categories_institutions (institution_id, categorie_id) VALUES (?, ?)' + ).run(institution_id, target.id); + added++; + } else { + skipped++; + } + } + db.prepare('DELETE FROM referentiel_categories_institutions WHERE categorie_id = ?').run(source.id); + db.prepare('DELETE FROM categories_institutions WHERE id = ?').run(source.id); + return { added, skipped, total: refs.length }; + })(); + + res.json({ ok: true, ...result, + message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index a4faf05..97db360 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -50,6 +50,8 @@ import adminAiRouter from './routes/adminAi.js'; import tauxCreditImpotRouter from './routes/tauxCreditImpot.js'; import referentielRouter from './routes/referentiel.js'; import referentielPublicRouter from './routes/referentielPublic.js'; +import institutionsReferentielRouter from './routes/institutionsReferentiel.js'; +import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js'; import refCategoriesRouter from './routes/ref-categories.js'; import refSecteursRouter from './routes/ref-secteurs.js'; import categoriesInvRouter from './routes/categories-inv.js'; @@ -168,6 +170,10 @@ app.use('/api/invitations', invitationsRouter); app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter); app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter); app.use('/api/referentiel-public', requireAuth, referentielPublicRouter); +// Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 : +// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js. +app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter); +app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter); app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/categories-inv', requireAuth, categoriesInvRouter); diff --git a/design/logos institution/BNP Paribas - Logo.webp b/design/logos institution/BNP Paribas - Logo.webp new file mode 100644 index 0000000..dcdd750 Binary files /dev/null and b/design/logos institution/BNP Paribas - Logo.webp differ diff --git a/design/logos institution/Banque Populaire - Logo.webp b/design/logos institution/Banque Populaire - Logo.webp new file mode 100644 index 0000000..1419bd9 Binary files /dev/null and b/design/logos institution/Banque Populaire - Logo.webp differ diff --git a/design/logos institution/BoursoBank - Logo.gif b/design/logos institution/BoursoBank - Logo.gif new file mode 100644 index 0000000..03d06b1 Binary files /dev/null and b/design/logos institution/BoursoBank - Logo.gif differ diff --git a/design/logos institution/CCF - Logo.webp b/design/logos institution/CCF - Logo.webp new file mode 100644 index 0000000..a4d4e76 Binary files /dev/null and b/design/logos institution/CCF - Logo.webp differ diff --git a/design/logos institution/CIC Logo.webp b/design/logos institution/CIC Logo.webp new file mode 100644 index 0000000..2abbd1d Binary files /dev/null and b/design/logos institution/CIC Logo.webp differ diff --git a/design/logos institution/Caisse d'epargne - Logo.webp b/design/logos institution/Caisse d'epargne - Logo.webp new file mode 100644 index 0000000..5003894 Binary files /dev/null and b/design/logos institution/Caisse d'epargne - Logo.webp differ diff --git a/design/logos institution/Credit agricole -Logo.gif b/design/logos institution/Credit agricole -Logo.gif new file mode 100644 index 0000000..642446c Binary files /dev/null and b/design/logos institution/Credit agricole -Logo.gif differ diff --git a/design/logos institution/Crédit Mutuel - Logo.gif b/design/logos institution/Crédit Mutuel - Logo.gif new file mode 100644 index 0000000..0282469 Binary files /dev/null and b/design/logos institution/Crédit Mutuel - Logo.gif differ diff --git a/design/logos institution/Fortuneo - Logo.webp b/design/logos institution/Fortuneo - Logo.webp new file mode 100644 index 0000000..dc5bb95 Binary files /dev/null and b/design/logos institution/Fortuneo - Logo.webp differ diff --git a/design/logos institution/LCL - Logo.webp b/design/logos institution/LCL - Logo.webp new file mode 100644 index 0000000..1322020 Binary files /dev/null and b/design/logos institution/LCL - Logo.webp differ diff --git a/design/logos institution/La banque postale - Logo.webp b/design/logos institution/La banque postale - Logo.webp new file mode 100644 index 0000000..b63ce3c Binary files /dev/null and b/design/logos institution/La banque postale - Logo.webp differ diff --git a/design/logos institution/Société Général Logo.webp b/design/logos institution/Société Général Logo.webp new file mode 100644 index 0000000..4a6d8a7 Binary files /dev/null and b/design/logos institution/Société Général Logo.webp differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index aea2bb5..726da77 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -29,6 +29,7 @@ import MonCompte from './pages/MonCompte.jsx'; import Admin from './pages/Admin.jsx'; import AdminPlateformes from './pages/AdminPlateformes.jsx'; import AdminFiscalite from './pages/AdminFiscalite.jsx'; +import AdminInstitutions from './pages/AdminInstitutions.jsx'; import AdminIA from './pages/AdminIA.jsx'; import Aide from './pages/Aide.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx'; @@ -240,6 +241,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 8b355d3..82edd04 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -16,6 +16,7 @@ function IconShield() { return ; } function IconTax() { return ; } function IconDatabase() { return ; } +function IconBank() { return ; } function IconSettings() { return ; } function IconMail() { return ; } function IconDownload() { return ; } @@ -41,6 +42,7 @@ const NAV = [ items: [ { id: 'plateformes', label: 'Plateformes', icon: , href: '/admin/plateformes' }, { id: 'fiscalite', label: 'Fiscalité', icon: , href: '/admin/fiscalite' }, + { id: 'institutions', label: 'Institutions financières', icon: , href: '/admin/institutions' }, ], }, { diff --git a/frontend/src/pages/AdminInstitutions.jsx b/frontend/src/pages/AdminInstitutions.jsx new file mode 100644 index 0000000..d8bbbfd --- /dev/null +++ b/frontend/src/pages/AdminInstitutions.jsx @@ -0,0 +1,734 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import ConfirmModal from '../components/ConfirmModal.jsx'; +import Modal from '../components/Modal.jsx'; +import ResultBanner from '../components/ResultBanner.jsx'; +import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; +import { usePagination } from '../hooks/usePagination.js'; +import Pagination from '../components/Pagination.jsx'; + +/** + * pages/AdminInstitutions.jsx — Référentiel admin "Institutions financières" + * (banques, courtiers, assureurs...) — chantier du 26/09/26 (demande Olivier). + * + * Phase 1 uniquement (cf. plan de déploiement, doc projet) : catalogue admin — logo, icône, + * catégories (type d'établissement). Même esprit que AdminPlateformes.jsx (ReferentielSection / + * LogosBibliothequeSection / RefListSection), mais volontairement plus simple : pas de champs + * spécifiques aux plateformes d'investissement (fiscalité, méthode de remboursement...), pas de + * rattachement workspace (une institution est transverse à tous les espaces de travail), pas de + * mécanique de "poussée" (aucun enregistrement lié pour l'instant — Phase 2, plus tard). + * RefListSection est dupliqué ici plutôt qu'importé depuis AdminPlateformes.jsx : même + * précédent déjà en place dans ce fichier (le traitement image logo/icône est aussi dupliqué + * côté backend plutôt que partagé) — évite de coupler ce référentiel à l'évolution de celui des + * plateformes. + */ + +const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—'; + +/* ── Icônes ───────────────────────────────────────────────────── */ +function IconBank() { + return ; +} +function IconImage() { + return ; +} +function IconTag() { + return ; +} +function IconEdit() { + return ; +} +function IconTrash() { + return ; +} +function IconChevronLeft() { + return ; +} + +/* ── Modale de création/édition d'une institution ──────────────── + Formulaire simple à un seul écran (contrairement au RefModal des plateformes, à 5 étapes) : + une institution financière n'a qu'une poignée de champs, un wizard serait disproportionné. */ +function InstitutionModal({ row, onClose, onSaved }) { + const isNew = !row?.id; + const [categories, setCategories] = useState([]); + const [err, setErr] = useState(null); + const [saving, setSaving] = useState(false); + + const [form, setForm] = useState({ + nom: row?.nom || '', + url: row?.url || '', + domiciliation: row?.domiciliation || 'FR', + description: row?.description || '', + categories_ids: (row?.categories || []).map(c => c.id), + }); + + useEffect(() => { + api.get('/ref-categories-institutions').then(setCategories).catch(() => {}); + }, []); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!form.nom.trim()) { setErr('Le nom est requis.'); return; } + setSaving(true); setErr(null); + try { + const payload = { + nom: form.nom.trim(), + url: form.url.trim() || null, + domiciliation: form.domiciliation, + description: form.description.trim() || null, + categories_ids: form.categories_ids || [], + }; + if (isNew) await api.post('/institutions-referentiel', payload); + else await api.put(`/institutions-referentiel/${row.id}`, payload); + onSaved(); onClose(); + } catch (ex) { setErr(ex.message); } + finally { setSaving(false); } + }; + + return ( + + + + + } + > +
+ {err &&
{err}
} +
+ + set('nom', e.target.value)} placeholder="Ex : BNP Paribas, Boursorama…" /> +
+
+ + set('url', e.target.value)} placeholder="https://…" /> +
+
+ + set('domiciliation', code)} showCode /> +
+
+ +
+ {categories.length === 0 && Aucune catégorie — gérez-les depuis l'onglet "Catégories".} + {categories.map(c => { + const checked = (form.categories_ids || []).includes(c.id); + return ( + + ); + })} +
+
+
+ +