Maj Phase 2 - Institutions financières
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
@@ -10,16 +10,20 @@ const Schema = z.object({
|
||||
type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'),
|
||||
investisseur_id: z.number().int().positive().nullable().optional(),
|
||||
banque: z.string().nullable().optional(),
|
||||
institution_id: z.number().int().positive().nullable().optional(),
|
||||
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
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.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
|
||||
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 = ?
|
||||
ORDER BY c.nom
|
||||
`).all(req.user.id);
|
||||
@@ -30,8 +34,8 @@ router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const r = db.prepare(
|
||||
'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)'
|
||||
).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
|
||||
'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.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
|
||||
res.status(201).json({ id: r.lastInsertRowid, ...body });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
@@ -40,8 +44,8 @@ router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const body = Schema.parse(req.body);
|
||||
const r = db.prepare(
|
||||
`UPDATE comptes SET nom=?, type=?, banque=?, 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);
|
||||
`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.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');
|
||||
res.json({ id: Number(req.params.id), ...body });
|
||||
} 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;
|
||||
@@ -51,6 +51,7 @@ 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 institutionsReferentielPublicRouter from './routes/institutionsReferentielPublic.js';
|
||||
import refCategoriesInstitutionsRouter from './routes/ref-categories-institutions.js';
|
||||
import refCategoriesRouter from './routes/ref-categories.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 :
|
||||
// catalogue admin transverse aux workspaces, cf. institutionsReferentiel.js.
|
||||
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', requireAuth, requireAdmin, refCategoriesRouter);
|
||||
app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter);
|
||||
|
||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -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 > 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 = [
|
||||
{ id: 'institutions', label: 'Institutions', icon: <IconBank /> },
|
||||
{ id: 'logos', label: 'Logos & icônes', icon: <IconImage /> },
|
||||
{ id: 'categories', label: 'Catégories', icon: <IconTag /> },
|
||||
{ id: 'comptes', label: 'Comptes à réconcilier', icon: <IconLink /> },
|
||||
];
|
||||
|
||||
/* ── Page principale ──────────────────────────────────────────── */
|
||||
@@ -728,6 +904,7 @@ export default function AdminInstitutions() {
|
||||
{section === 'institutions' && <InstitutionsSection />}
|
||||
{section === 'logos' && <LogosInstitutionsSection />}
|
||||
{section === 'categories' && <CategoriesInstitutionsSection />}
|
||||
{section === 'comptes' && <ComptesOrphelinsSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,14 +14,22 @@ const TYPE_COMPTE_LABELS = {
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function CompteFormFields({ state, setter, investisseurs }) {
|
||||
function CompteFormFields({ state, setter, investisseurs, institutions }) {
|
||||
const isAutre = !state.institution_id;
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
@@ -40,11 +48,30 @@ function CompteFormFields({ state, setter, investisseurs }) {
|
||||
</div>
|
||||
<div>
|
||||
<label>Banque</label>
|
||||
<input value={state.banque}
|
||||
<select
|
||||
value={state.institution_id ?? '__autre__'}
|
||||
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>
|
||||
{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" />
|
||||
</div>
|
||||
placeholder="ex. BNP Paribas — absente du référentiel ?" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Détenteur</label>
|
||||
<select value={state.investisseur_id ?? ''}
|
||||
@@ -70,14 +97,17 @@ function CompteFormFields({ state, setter, investisseurs }) {
|
||||
export default function ComptesSection() {
|
||||
const [comptes, setComptes] = useState([]);
|
||||
const [investisseurs, setInvestisseurs] = useState([]);
|
||||
const [institutions, setInstitutions] = useState([]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [cpts, invs] = await Promise.all([
|
||||
const [cpts, invs, insts] = await Promise.all([
|
||||
api.get('/comptes'),
|
||||
api.get('/investisseurs'),
|
||||
api.get('/institutions-referentiel-public'),
|
||||
]);
|
||||
setComptes(cpts);
|
||||
setInvestisseurs(invs);
|
||||
setInstitutions(insts);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
@@ -98,6 +128,7 @@ export default function ComptesSection() {
|
||||
nom: newCompte.nom,
|
||||
type: newCompte.type,
|
||||
banque: newCompte.banque || null,
|
||||
institution_id: newCompte.institution_id ?? null,
|
||||
investisseur_id: newCompte.investisseur_id ? Number(newCompte.investisseur_id) : null,
|
||||
exoneration_fiscale: newCompte.exoneration_fiscale ?? 'aucune',
|
||||
});
|
||||
@@ -112,6 +143,7 @@ export default function ComptesSection() {
|
||||
nom: editCompte.nom,
|
||||
type: editCompte.type,
|
||||
banque: editCompte.banque || null,
|
||||
institution_id: editCompte.institution_id ?? null,
|
||||
investisseur_id: editCompte.investisseur_id ? Number(editCompte.investisseur_id) : null,
|
||||
exoneration_fiscale: editCompte.exoneration_fiscale ?? 'aucune',
|
||||
});
|
||||
@@ -121,7 +153,7 @@ export default function ComptesSection() {
|
||||
|
||||
const openEdit = (c) => {
|
||||
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) => {
|
||||
@@ -178,7 +210,17 @@ export default function ComptesSection() {
|
||||
<td style={{ fontWeight: 500 }}>{c.nom}</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>}
|
||||
<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>
|
||||
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
|
||||
<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 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} />
|
||||
<CompteFormFields state={newCompte} setter={setNewCompte} investisseurs={investisseurs} institutions={institutions} />
|
||||
</form>
|
||||
</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 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} />
|
||||
<CompteFormFields state={editCompte} setter={setEditCompte} investisseurs={investisseurs} institutions={institutions} />
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
|
||||