Amélioration des frais

This commit is contained in:
ocroguennec committed 2026-09-04 08:38:32 +02:00
1 parent 981ddf5830
commit 3e8508d48b
12 files changed
+1083 -206

No files matched your search

+13 -3
View File
@@ -315,17 +315,27 @@ export default function Aide() {
remboursement), ou autre. Le bloc <em>Frais d'opération</em> de la fiche d'un investissement les recense tous.
</p>
<p>
Dès qu'au moins un frais a été déclaré sur l'une de vos plateformes, une page <strong style={{ color: 'var(--text)' }}>Frais</strong> dédiée
apparaît dans le menu de gauche (entre Remboursements et Fiscalité) — après un rechargement de la page qui
suit cette première déclaration. Elle consolide, tous investissements confondus, le montant total, la
répartition par catégorie et le détail de chaque opération, avec ajout/modification/suppression directement
depuis cette vue (plus besoin de passer par la fiche de chaque investissement). Cette page remplace
l'ancien onglet « Frais » qui se trouvait dans Remboursements.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Où saisir un frais ?</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>Frais de souscription</strong> — depuis l'étape « Frais » du
formulaire de création ou modification de l'investissement. Un seul frais de cette catégorie par
investissement.</li>
<li><strong style={{ color: 'var(--text)' }}>Frais de gestion ou autre</strong> — depuis le bouton
<strong style={{ color: 'var(--text)' }}> + Ajouter</strong> du bloc <em>Frais d'opération</em> sur la fiche.</li>
<strong style={{ color: 'var(--text)' }}> + Ajouter</strong> du bloc <em>Frais d'opération</em> sur la fiche
de l'investissement, ou depuis la page <em>Frais</em> dédiée (avec sélecteur d'investissement).</li>
<li><strong style={{ color: 'var(--text)' }}>Frais sur distribution</strong> — depuis le champ
<strong style={{ color: 'var(--text)' }}> Frais</strong> du formulaire d'un remboursement. La ligne est créée et
synchronisée automatiquement dans le bloc <em>Frais d'opération</em> ; elle ne peut être modifiée ou
supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li>
synchronisée automatiquement dans le bloc <em>Frais d'opération</em> et dans la page <em>Frais</em> ; elle ne
peut être modifiée ou supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li>
</ul>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Mode de règlement et impact</h4>
+552
View File
@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import FraisCumulChart from '../components/FraisCumulChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
/**
* Page dédiée "Frais" (Étape 6, 02/09/26) — reporting + CRUD complet des frais_operations,
* sur le modèle de l'onglet "Plateformes" de Remboursements.jsx : zone de graphiques (courbe du
* cumul des frais), zone de KPI, onglets (Étape 7, 02/09/26 — restructuration demandée par
* Olivier pour respecter cet "esprit" : Plateformes / Typologie / Opérations, cf.
* project_frais_operation.md). Route protégée (FraisOnly, cf. App.jsx) : n'apparaît qu'après
* déclaration d'au moins un frais (hasFrais, cf. AuthContext.jsx) — le contenu de l'onglet
* "Frais" d'origine a été retiré de Remboursements.jsx au profit de cette page (décision
* Olivier, 02/09/26).
*/
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
function emptyForm() {
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
}
export default function Frais() {
const { activeId, activeView } = useInvestisseur();
const [allFraisOps, setAllFraisOps] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [plateformes, setPlateformes] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false);
const [loading, setLoading] = useState(false);
/* Onglets (Étape 7, 02/09/26) */
const [activeTab, setActiveTab] = useState('plateformes');
/* Modale ajout/édition */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [err, setErr] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, inv] = await Promise.all([
api.get('/frais-operations'),
api.get('/investissements', scopeParams),
]);
setAllFraisOps(f);
setInvestissements(inv);
} finally {
setLoading(false);
}
};
useEffect(() => {
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
}, []);
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Charge les comptes du détenteur de l'investissement sélectionné dans le formulaire —
// même pattern que le bloc "Frais d'opération" de InvestissementDetail.jsx, sauf que
// l'investissement (donc le détenteur) peut changer à tout moment via le sélecteur ici.
useEffect(() => {
const inv = investissements.find(i => String(i.id) === String(form.investissement_id));
if (!modalOpen || !inv?.investisseur_id) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalOpen, form.investissement_id]);
/* Plus de filtres Plateforme/Année sur cette page (retirés le 02/09/26, Étape 10 — demande
Olivier) : la page affiche toujours l'ensemble des frais. */
const fraisFiltered = allFraisOps;
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
const fraisSouscriptionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
const fraisDistributionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
/* ── Répartition par catégorie (onglet "Typologie") ── */
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Répartition par plateforme (onglet "Plateformes") ── */
const byPlateforme = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.plateforme_id;
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Frais par investissement (onglet "Investissements") — uniquement les investissements
concernés par au moins un frais, cf. Étape 11, 02/09/26. ── */
const byInvestissement = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.investissement_id;
if (!map[key]) map[key] = {
key,
plateforme: f.plateforme_nom || '—',
detenteur: f.investisseur_nom || '—',
projet: f.nom_projet || '—',
souscription: 0,
distribution: 0,
total: 0,
};
const cat = f.categorie || 'autre';
if (cat === 'souscription') map[key].souscription += f.montant || 0;
if (cat === 'distribution') map[key].distribution += f.montant || 0;
map[key].total += f.montant || 0;
}
return Object.values(map).sort((a, b) => {
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return a.projet.localeCompare(b.projet, 'fr');
});
}, [fraisFiltered]);
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
const {
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
totalPages, totalItems, PAGE_SIZES,
} = usePagination(fraisFiltered, 'cl_pagesize_frais', []);
/* ── Investissements pour le sélecteur de la modale (triés plateforme puis projet) ── */
const investissementOptions = useMemo(() =>
investissements.slice().sort((a, b) => {
const pa = (a.plateforme_nom || '').toLowerCase();
const pb = (b.plateforme_nom || '').toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr');
}),
[investissements]
);
/* ── CRUD ── */
const openAdd = () => {
setEditingId(null);
setForm(emptyForm());
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const openEdit = (f) => {
setEditingId(f.id);
setForm({
investissement_id: String(f.investissement_id),
montant: f.montant,
mode_reglement: f.mode_reglement,
compte_id: f.compte_id || '',
date_operation: f.date_operation,
notes: f.notes || '',
categorie: f.categorie || 'autre',
});
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
// à 'compte_courant' ; changer d'investissement réinitialise compte_id (détenteur différent).
const setField = (key, value) => {
setForm(f => {
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
return { ...f, [key]: value };
});
};
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
const payload = {
investissement_id: Number(form.investissement_id),
montant: Number(form.montant),
mode_reglement: form.mode_reglement,
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
date_operation: form.date_operation,
origine: 'manuel',
categorie: form.categorie || 'autre',
notes: form.notes || undefined,
};
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
else await api.post('/frais-operations', payload);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
setErr(null);
try {
await api.del(`/frais-operations/${editingId}`);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
/* ── Rendu ── */
if (platLoaded && !loading && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Frais</h2></div>
<EmptyState />
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="frais" />Frais</h2>
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
</div>
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
<div className="charts-row">
<FraisCumulChart rows={fraisFiltered} />
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div className="kpi">
<div className="label">Frais payés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de souscription</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de distribution</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Nombre d'opérations</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais moyen / opération</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
</div>
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
onClick={() => setActiveTab('typologie')}>Typologie</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
onClick={() => setActiveTab('operations')}>Opérations</button>
</div>
{/* ══ ONGLET PLATEFORMES — répartition des frais par plateforme (graphique déjà affiché
en haut de page, à côté de la courbe de cumul — cf. Étape 8, 02/09/26) ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlateforme.length === 0 && (
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byPlateforme.map(p => {
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
return (
<tr key={p.key}>
<td>{p.label}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
<td className="num">{fmtEUR(p.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlateforme.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET TYPOLOGIE — répartition des frais par catégorie (graphique retiré, cf.
Étape 9, 02/09/26 — demande Olivier) ══ */}
{activeTab === 'typologie' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.key}>
<td>{c.label}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par investissement, uniquement ceux
concernés (Étape 11, 02/09/26 — demande Olivier) ══ */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th>Projet</th>
<th className="num">Souscription</th>
<th className="num">Distribution</th>
<th className="num">Total Frais</th>
</tr>
</thead>
<tbody>
{byInvestissement.length === 0 && (
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byInvestissement.map(inv => (
<tr key={inv.key}>
<td>{inv.plateforme}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{inv.detenteur}</td>}
<td>{inv.projet}</td>
<td className="num">{fmtEUR(inv.souscription)}</td>
<td className="num">{fmtEUR(inv.distribution)}</td>
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(inv.total)}</td>
</tr>
))}
</tbody>
{byInvestissement.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.souscription, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.distribution, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.total, 0))}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
{activeTab === 'operations' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Projet</th><th className="num">Montant</th>
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{pagedFrais.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{pagedFrais.map(f => (
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
<td>{fmtDate(f.date_operation)}</td>
<td>{f.plateforme_nom}</td>
<td>{f.nom_projet}</td>
<td className="num">{fmtEUR(f.montant)}</td>
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
title="Modifier"
onClick={() => openEdit(f)}>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalPages={totalPages} totalItems={totalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
<FraisFormModal
open={modalOpen}
onClose={closeModal}
editingId={editingId}
form={form}
onFieldChange={setField}
err={err}
onSubmit={submit}
onDelete={onDelete}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
/>
</>
);
}
+19 -64
View File
@@ -6,6 +6,7 @@ import { useUi } from '../context/UiContext.jsx';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import PageIcon from '../components/PageIcon.jsx';
import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
@@ -356,6 +357,15 @@ export default function InvestissementDetail() {
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); };
// Callback générique passé à FraisFormModal (onFieldChange) — reproduit le comportement de
// l'ancienne modale inline : changer mode_reglement réinitialise compte_id sauf en passant
// à 'compte_courant' (où l'on conserve la valeur déjà saisie, cf. FraisFormModal.jsx).
const setFraisField = (key, value) => {
setFraisForm(f => key === 'mode_reglement'
? { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' }
: { ...f, [key]: value });
};
const submitFrais = async (e) => {
e?.preventDefault?.();
setFraisErr(null);
@@ -3262,72 +3272,17 @@ export default function InvestissementDetail() {
)}
{/* ── Modale "Frais d'opération" (ajout / édition) ─────────────── */}
<Modal
<FraisFormModal
open={fraisModalOpen}
title={editingFraisId ? 'Modifier le frais' : 'Ajouter un frais'}
onClose={closeFraisModal}
width={480}
footer={
<>
<button className="ghost" onClick={closeFraisModal}>Annuler</button>
<button className="primary" onClick={submitFrais}>{editingFraisId ? 'Enregistrer' : 'Ajouter'}</button>
</>
}
>
<form onSubmit={submitFrais}>
{fraisErr && <div className="error">{fraisErr}</div>}
<div className="form-grid">
<div>
<label>Montant TTC (€) *</label>
<input type="number" step="0.01" min="0" required value={fraisForm.montant}
onChange={e => setFraisForm(f => ({ ...f, montant: e.target.value }))} />
</div>
<div>
<label>Date de l'opération *</label>
<input type="date" required value={fraisForm.date_operation}
onChange={e => setFraisForm(f => ({ ...f, date_operation: e.target.value }))} />
</div>
<div style={{ gridColumn: 'span 2' }}>
<label>Catégorie</label>
<select value={fraisForm.categorie} onChange={e => setFraisForm(f => ({ ...f, categorie: e.target.value }))}>
<option value="gestion">Gestion</option>
<option value="distribution">Distribution</option>
<option value="autre">Autre</option>
</select>
</div>
<div style={{ gridColumn: 'span 2' }}>
<label>Prélèvement</label>
<select value={fraisForm.mode_reglement}
onChange={e => setFraisForm(f => ({ ...f, mode_reglement: e.target.value, compte_id: e.target.value === 'compte_courant' ? f.compte_id : '' }))}>
<option value="source">À la source (sans mouvement complémentaire)</option>
<option value="portefeuille">Versement complémentaire — Porte-monnaie de la plateforme</option>
<option value="compte_courant">Versement complémentaire — Compte courant du détenteur</option>
</select>
</div>
{fraisForm.mode_reglement === 'compte_courant' && (
<div style={{ gridColumn: 'span 2' }}>
<label>Compte de règlement *</label>
{comptesFraisInvestisseur.length > 0 ? (
<select required value={fraisForm.compte_id} onChange={e => setFraisForm(f => ({ ...f, compte_id: e.target.value }))}>
<option value="">— Choisir un compte —</option>
{comptesFraisInvestisseur.map(c => (
<option key={c.id} value={c.id}>{c.nom}{c.banque ? ` — ${c.banque}` : ''}</option>
))}
</select>
) : (
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '4px 0' }}>
Aucun compte défini pour ce détenteur. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
</p>
)}
</div>
)}
<div style={{ gridColumn: 'span 2' }}>
<label>Notes</label>
<input value={fraisForm.notes} onChange={e => setFraisForm(f => ({ ...f, notes: e.target.value }))} placeholder="Précisions optionnelles…" />
</div>
</div>
</form>
</Modal>
editingId={editingFraisId}
form={fraisForm}
onFieldChange={setFraisField}
err={fraisErr}
onSubmit={submitFrais}
comptesInvestisseur={comptesFraisInvestisseur}
locked
/>
<ConfirmModal
open={!!rowDeleteConfirm}
-127
View File
@@ -10,8 +10,6 @@ import { useUi } from '../context/UiContext.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx';
@@ -420,39 +418,6 @@ export default function Remboursements() {
[byPlatform]
);
/* ── Étape 5 : reporting "Frais payés" — filtré comme le reste de la page par
filterPlatId (plateforme) et rembPlatYear (année), sur date_operation cette fois. ── */
const fraisFiltered = useMemo(() =>
allFraisOps.filter(f => {
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
if (rembPlatYear && (f.date_operation || '').slice(0, 4) !== rembPlatYear) return false;
return true;
}),
[allFraisOps, filterPlatId, rembPlatYear]
);
const fraisPrevFiltered = useMemo(() =>
allFraisOps.filter(f => {
if (filterPlatId && String(f.plateforme_id) !== filterPlatId) return false;
return (f.date_operation || '').slice(0, 4) === rembPrevYear;
}),
[allFraisOps, filterPlatId, rembPrevYear]
);
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisPrevTotal = useMemo(() => fraisPrevFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisPrevFiltered]);
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { categorie: key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Tableau remboursements : filtrage client-side ── */
const tableRows = useMemo(() => {
// Remboursements filtrés
@@ -941,8 +906,6 @@ export default function Remboursements() {
onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
onClick={() => setActiveTab('projections')}>Projections Échéances</button>
<button className={`dr-tab${activeTab === 'frais' ? ' active' : ''}`}
onClick={() => setActiveTab('frais')}>Frais</button>
</div>}
{/* ══ ONGLET PLATEFORMES ══ */}
@@ -1561,96 +1524,6 @@ export default function Remboursements() {
</div>
)}
{/* ══ ONGLET FRAIS (Étape 5 — reporting consolidé) ══ */}
{activeTab === 'frais' && (
<div style={{ padding: '0 24px' }}>
{/* ── KPI ── */}
<div className="dr-kpi-row" style={{ marginBottom: 16 }}>
<div className="kpi">
<div className="label">
Frais payés{rembPlatYear && ` en ${rembPlatYear}`}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
{rembPlatYear && <TrendBadge current={fraisTotal} prev={fraisPrevTotal} invert />}
</div>
{rembPlatYear && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
{fmtEUR(fraisPrevTotal)} en {rembPrevYear}
</div>
)}
</div>
</div>
{/* ── Graphique ── */}
<div className="charts-row" style={{ marginBottom: 16 }}>
<FraisDistributionChart rows={fraisFiltered} />
</div>
{/* ── Tableau par catégorie ── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>
Répartition par catégorie
{rembPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {rembPlatYear}</span>}
</h3>
{filterPlatId && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontStyle: 'italic' }}>
Filtré sur cette plateforme
</span>
)}
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.categorie}>
<td>{FRAIS_CATEGORIE_LABELS[c.categorie] || c.categorie}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ MODAL ══ */}
{(() => {