Maj Phase 2 - Institutions financières

This commit is contained in:
ocroguennec committed 2026-09-26 18:28:15 +02:00
1 parent 1dcb33aa74
commit b18ffc63ee
18 files changed
+383 -16

No files matched your search

+12
View File
@@ -3657,4 +3657,16 @@ db.exec(`
} }
} }
// ── Migration : institution_id sur comptes (Phase 2, demande Olivier 26/09/26) ────────────
// Rattache un compte au référentiel institutions_referentiel — nullable, non-destructif :
// le champ banque (texte libre) reste en base tel quel pour les comptes non encore réconciliés
// (cf. plan de déploiement, doc projet claude/plan_referentiel_institutions_financieres.md).
{
const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name);
if (!cols.includes('institution_id')) {
db.exec('ALTER TABLE comptes ADD COLUMN institution_id INTEGER REFERENCES institutions_referentiel(id) ON DELETE SET NULL');
}
}
export default db; export default db;
+86
View File
@@ -832,4 +832,90 @@ router.post('/exports/:filename/restore', async (req, res, next) => {
} }
}); });
/* ── Comptes orphelins (banque texte libre non reliée au référentiel institutions) ───────
Phase 2 du chantier "Référentiel Institutions financières" (cf. plan de déploiement, doc
projet claude/plan_referentiel_institutions_financieres.md). Contrairement aux plateformes
orphelines (une ligne par plateforme), ici on regroupe par valeur `banque` distincte car
plusieurs comptes (potentiellement de plusieurs utilisateurs) partagent souvent le même
texte libre — Lier/Importer agit donc sur tous les comptes portant cette valeur d'un coup. */
/** Liste les valeurs `banque` distinctes non reliées à institution_id, avec suggestion */
router.get('/comptes-orphelins', (_req, res) => {
const rows = db.prepare(`
SELECT c.banque, u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name, COUNT(*) AS nb
FROM comptes c
JOIN users u ON u.id = c.user_id
WHERE c.institution_id IS NULL AND c.banque IS NOT NULL AND TRIM(c.banque) != ''
GROUP BY c.banque, u.id
ORDER BY c.banque, u.email
`).all();
const byBanque = {};
for (const r of rows) {
if (!byBanque[r.banque]) byBanque[r.banque] = { banque: r.banque, total: 0, users: [] };
byBanque[r.banque].total += r.nb;
byBanque[r.banque].users.push({ id: r.user_id, email: r.user_email, display_name: r.user_display_name, nb: r.nb });
}
const refs = db.prepare('SELECT id, nom FROM institutions_referentiel ORDER BY nom').all();
const enriched = Object.values(byBanque).map(g => {
let bestRef = null, bestScore = 0;
for (const ref of refs) {
const score = similarity(g.banque, ref.nom);
if (score > bestScore) { bestScore = score; bestRef = ref; }
}
return {
...g,
suggestion: bestScore >= SIMILARITY_THRESHOLD
? { institution_id: bestRef.id, institution_nom: bestRef.nom, score: Math.round(bestScore * 100) }
: null,
};
}).sort((a, b) => a.banque.localeCompare(b.banque, 'fr'));
res.json(enriched);
});
/** Lie tous les comptes portant cette valeur `banque` à une institution existante du référentiel */
router.post('/comptes-orphelins/lier', (req, res, next) => {
try {
const { banque, institution_id } = z.object({
banque: z.string().min(1),
institution_id: z.number().int().positive(),
}).parse(req.body);
const ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(institution_id);
if (!ref) throw new HttpError(404, 'Institution introuvable');
const r = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(institution_id, banque);
res.json({ linked: true, nb_comptes: r.changes, institution: ref });
} catch (e) { next(e); }
});
/** Crée une nouvelle institution dans le référentiel à partir de la valeur `banque`, et lie
tous les comptes correspondants (ou lie simplement si une institution de ce nom existe déjà) */
router.post('/comptes-orphelins/importer', (req, res, next) => {
try {
const { banque } = z.object({ banque: z.string().min(1) }).parse(req.body);
let ref = db.prepare('SELECT * FROM institutions_referentiel WHERE nom = ?').get(banque);
let created = false;
if (!ref) {
const r = db.prepare('INSERT INTO institutions_referentiel (nom) VALUES (?)').run(banque);
ref = db.prepare('SELECT * FROM institutions_referentiel WHERE id = ?').get(r.lastInsertRowid);
created = true;
}
const upd = db.prepare(
'UPDATE comptes SET institution_id = ? WHERE banque = ? AND institution_id IS NULL'
).run(ref.id, banque);
res.status(created ? 201 : 200).json({ linked: true, created, nb_comptes: upd.changes, institution: ref });
} catch (e) { next(e); }
});
export default router; export default router;
+10 -6
View File
@@ -10,16 +10,20 @@ const Schema = z.object({
type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'), type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'),
investisseur_id: z.number().int().positive().nullable().optional(), investisseur_id: z.number().int().positive().nullable().optional(),
banque: z.string().nullable().optional(), banque: z.string().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'), exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
}); });
router.get('/', (req, res) => { router.get('/', (req, res) => {
const rows = db.prepare(` const rows = db.prepare(`
SELECT c.id, c.nom, c.type, c.banque, c.exoneration_fiscale, c.investisseur_id, c.created_at, SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM comptes c FROM comptes c
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
WHERE c.user_id = ? WHERE c.user_id = ?
ORDER BY c.nom ORDER BY c.nom
`).all(req.user.id); `).all(req.user.id);
@@ -30,8 +34,8 @@ router.post('/', (req, res, next) => {
try { try {
const body = Schema.parse(req.body); const body = Schema.parse(req.body);
const r = db.prepare( const r = db.prepare(
'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)' 'INSERT INTO comptes (user_id, nom, type, banque, institution_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune'); ).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
res.status(201).json({ id: r.lastInsertRowid, ...body }); res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@@ -40,8 +44,8 @@ router.put('/:id', (req, res, next) => {
try { try {
const body = Schema.parse(req.body); const body = Schema.parse(req.body);
const r = db.prepare( const r = db.prepare(
`UPDATE comptes SET nom=?, type=?, banque=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?` `UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id); ).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found'); if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body }); res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -0,0 +1,44 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/institutionsReferentielPublic.js — lecture seule du référentiel "Institutions
* financières" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
* referentielPublic.js. Contrairement aux plateformes (où chaque utilisateur crée sa propre
* entrée, réconciliée a posteriori par l'admin via OrphelinesSection), le référentiel
* institutions est un catalogue transverse : on expose ici une LISTE complète, utilisée par le
* sélecteur "Banque" de Paramètres > Mes comptes (Phase 2, cf. plan de déploiement).
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
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, 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.id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/* GET /api/institutions-referentiel-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT id, nom, domiciliation, logo_filename, icone_filename
FROM institutions_referentiel
ORDER BY nom
`).all();
res.json(attachCategories(rows));
});
export default router;
+2
View File
@@ -51,6 +51,7 @@ import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js'; import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js'; import referentielPublicRouter from './routes/referentielPublic.js';
import institutionsReferentielRouter from './routes/institutionsReferentiel.js'; import institutionsReferentielRouter from './routes/institutionsReferentiel.js';
import institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js'; import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
import refCategoriesRouter from './routes/ref-categories.js'; import refCategoriesRouter from './routes/ref-categories.js';
import refSecteursRouter from './routes/ref-secteurs.js'; import refSecteursRouter from './routes/ref-secteurs.js';
@@ -173,6 +174,7 @@ app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
// Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 : // Chantier "Référentiel Institutions financières" (26/09/26, demande Olivier) — Phase 1 :
// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js. // catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js.
app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter); app.use('/api/institutions-referentiel', requireAuth, requireAdmin, institutionsReferentielRouter);
app.use('/api/institutions-referentiel-public', requireAuth, institutionsReferentielPublicRouter);
app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter); app.use('/api/ref-categories-institutions', requireAuth, requireAdmin, refCategoriesInstitutionsRouter);
app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter);
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+177
View File
@@ -685,10 +685,186 @@ function CategoriesInstitutionsSection() {
); );
} }
function IconLink() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>;
}
/* ── Comptes à réconcilier ─────────────────────────────────────────────
Phase 2 (26/09/26, demande Olivier) : les comptes créés avant l'ajout du référentiel (ou
dont l'utilisateur a choisi "Autre (texte libre)") portent une valeur `banque` en texte
libre non reliée à `institution_id`. Cet écran liste ces valeurs, groupées par texte
(potentiellement partagé par plusieurs comptes/utilisateurs), avec une suggestion de
rapprochement par similarité de nom — sur le modèle d'OrphelinesSection (AdminPlateformes.jsx)
mais opérant sur un groupe de comptes à la fois plutôt que sur une seule plateforme. */
function ComptesOrphelinsSection() {
const [rows, setRows] = useState([]);
const [institutions, setInstitutions] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [acting, setActing] = useState(null);
const [confirm, setConfirm] = useState(null);
const [result, setResult] = useState(null);
const [selected, setSelected] = useState({});
const load = useCallback(async () => {
try {
setLoading(true);
const [orphs, insts] = await Promise.all([
api.get('/admin/comptes-orphelins'),
api.get('/institutions-referentiel'),
]);
setRows(orphs);
setInstitutions(insts);
const initSel = {};
for (const r of orphs) if (r.suggestion) initSel[r.banque] = String(r.suggestion.institution_id);
setSelected(initSel);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleLier = (row) => {
const instId = Number(selected[row.banque]);
if (!instId) return;
const inst = institutions.find(i => i.id === instId);
setConfirm({
title: 'Lier au référentiel',
message: `Lier ${row.total > 1 ? `les ${row.total} comptes portant` : 'le compte portant'} "${row.banque}" à "${inst?.nom}" ? Le texte libre existant n'est pas modifié — seul le rattachement au référentiel est ajouté.`,
confirmLabel: 'Lier',
onConfirm: async () => {
setConfirm(null); setActing(row.banque);
try {
const r = await api.post('/admin/comptes-orphelins/lier', { banque: row.banque, institution_id: instId });
setResult({ ok: true, msg: `${r.nb_comptes} compte(s) lié(s) à "${r.institution.nom}".` });
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
finally { setActing(null); }
},
});
};
const handleImport = (row) => {
setConfirm({
title: 'Importer dans le référentiel',
message: `Créer une nouvelle entrée "${row.banque}" dans le référentiel des institutions et lier automatiquement ${row.total > 1 ? `les ${row.total} comptes correspondants` : 'le compte correspondant'} ?`,
confirmLabel: 'Importer',
onConfirm: async () => {
setConfirm(null); setActing(row.banque);
try {
const r = await api.post('/admin/comptes-orphelins/importer', { banque: row.banque });
setResult({
ok: true,
msg: r.created
? `"${r.institution.nom}" créée dans le référentiel — ${r.nb_comptes} compte(s) lié(s).`
: `"${r.institution.nom}" existait déjà — ${r.nb_comptes} compte(s) lié(s).`,
});
load();
} catch (e) { setResult({ ok: false, msg: e.message }); }
finally { setActing(null); }
},
});
};
const totalSuggestions = rows.filter(r => r.suggestion).length;
return (
<div>
<div style={{ marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Comptes à réconcilier</h2>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 13 }}>
Valeurs "Banque" saisies en texte libre (Paramètres &gt; Mes comptes, avant ce référentiel, ou via "Autre") et pas encore reliées à une institution.
{totalSuggestions > 0 && <span style={{ color: '#f59e0b', marginLeft: 6 }}>⚡ {totalSuggestions} correspondance(s) suggérée(s) avec le référentiel existant.</span>}
</p>
</div>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginBottom: 12 }} />
{loading ? <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>
: rows.length === 0
? <div className="card" style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Aucun compte à réconcilier — tout est à jour.</div>
: (
<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' }}>Valeur "Banque"</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Comptes concernés</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Lier à…</th>
<th style={{ padding: '10px 16px', textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.banque} style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none',
background: row.suggestion ? 'rgba(245,158,11,.04)' : 'transparent' }}>
<td style={{ padding: '10px 16px', fontWeight: 500 }}>
{row.banque}
{row.suggestion && (
<div style={{ marginTop: 4 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '2px 8px', borderRadius: 10,
background: 'rgba(245,158,11,.15)', color: '#b45309', fontWeight: 600 }}>
≈ {row.suggestion.institution_nom} ({row.suggestion.score}%)
</span>
</div>
)}
</td>
<td style={{ padding: '10px 8px', color: 'var(--text-muted)', fontSize: 12 }}>
{row.total} compte{row.total > 1 ? 's' : ''} — {row.users.map(u => u.display_name || u.email).join(', ')}
</td>
<td style={{ padding: '10px 8px' }}>
<select
value={selected[row.banque] ?? ''}
onChange={e => setSelected(s => ({ ...s, [row.banque]: e.target.value }))}
style={{ fontSize: 12, padding: '5px 8px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }}
>
<option value="">— Choisir une institution —</option>
{institutions.map(inst => (
<option key={inst.id} value={inst.id}>{inst.nom}</option>
))}
</select>
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, padding: '4px 10px',
background: selected[row.banque] ? 'rgba(245,158,11,.12)' : 'var(--surface-2)',
border: '1px solid ' + (selected[row.banque] ? 'rgba(245,158,11,.4)' : 'var(--border)'),
borderRadius: 6, color: selected[row.banque] ? '#b45309' : 'var(--text-muted)',
cursor: selected[row.banque] ? 'pointer' : 'not-allowed', whiteSpace: 'nowrap' }}
disabled={acting === row.banque || !selected[row.banque]}
onClick={() => handleLier(row)}>
<IconLink /> {acting === row.banque ? '…' : 'Lier'}
</button>
<button
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, padding: '4px 10px',
border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer',
background: 'var(--surface)', color: 'var(--text)', whiteSpace: 'nowrap' }}
disabled={acting === row.banque}
onClick={() => handleImport(row)}>
{acting === row.banque ? '…' : 'Importer'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
{confirm && <ConfirmModal open title={confirm.title} message={confirm.message} confirmLabel={confirm.confirmLabel} onConfirm={confirm.onConfirm} onCancel={() => setConfirm(null)} />}
</div>
);
}
const NAV = [ const NAV = [
{ id: 'institutions', label: 'Institutions', icon: <IconBank /> }, { id: 'institutions', label: 'Institutions', icon: <IconBank /> },
{ id: 'logos', label: 'Logos & icônes', icon: <IconImage /> }, { id: 'logos', label: 'Logos & icônes', icon: <IconImage /> },
{ id: 'categories', label: 'Catégories', icon: <IconTag /> }, { id: 'categories', label: 'Catégories', icon: <IconTag /> },
{ id: 'comptes', label: 'Comptes à réconcilier', icon: <IconLink /> },
]; ];
/* ── Page principale ──────────────────────────────────────────── */ /* ── Page principale ──────────────────────────────────────────── */
@@ -728,6 +904,7 @@ export default function AdminInstitutions() {
{section === 'institutions' && <InstitutionsSection />} {section === 'institutions' && <InstitutionsSection />}
{section === 'logos' && <LogosInstitutionsSection />} {section === 'logos' && <LogosInstitutionsSection />}
{section === 'categories' && <CategoriesInstitutionsSection />} {section === 'categories' && <CategoriesInstitutionsSection />}
{section === 'comptes' && <ComptesOrphelinsSection />}
</div> </div>
</div> </div>
); );
+52 -10
View File
@@ -14,14 +14,22 @@ const TYPE_COMPTE_LABELS = {
pea_pme: 'PEA-PME', pea_pme: 'PEA-PME',
}; };
const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, banque: '', exoneration_fiscale: 'aucune' }; const EMPTY_COMPTE = { nom: '', type: 'compte_courant', investisseur_id: null, institution_id: null, banque: '', exoneration_fiscale: 'aucune' };
// Référentiel institutions financières (Phase 2, 26/09/26) — logos servis au même endroit
// que ceux des plateformes (/api/logos/:filename), cf. institutionsReferentiel.js.
const API_BASE = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '');
function institutionLogoUrl(institutionIconeOrLogo) {
return institutionIconeOrLogo ? `${API_BASE}/api/logos/${institutionIconeOrLogo}` : null;
}
function compteInvestisseur(c) { function compteInvestisseur(c) {
if (!c.investisseur_id) return null; if (!c.investisseur_id) return null;
return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal }; return { id: c.investisseur_id, nom: c.investisseur_nom, prenom: c.investisseur_prenom, type: c.investisseur_type, type_fiscal: c.investisseur_type_fiscal };
} }
function CompteFormFields({ state, setter, investisseurs }) { function CompteFormFields({ state, setter, investisseurs, institutions }) {
const isAutre = !state.institution_id;
return ( return (
<> <>
<div> <div>
@@ -40,11 +48,30 @@ function CompteFormFields({ state, setter, investisseurs }) {
</div> </div>
<div> <div>
<label>Banque</label> <label>Banque</label>
<input value={state.banque} <select
onChange={e => setter({ ...state, banque: e.target.value })} value={state.institution_id ?? '__autre__'}
placeholder="ex. BNP Paribas" /> onChange={e => {
const v = e.target.value;
if (v === '__autre__') { setter({ ...state, institution_id: null }); return; }
const inst = institutions.find(i => i.id === Number(v));
setter({ ...state, institution_id: Number(v), banque: inst ? inst.nom : state.banque });
}}
>
<option value="__autre__">Autre (texte libre)</option>
{institutions.map(inst => (
<option key={inst.id} value={inst.id}>{inst.nom}</option>
))}
</select>
</div> </div>
</div> </div>
{isAutre && (
<div>
<label>Nom de la banque (texte libre)</label>
<input value={state.banque || ''}
onChange={e => setter({ ...state, banque: e.target.value })}
placeholder="ex. BNP Paribas — absente du référentiel ?" />
</div>
)}
<div> <div>
<label>Détenteur</label> <label>Détenteur</label>
<select value={state.investisseur_id ?? ''} <select value={state.investisseur_id ?? ''}
@@ -70,14 +97,17 @@ function CompteFormFields({ state, setter, investisseurs }) {
export default function ComptesSection() { export default function ComptesSection() {
const [comptes, setComptes] = useState([]); const [comptes, setComptes] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]); const [investisseurs, setInvestisseurs] = useState([]);
const [institutions, setInstitutions] = useState([]);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const [cpts, invs] = await Promise.all([ const [cpts, invs, insts] = await Promise.all([
api.get('/comptes'), api.get('/comptes'),
api.get('/investisseurs'), api.get('/investisseurs'),
api.get('/institutions-referentiel-public'),
]); ]);
setComptes(cpts); setComptes(cpts);
setInvestisseurs(invs); setInvestisseurs(invs);
setInstitutions(insts);
}, []); }, []);
useEffect(() => { reload(); }, [reload]); useEffect(() => { reload(); }, [reload]);
@@ -98,6 +128,7 @@ export default function ComptesSection() {
nom: newCompte.nom, nom: newCompte.nom,
type: newCompte.type, type: newCompte.type,
banque: newCompte.banque || null, banque: newCompte.banque || null,
institution_id: newCompte.institution_id ?? null,
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null, investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune', exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
}); });
@@ -112,6 +143,7 @@ export default function ComptesSection() {
nom: editCompte.nom, nom: editCompte.nom,
type: editCompte.type, type: editCompte.type,
banque: editCompte.banque || null, banque: editCompte.banque || null,
institution_id: editCompte.institution_id ?? null,
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null, investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune', exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
}); });
@@ -121,7 +153,7 @@ export default function ComptesSection() {
const openEdit = (c) => { const openEdit = (c) => {
setErr(null); setErr(null);
setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' }); setEditCompte({ id: c.id, nom: c.nom, type: c.type, banque: c.banque || '', institution_id: c.institution_id ?? null, investisseur_id: c.investisseur_id ?? null, exoneration_fiscale: c.exoneration_fiscale ?? 'aucune' });
}; };
const del = (c) => { const del = (c) => {
@@ -178,7 +210,17 @@ export default function ComptesSection() {
<td style={{ fontWeight: 500 }}>{c.nom}</td> <td style={{ fontWeight: 500 }}>{c.nom}</td>
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td> <td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
{multiDetenteur && <td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>} {multiDetenteur && <td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>}
<td>{c.banque || <span className="text-muted">—</span>}</td> <td>
{c.institution_id ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{institutionLogoUrl(c.institution_icone_filename || c.institution_logo_filename) && (
<img src={institutionLogoUrl(c.institution_icone_filename || c.institution_logo_filename)} alt=""
style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} />
)}
{c.institution_nom}
</span>
) : (c.banque || <span className="text-muted">—</span>)}
</td>
<td> <td>
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? ( {c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
<span <span
@@ -217,7 +259,7 @@ export default function ComptesSection() {
> >
<form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <form id="form-new-compte" onSubmit={e => runAddCompte(addCompte, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} /> <CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} institutions={institutions} />
</form> </form>
</Modal> </Modal>
@@ -241,7 +283,7 @@ export default function ComptesSection() {
> >
<form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <form id="form-edit-compte" onSubmit={e => runSaveEdit(saveEdit, e)} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} /> <CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} institutions={institutions} />
</form> </form>
</Modal> </Modal>