Amélioration des frais
This commit is contained in:
1 parent
981ddf5830
commit
3e8508d48b
12 files changed
+1083
-206
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ══ */}
|
||||
{(() => {
|
||||
|
||||
Reference in new issue
Block a user