Maj Phase 2 - Institutions financières
This commit is contained in:
1 parent
1dcb33aa74
commit
b18ffc63ee
18 files changed
+383
-16
No files matched your search
@@ -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}
|
||||
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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user