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

+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 = [
{ 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>
);
+52 -10
View File
@@ -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}
onChange={e => setter({ ...state, banque: e.target.value })}
placeholder="ex. BNP Paribas" />
<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 — 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>