Reférentiel des institutions financières
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
* (<DATA_DIR>/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(/(<svg\b[^>]*)\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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -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() {
|
||||
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
|
||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||
<Route path="admin/institutions" element={<AdminOnly><AdminInstitutions /></AdminOnly>} />
|
||||
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
|
||||
@@ -16,6 +16,7 @@ function IconShield() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
|
||||
function IconTax() { 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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
|
||||
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||
function IconBank() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 21 8 3 8"/></svg>; }
|
||||
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
|
||||
function IconMail() { 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="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||
function IconDownload() { 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
|
||||
@@ -41,6 +42,7 @@ const NAV = [
|
||||
items: [
|
||||
{ id: 'plateformes', label: 'Plateformes', icon: <IconDatabase />, href: '/admin/plateformes' },
|
||||
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
||||
{ id: 'institutions', label: 'Institutions financières', icon: <IconBank />, href: '/admin/institutions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="3" y1="22" x2="21" y2="22"/><line x1="6" y1="18" x2="6" y2="11"/><line x1="10" y1="18" x2="10" y2="11"/><line x1="14" y1="18" x2="14" y2="11"/><line x1="18" y1="18" x2="18" y2="11"/><polygon points="12 2 21 8 3 8"/></svg>;
|
||||
}
|
||||
function IconImage() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>;
|
||||
}
|
||||
function IconTag() {
|
||||
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="M20.59 13.41L13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>;
|
||||
}
|
||||
function IconEdit() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||
}
|
||||
function IconTrash() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>;
|
||||
}
|
||||
function IconChevronLeft() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>;
|
||||
}
|
||||
|
||||
/* ── 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 (
|
||||
<Modal
|
||||
open
|
||||
title={isNew ? 'Ajouter une institution' : `Modifier — ${row.nom}`}
|
||||
onClose={onClose}
|
||||
width={560}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
|
||||
<button type="submit" form="institution-form" className="primary" disabled={saving}>
|
||||
{saving ? '…' : (isNew ? 'Créer' : 'Enregistrer')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="institution-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div>
|
||||
<label>Nom *</label>
|
||||
<input value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : BNP Paribas, Boursorama…" />
|
||||
</div>
|
||||
<div>
|
||||
<label>URL</label>
|
||||
<input value={form.url} onChange={e => set('url', e.target.value)} placeholder="https://…" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Domiciliation</label>
|
||||
<CountrySelect value={form.domiciliation} onChange={code => set('domiciliation', code)} showCode />
|
||||
</div>
|
||||
<div>
|
||||
<label>Catégories</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{categories.length === 0 && <span className="text-muted" style={{ fontSize: 12 }}>Aucune catégorie — gérez-les depuis l'onglet "Catégories".</span>}
|
||||
{categories.map(c => {
|
||||
const checked = (form.categories_ids || []).includes(c.id);
|
||||
return (
|
||||
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => set('categories_ids', e.target.checked
|
||||
? [...(form.categories_ids || []), c.id]
|
||||
: (form.categories_ids || []).filter(id => id !== c.id))} />
|
||||
<span className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Description</label>
|
||||
<textarea value={form.description} onChange={e => set('description', e.target.value)}
|
||||
style={{ minHeight: 100, resize: 'vertical' }} placeholder="Informations générales sur l'institution…" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section principale : liste des institutions ─────────────────── */
|
||||
function InstitutionsSection() {
|
||||
const navigate = useNavigate();
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterCategorie, setFilterCategorie] = useState('');
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set();
|
||||
rows.forEach(r => (r.categories || []).forEach(c => set.add(c.nom)));
|
||||
return [...set].sort((a, b) => a.localeCompare(b, 'fr'));
|
||||
}, [rows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
let r = rows;
|
||||
if (filterCategorie) r = r.filter(x => (x.categories || []).some(c => c.nom === filterCategorie));
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase();
|
||||
r = r.filter(x => x.nom.toLowerCase().includes(q));
|
||||
}
|
||||
return r;
|
||||
}, [rows, search, filterCategorie]);
|
||||
|
||||
const { pagedItems, page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES } =
|
||||
usePagination(filteredRows, 'cl_pagesize_ref_institutions', [search, filterCategorie]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setLoading(true); setRows(await api.get('/institutions-referentiel')); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const handleDelete = (row) => {
|
||||
setDeleting({
|
||||
title: 'Supprimer cette institution ?',
|
||||
message: `Supprimer "${row.nom}" du référentiel ? Cette action est définitive.`,
|
||||
confirmLabel: 'Supprimer', danger: true,
|
||||
onConfirm: async () => {
|
||||
try { await api.del(`/institutions-referentiel/${row.id}`); load(); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setDeleting(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Référentiel des institutions financières</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Banques, courtiers, assureurs… — catalogue commun, transverse à tous les espaces de travail.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary" style={{ flexShrink: 0 }} onClick={() => setEditing('new')}>+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher par nom…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 14px', fontSize: 14, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
value={filterCategorie}
|
||||
onChange={e => setFilterCategorie(e.target.value)}
|
||||
style={{ flex: 1, padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
|
||||
>
|
||||
<option value="">Toutes les catégories</option>
|
||||
{allCategories.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{(search || filterCategorie) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setFilterCategorie(''); }}
|
||||
style={{ padding: '7px 12px', fontSize: 13, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'pointer', whiteSpace: 'nowrap' }}
|
||||
>Effacer les filtres</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td colSpan={5} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
{rows.length === 0
|
||||
? 'Aucune institution dans le référentiel. Créez-en une pour commencer.'
|
||||
: 'Aucun résultat pour ces critères.'}
|
||||
</td></tr>
|
||||
) : pagedItems.map((row, i) => (
|
||||
<tr key={row.id}
|
||||
style={{ borderBottom: i < pagedItems.length - 1 ? '1px solid var(--border)' : 'none', cursor: 'pointer' }}
|
||||
onClick={() => setEditing(row)}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<td style={{ padding: '8px 8px 8px 16px', width: 40 }}>
|
||||
{row.icone_filename || row.logo_filename
|
||||
? <img src={`${API_BASE}/api/logos/${row.icone_filename || row.logo_filename}`}
|
||||
alt="" style={{ width: 32, height: 32, objectFit: 'contain', borderRadius: 4, display: 'block' }} />
|
||||
: <div title="Aucune image" style={{ width: 32, height: 32, borderRadius: 4, background: 'var(--surface-2)', border: '1px dashed var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)', fontSize: 16 }}>×</div>
|
||||
}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<div style={{ fontWeight: 600 }}>{row.nom}</div>
|
||||
{row.description && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{row.description}</div>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<FlagIcon code={row.domiciliation} size={15} />{countryLabel(row.domiciliation)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{(row.categories || []).slice(0, 2).map(c => <span key={c.id} className="chip-cat" style={{ fontSize: 11 }}>{c.nom}</span>)}
|
||||
{(row.categories || []).length > 2 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>+{row.categories.length - 2}</span>}
|
||||
{(row.categories || []).length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={e => { e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); setOpenMenu({ row, x: rect.right, y: rect.bottom }); }}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination
|
||||
page={page} setPage={setPage}
|
||||
pageSize={pageSize} setPageSize={setPageSize}
|
||||
totalPages={totalPages} totalItems={totalItems}
|
||||
PAGE_SIZES={PAGE_SIZES}
|
||||
/>
|
||||
|
||||
{openMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y,
|
||||
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
|
||||
{[
|
||||
{ icon: <IconEdit />, label: 'Modifier', onClick: () => { setOpenMenu(null); setEditing(openMenu.row); } },
|
||||
{ icon: <IconTrash />, label: 'Supprimer', onClick: () => { const r = openMenu.row; setOpenMenu(null); handleDelete(r); }, color: 'var(--danger)' },
|
||||
].map(({ icon, label, onClick, color }) => (
|
||||
<button key={label}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 'var(--fs-sm)', color: color || 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={onClick}>
|
||||
<span style={{ opacity: 0.7, flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editing && <InstitutionModal row={editing === 'new' ? null : editing} onClose={() => setEditing(null)} onSaved={load} />}
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bibliothèque de logos/icônes ────────────────────────────────── */
|
||||
function LogosInstitutionsSection() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [uploading, setUploading] = useState(null);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
const [type, setType] = useState('logo'); // 'logo' | 'icone'
|
||||
const fileInputRef = useRef(null);
|
||||
const [targetId, setTargetId] = useState(null);
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
|
||||
|
||||
const load = async () => {
|
||||
try { setLoading(true); setRows(await api.get('/institutions-referentiel')); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !targetId) return;
|
||||
e.target.value = '';
|
||||
|
||||
const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp'];
|
||||
const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0];
|
||||
if (!allowed.includes(ext)) {
|
||||
setResult({ ok: false, msg: 'Format non supporté — SVG, PNG, JPG ou WebP uniquement' });
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(targetId);
|
||||
setResult(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await api.upload(`/institutions-referentiel/${targetId}/${type}`, formData);
|
||||
const label = type === 'logo' ? 'Logo' : 'Icône';
|
||||
setResult({ ok: true, msg: `${label} importé(e).` });
|
||||
load();
|
||||
} catch (ex) { setResult({ ok: false, msg: ex.message }); }
|
||||
finally { setUploading(null); setTargetId(null); }
|
||||
};
|
||||
|
||||
const triggerUpload = (id) => {
|
||||
setTargetId(id);
|
||||
setTimeout(() => fileInputRef.current?.click(), 50);
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
const label = type === 'logo' ? 'logo' : 'icône';
|
||||
setDeleting({
|
||||
title: `Supprimer le ${label} de "${row.nom}" ?`,
|
||||
message: `Le ${label} sera retiré du référentiel.`,
|
||||
confirmLabel: 'Supprimer', danger: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/institutions-referentiel/${row.id}/${type}`);
|
||||
setResult({ ok: true, msg: `${label.charAt(0).toUpperCase() + label.slice(1)} de "${row.nom}" supprimé(e).` });
|
||||
load();
|
||||
} catch (ex) { setResult({ ok: false, msg: ex.message }); }
|
||||
finally { setDeleting(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getImageUrl = (row) => {
|
||||
const filename = type === 'logo' ? row.logo_filename : row.icone_filename;
|
||||
return filename ? `${API_BASE}/api/logos/${filename}` : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Logos & icônes</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
{type === 'logo'
|
||||
? 'Logos associés au référentiel des institutions financières.'
|
||||
: "Icônes associées au référentiel des institutions financières (variante compacte du logo)."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dr-tabs" style={{ marginBottom: 20 }}>
|
||||
<button className={`dr-tab${type === 'logo' ? ' active' : ''}`} onClick={() => { setType('logo'); setResult(null); }}>Logos</button>
|
||||
<button className={`dr-tab${type === 'icone' ? ' active' : ''}`} onClick={() => { setType('icone'); setResult(null); }}>Icônes</button>
|
||||
</div>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept=".svg,.png,.jpg,.jpeg,.webp" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginBottom: 16 }} />
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
|
||||
{rows.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', gridColumn: '1 / -1' }}>Aucune institution dans le référentiel.</p>
|
||||
)}
|
||||
{rows.map(row => {
|
||||
const imageUrl = getImageUrl(row);
|
||||
const isUploading = uploading === row.id;
|
||||
const missingLabel = type === 'logo' ? 'Logo manquant' : 'Icône manquante';
|
||||
|
||||
return (
|
||||
<div key={row.id} className="card" style={{ padding: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
height: 110, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', position: 'relative',
|
||||
}}>
|
||||
{isUploading ? (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>Import…</span>
|
||||
) : imageUrl ? (
|
||||
<img src={imageUrl} alt={row.nom}
|
||||
style={{ maxWidth: 120, maxHeight: 80, objectFit: 'contain' }}
|
||||
onError={e => { e.currentTarget.style.display = 'none'; e.currentTarget.nextSibling.style.display = 'flex'; }} />
|
||||
) : null}
|
||||
<div style={{
|
||||
display: imageUrl && !isUploading ? 'none' : 'flex',
|
||||
flexDirection: 'column', alignItems: 'center', gap: 6, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{!isUploading && (
|
||||
<>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.45">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="8" y1="8" x2="16" y2="16"/><line x1="16" y1="8" x2="8" y2="16"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 11 }}>{missingLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '10px 12px', flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, lineHeight: 1.3 }}>{row.nom}</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 8, display: 'flex', gap: 6 }}>
|
||||
<button onClick={() => triggerUpload(row.id)} disabled={isUploading}
|
||||
style={{ flex: 1, fontSize: 12, padding: '5px 8px', cursor: 'pointer' }}>
|
||||
{imageUrl ? 'Remplacer' : 'Importer'}
|
||||
</button>
|
||||
{imageUrl && (
|
||||
<button onClick={() => handleDelete(row)} disabled={isUploading}
|
||||
style={{ fontSize: 12, padding: '5px 8px', cursor: 'pointer', color: 'var(--danger)', background: 'rgba(239,68,68,.08)', border: '1px solid rgba(239,68,68,.25)', borderRadius: 6 }}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleting && <ConfirmModal open title={deleting.title} message={deleting.message} confirmLabel={deleting.confirmLabel} danger={deleting.danger} onConfirm={deleting.onConfirm} onCancel={() => setDeleting(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Catégories (type d'établissement) — liste admin-gérable ─────────
|
||||
Dupliqué depuis RefListSection (AdminPlateformes.jsx) : même mécanique générique pilotée par
|
||||
apiPath, sans le renvoi vers les "suggestions" (spécifique aux catégories/secteurs
|
||||
d'investissement crowdlending/PE, sans équivalent ici). */
|
||||
function CategoriesInstitutionsSection() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [newNom, setNewNom] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editNom, setEditNom] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
|
||||
const apiPath = '/ref-categories-institutions';
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api.get(apiPath)
|
||||
.then(data => { setItems(data); setErr(null); })
|
||||
.catch(e => setErr(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const close = () => setOpenMenu(null);
|
||||
window.addEventListener('scroll', close, true);
|
||||
return () => window.removeEventListener('scroll', close, true);
|
||||
}, [openMenu]);
|
||||
|
||||
const handleAdd = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newNom.trim()) return;
|
||||
setAdding(true); setErr(null);
|
||||
try { await api.post(apiPath, { nom: newNom.trim() }); setNewNom(''); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
finally { setAdding(false); }
|
||||
};
|
||||
|
||||
const startEdit = (item) => { setEditId(item.id); setEditNom(item.nom); };
|
||||
const cancelEdit = () => { setEditId(null); setEditNom(''); };
|
||||
|
||||
const handleRename = async (e, id) => {
|
||||
e.preventDefault();
|
||||
if (!editNom.trim()) return;
|
||||
setSaving(true); setErr(null);
|
||||
try { await api.put(`${apiPath}/${id}`, { nom: editNom.trim() }); cancelEdit(); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = (item) => {
|
||||
setConfirm({
|
||||
title: 'Confirmer la suppression',
|
||||
message: `Supprimer "${item.nom}" ?`,
|
||||
confirmLabel: 'Supprimer',
|
||||
onConfirm: async () => {
|
||||
setConfirm(null);
|
||||
try { await api.del(`${apiPath}/${item.id}`); load(); }
|
||||
catch (ex) { setErr(ex.message); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openContextMenu = (e, item) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOpenMenu({ item, x: rect.right - 160, y: rect.bottom + 4 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18 }}>Catégories (type d'établissement)</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
Liste globale utilisée pour classer les institutions du référentiel (Banque, Courtier, Assureur…).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 16, padding: '16px 20px' }}>
|
||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
value={newNom}
|
||||
onChange={e => setNewNom(e.target.value)}
|
||||
placeholder="Nouvelle catégorie…"
|
||||
style={{ flex: 1, padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}
|
||||
/>
|
||||
<button type="submit" className="primary" disabled={adding || !newNom.trim()}>{adding ? '…' : 'Ajouter'}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p> : (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left' }}>Nom</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'center' }}>Utilisée</th>
|
||||
<th style={{ padding: '10px 16px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={3} style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune catégorie.</td></tr>
|
||||
) : items.map((item, i) => (
|
||||
<tr key={item.id} style={{ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
{editId === item.id ? (
|
||||
<form onSubmit={e => handleRename(e, item.id)} style={{ display: 'flex', gap: 6 }}>
|
||||
<input autoFocus value={editNom} onChange={e => setEditNom(e.target.value)}
|
||||
style={{ flex: 1, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }} />
|
||||
<button type="submit" className="primary" disabled={saving} style={{ fontSize: 12, padding: '5px 10px' }}>OK</button>
|
||||
<button type="button" onClick={cancelEdit} style={{ fontSize: 12, padding: '5px 10px' }}>Annuler</button>
|
||||
</form>
|
||||
) : <span style={{ fontWeight: 600 }}>{item.nom}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center' }}>
|
||||
<span style={{ display: 'inline-block', minWidth: 28, padding: '2px 8px', borderRadius: 12, fontSize: 12, fontWeight: 700,
|
||||
background: item.nb_utilises > 0 ? 'rgba(99,102,241,.12)' : 'var(--surface-2)',
|
||||
color: item.nb_utilises > 0 ? '#6366f1' : 'var(--text-muted)' }}>
|
||||
{item.nb_utilises}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 8px', borderRadius: 4, fontSize: 18, color: 'var(--text-muted)', lineHeight: 1 }}
|
||||
onClick={e => openContextMenu(e, item)}
|
||||
>⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openMenu && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
|
||||
<div style={{ position: 'fixed', left: openMenu.x, top: openMenu.y, zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 160 }}>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { const it = openMenu.item; setOpenMenu(null); startEdit(it); }}>
|
||||
<IconEdit /> Renommer
|
||||
</button>
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--danger)', textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { const it = openMenu.item; setOpenMenu(null); handleDelete(it); }}>
|
||||
<IconTrash /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal open={!!confirm} title={confirm?.title} message={confirm?.message} confirmLabel={confirm?.confirmLabel}
|
||||
onConfirm={confirm?.onConfirm} onCancel={() => setConfirm(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ id: 'institutions', label: 'Institutions', icon: <IconBank /> },
|
||||
{ id: 'logos', label: 'Logos & icônes', icon: <IconImage /> },
|
||||
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
|
||||
];
|
||||
|
||||
/* ── Page principale ──────────────────────────────────────────── */
|
||||
export default function AdminInstitutions() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'institutions';
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
<aside className="account-sidebar">
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
|
||||
color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '4px 0', marginBottom: 8 }}>
|
||||
<IconChevronLeft /> Administration
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="account-title">Institutions financières</h1>
|
||||
<div className="account-nav-group">
|
||||
{NAV.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => navigate(`/admin/institutions?section=${item.id}`, { replace: true })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'institutions' && <InstitutionsSection />}
|
||||
{section === 'logos' && <LogosInstitutionsSection />}
|
||||
{section === 'categories' && <CategoriesInstitutionsSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||