Amélioration des frais
This commit is contained in:
1 parent
981ddf5830
commit
3e8508d48b
12 files changed
+1083
-206
No files matched your search
@@ -13,6 +13,7 @@ import DepotsRetraits from './pages/DepotsRetraits.jsx';
|
||||
import Investissements from './pages/Investissements.jsx';
|
||||
import InvestissementDetail from './pages/InvestissementDetail.jsx';
|
||||
import Remboursements from './pages/Remboursements.jsx';
|
||||
import Frais from './pages/Frais.jsx';
|
||||
import SimulRemboursements from './pages/SimulRemboursements.jsx';
|
||||
import TaxReport from './pages/TaxReport.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
@@ -40,6 +41,16 @@ function AdminOnly({ children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Étape 3 (page dédiée Frais, 02/09/26) — visible seulement après déclaration d'au moins un
|
||||
// frais (cf. hasFrais dans AuthContext, calculé depuis GET /auth/me). Mécanisme simple assumé :
|
||||
// nécessite un rechargement de session après la toute première déclaration pour apparaître.
|
||||
function FraisOnly({ children }) {
|
||||
const { hasFrais, loading } = useAuth();
|
||||
if (loading) return <div style={{ padding: 32 }}>Chargement…</div>;
|
||||
if (!hasFrais) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
|
||||
@@ -84,6 +95,7 @@ export default function App() {
|
||||
<Route path="investissements" element={<Investissements />} />
|
||||
<Route path="investissements/:id" element={<InvestissementDetail />} />
|
||||
<Route path="remboursements" element={<Remboursements />} />
|
||||
<Route path="frais" element={<FraisOnly><Frais /></FraisOnly>} />
|
||||
<Route path="simul" element={<SimulRemboursements />} />
|
||||
<Route path="taxreport" element={<TaxReport />} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* FraisCumulChart — courbe du cumul des frais dans le temps.
|
||||
*
|
||||
* Adapté de InteretsChart.jsx (Remboursements.jsx, onglet "Plateformes") pour la page dédiée
|
||||
* Frais.jsx (Étape 7, 02/09/26 — "zone de graphiques" demandée par Olivier). Même mécanique de
|
||||
* cumul/plages temporelles/hover, mais sur `date_operation`/`montant` (frais_operations) plutôt
|
||||
* que `date_remb`/`interets_bruts|nets` (remboursements), et sans bascule brut/net (un seul
|
||||
* montant par frais).
|
||||
*/
|
||||
|
||||
const COLOR = '#e8547a';
|
||||
const BG = '#070c15';
|
||||
const GRID = 'rgba(255,255,255,0.055)';
|
||||
const LABEL = '#4a5568';
|
||||
|
||||
const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT'];
|
||||
const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.'];
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────── */
|
||||
function fmtK(v) {
|
||||
if (v === 0) return '0 €';
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 }) + ' k €';
|
||||
return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtAxisDate(dateStr, range) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = MOIS_COURT[d.getMonth()];
|
||||
const yr = d.getFullYear();
|
||||
if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||
if (range === '1M' || range === '3M') return `${day} ${mon}`;
|
||||
return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`;
|
||||
}
|
||||
|
||||
function fmtValueDisplay(v) {
|
||||
return v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||
}
|
||||
|
||||
function fmtTodayFull() {
|
||||
return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function FraisCumulChart({ rows }) {
|
||||
const [range, setRange] = useState('TOUT');
|
||||
const [hover, setHover] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* ── 1. Cumul complet ── */
|
||||
const allPoints = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byDate = {};
|
||||
for (const r of rows) {
|
||||
const d = (r.date_operation || '').slice(0, 10);
|
||||
if (!d) continue;
|
||||
const val = r.montant || 0;
|
||||
byDate[d] = (byDate[d] || 0) + val;
|
||||
}
|
||||
let cum = 0;
|
||||
return Object.keys(byDate).sort().map(date => {
|
||||
cum += byDate[date];
|
||||
return { date, value: cum };
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0;
|
||||
|
||||
/* ── 2. Filtrage par plage ── */
|
||||
const points = useMemo(() => {
|
||||
if (!allPoints.length) return [];
|
||||
if (range === 'TOUT') {
|
||||
const pts = [...allPoints];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}
|
||||
const now = new Date();
|
||||
let fromDate = new Date(now);
|
||||
switch (range) {
|
||||
case '1J': fromDate.setDate(now.getDate() - 1); break;
|
||||
case '7J': fromDate.setDate(now.getDate() - 7); break;
|
||||
case '1M': fromDate.setMonth(now.getMonth() - 1); break;
|
||||
case '3M': fromDate.setMonth(now.getMonth() - 3); break;
|
||||
case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break;
|
||||
case '1A': fromDate.setFullYear(now.getFullYear() - 1); break;
|
||||
}
|
||||
const fromStr = fromDate.toISOString().slice(0, 10);
|
||||
const before = allPoints.filter(p => p.date < fromStr);
|
||||
const startV = before.length ? before[before.length - 1].value : 0;
|
||||
const after = allPoints.filter(p => p.date >= fromStr);
|
||||
const pts = [{ date: fromStr, value: startV }, ...after];
|
||||
if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value });
|
||||
return pts;
|
||||
}, [allPoints, range, todayStr]);
|
||||
|
||||
/* ── 3. SVG dimensions ── */
|
||||
const W = 900, H = 260;
|
||||
const PAD = { top: 16, right: 16, bottom: 32, left: 70 };
|
||||
const plotW = W - PAD.left - PAD.right;
|
||||
const plotH = H - PAD.top - PAD.bottom;
|
||||
|
||||
/* ── 4. Échelles ── */
|
||||
const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => {
|
||||
if (points.length < 2) return {};
|
||||
const vals = points.map(p => p.value);
|
||||
const dataMin = Math.min(...vals);
|
||||
const dataMax = Math.max(...vals);
|
||||
const lo = Math.min(0, dataMin);
|
||||
const hi = Math.max(0, dataMax);
|
||||
const pad = (hi - lo) * 0.1 || 1;
|
||||
const scaleLo = lo - (lo < 0 ? pad : 0);
|
||||
const scaleHi = hi + pad;
|
||||
const valRange = scaleHi - scaleLo || 1;
|
||||
|
||||
const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime());
|
||||
const minDt = ts[0];
|
||||
const maxDt = ts[ts.length - 1];
|
||||
const dtRange = maxDt - minDt || 1;
|
||||
|
||||
const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW;
|
||||
const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH;
|
||||
|
||||
const step = (scaleHi - scaleLo) / 4;
|
||||
const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) }));
|
||||
|
||||
const nX = Math.min(8, points.length);
|
||||
const xTicks = Array.from({ length: nX }, (_, i) => {
|
||||
const idx = Math.round((i / (nX - 1)) * (points.length - 1));
|
||||
return points[idx];
|
||||
});
|
||||
|
||||
return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) };
|
||||
}, [points, plotW, plotH]);
|
||||
|
||||
/* ── 5. Chemins SVG ── */
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (!xScale || points.length < 2) return { linePath: '', areaPath: '' };
|
||||
let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`;
|
||||
}
|
||||
const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1);
|
||||
const area = `${line} V ${zeroY} H ${PAD.left} Z`;
|
||||
return { linePath: line, areaPath: area };
|
||||
}, [points, xScale, yScale, yZero]);
|
||||
|
||||
/* ── 6. Hover ── */
|
||||
const handleMouseMove = (e) => {
|
||||
if (!svgRef.current || !xScale || points.length < 2) return;
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate);
|
||||
let nearest = points[0], minDiff = Infinity;
|
||||
for (const p of points) {
|
||||
const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t);
|
||||
if (diff < minDiff) { minDiff = diff; nearest = p; }
|
||||
}
|
||||
setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date });
|
||||
};
|
||||
|
||||
const tooltipStyle = useMemo(() => {
|
||||
if (!hover) return null;
|
||||
const xPct = (hover.x / W) * 100;
|
||||
const yPct = (hover.y / H) * 100;
|
||||
const anchorRight = xPct > 65;
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: `calc(${yPct}% - 64px)`,
|
||||
left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`,
|
||||
right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto',
|
||||
transform: 'none',
|
||||
pointerEvents: 'none',
|
||||
};
|
||||
}, [hover]);
|
||||
|
||||
if (!allPoints.length) return null;
|
||||
|
||||
const displayDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: fmtTodayFull();
|
||||
const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue);
|
||||
const tooltipDate = hover
|
||||
? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="solde-chart-wrap" ref={wrapRef} style={{ position: 'relative' }}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="solde-chart-header">
|
||||
<div className="solde-chart-info">
|
||||
<div className="solde-chart-date">{displayDate}</div>
|
||||
<div className="solde-chart-value">{displayValue}</div>
|
||||
</div>
|
||||
<div className="solde-chart-controls" style={{ gap: 8 }}>
|
||||
{/* Plages temporelles */}
|
||||
<div className="solde-chart-ranges">
|
||||
{RANGES.map(r => (
|
||||
<button key={r}
|
||||
className={`solde-range-btn${range === r ? ' active' : ''}`}
|
||||
onClick={() => { setRange(r); setHover(null); }}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SVG ── */}
|
||||
{xScale && (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block', cursor: 'crosshair' }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fcc-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={COLOR} stopOpacity="0.22" />
|
||||
<stop offset="70%" stopColor={COLOR} stopOpacity="0.06" />
|
||||
<stop offset="100%" stopColor={COLOR} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="fcc-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grille horizontale */}
|
||||
{yTicks.map(({ v, y }) => (
|
||||
<g key={v}>
|
||||
<line x1={PAD.left} y1={y} x2={W - PAD.right} y2={y}
|
||||
stroke={GRID} strokeWidth="1" strokeDasharray="3 5" />
|
||||
<text x={PAD.left - 10} y={y + 4} textAnchor="end"
|
||||
fill={LABEL} fontSize="11" fontFamily="system-ui,sans-serif">
|
||||
{fmtK(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ligne zéro */}
|
||||
{yZero && yZero > PAD.top && yZero < PAD.top + plotH && (
|
||||
<line x1={PAD.left} y1={yZero} x2={W - PAD.right} y2={yZero}
|
||||
stroke="rgba(255,255,255,0.18)" strokeWidth="1" />
|
||||
)}
|
||||
|
||||
{/* Remplissage dégradé */}
|
||||
<path d={areaPath} fill="url(#fcc-fill)" />
|
||||
|
||||
{/* Ligne principale */}
|
||||
<path d={linePath} fill="none" stroke={COLOR} strokeWidth="1"
|
||||
filter="url(#fcc-glow)" strokeLinejoin="round" />
|
||||
|
||||
{/* Labels axe X */}
|
||||
{xTicks.map((p, i) => {
|
||||
const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle';
|
||||
return (
|
||||
<text key={i} x={xScale(p.date)} y={H - 8}
|
||||
textAnchor={anchor} fill={LABEL} fontSize="10"
|
||||
fontFamily="system-ui,sans-serif">
|
||||
{fmtAxisDate(p.date, range)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ligne verticale + point hover */}
|
||||
{hover && (
|
||||
<g>
|
||||
<line x1={hover.x} y1={PAD.top} x2={hover.x} y2={PAD.top + plotH}
|
||||
stroke="rgba(255,255,255,0.12)" strokeWidth="1" strokeDasharray="3 4" />
|
||||
<circle cx={hover.x} cy={hover.y} r="4.5"
|
||||
fill={COLOR} stroke={BG} strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* ── Tooltip ── */}
|
||||
{hover && tooltipStyle && (
|
||||
<div style={tooltipStyle}>
|
||||
<div className="sg-tooltip">
|
||||
<span className="sg-tooltip-date">{tooltipDate}</span>
|
||||
<span className="sg-tooltip-value">{fmtValueDisplay(hover.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,26 +58,32 @@ function wrapText(text, maxWidth, fontSize) {
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function FraisDistributionChart({ rows }) {
|
||||
// Généralisé (Étape 7, 02/09/26) : `groupBy(row) => clé` détermine le regroupement (catégorie par
|
||||
// défaut) et `label` le texte affiché dans l'en-tête — permet de réutiliser ce composant pour la
|
||||
// répartition par plateforme sur la page Frais.jsx sans dupliquer le treemap.
|
||||
export default function FraisDistributionChart({ rows, groupBy, label = 'par Catégorie' }) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Frais cumulés par catégorie */
|
||||
/* Frais cumulés, regroupés selon `groupBy` (clé de regroupement par ligne) — par défaut la
|
||||
catégorie (comportement d'origine), généralisé (Étape 7, 02/09/26) pour être réutilisé à
|
||||
l'identique sur l'onglet "Plateformes" (groupBy = plateforme) de la page Frais.jsx. */
|
||||
const keyFn = groupBy || (r => FRAIS_CATEGORIE_LABELS[r.categorie] || r.categorie || 'Autre');
|
||||
const data = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byCat = {};
|
||||
const byKey = {};
|
||||
for (const r of rows) {
|
||||
const key = FRAIS_CATEGORIE_LABELS[r.categorie] || r.categorie || 'Autre';
|
||||
const key = keyFn(r) || 'Autre';
|
||||
const val = r.montant || 0;
|
||||
byCat[key] = (byCat[key] || 0) + val;
|
||||
byKey[key] = (byKey[key] || 0) + val;
|
||||
}
|
||||
return Object.entries(byCat)
|
||||
return Object.entries(byKey)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
|
||||
}, [rows]);
|
||||
}, [rows, keyFn]);
|
||||
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
const W = 440, H = 290, GAP = 3;
|
||||
@@ -116,7 +122,7 @@ export default function FraisDistributionChart({ rows }) {
|
||||
<div className="dist-chart-header">
|
||||
<span className="dist-chart-title">Frais</span>
|
||||
<div className="dist-dropdown">
|
||||
par Catégorie
|
||||
{label}
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
/**
|
||||
* FraisFormModal — formulaire partagé de saisie/édition d'un frais d'opération
|
||||
* (frais_operations, catégories gestion/distribution/autre — cf. project_frais_operation.md).
|
||||
*
|
||||
* Extrait de InvestissementDetail.jsx (Étape 5, page dédiée Frais, 02/09/26) sur le modèle de
|
||||
* RembFormModal.jsx : composant volontairement "bête" côté état, le parent garde la propriété
|
||||
* du form (state, effets de bord au changement de champ comme le chargement des comptes du
|
||||
* détenteur). Utilisé par InvestissementDetail.jsx (mode `locked` : investissement déjà
|
||||
* déterminé par le contexte, pas de sélecteur) et par la page Frais.jsx (mode complet, avec
|
||||
* sélecteur d'investissement).
|
||||
*
|
||||
* La catégorie "souscription" n'apparaît jamais dans le sélecteur de ce formulaire : elle est
|
||||
* unique par investissement et se saisit/modifie uniquement depuis l'étape "Frais" du formulaire
|
||||
* "Modifier l'investissement" (décision Olivier, 31/08/26) — ce composant ne gère jamais cette
|
||||
* catégorie, y compris depuis la page dédiée Frais.
|
||||
*
|
||||
* Props :
|
||||
* open, onClose — état d'ouverture
|
||||
* editingId — id en édition, null si création
|
||||
* form — { investissement_id?, montant, mode_reglement, compte_id,
|
||||
* date_operation, categorie, notes }
|
||||
* onFieldChange(key, value) — pour les champs à effets de bord (ex. investissement_id,
|
||||
* qui déclenche côté parent le rechargement des comptes du
|
||||
* détenteur ; mode_reglement, qui réinitialise compte_id)
|
||||
* err — message d'erreur à afficher
|
||||
* onSubmit — handler de soumission du formulaire
|
||||
* onDelete — handler de suppression (optionnel, affiche le bouton si fourni)
|
||||
* comptesInvestisseur — comptes du détenteur de l'investissement courant (select
|
||||
* "Compte de règlement" quand mode_reglement === 'compte_courant')
|
||||
* locked — bool : masque le sélecteur d'investissement (mode
|
||||
* InvestissementDetail.jsx, investissement déjà connu du contexte)
|
||||
* investissementOptions — liste pour le <select> investissement (mode non-locked
|
||||
* uniquement) — { id, plateforme_nom, nom_projet }[]
|
||||
* confirmingDelete, setConfirmingDelete — état de confirmation de suppression (géré par le
|
||||
* parent, remis à false dans son propre onClose)
|
||||
*/
|
||||
export default function FraisFormModal({
|
||||
open, onClose, editingId,
|
||||
form, onFieldChange,
|
||||
err, onSubmit, onDelete,
|
||||
comptesInvestisseur = [],
|
||||
locked = false,
|
||||
investissementOptions = [],
|
||||
confirmingDelete, setConfirmingDelete,
|
||||
}) {
|
||||
const modalTitle = editingId ? 'Modifier le frais' : 'Ajouter un frais';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={modalTitle}
|
||||
onClose={onClose}
|
||||
width={locked ? 480 : 560}
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{editingId && onDelete && (
|
||||
!confirmingDelete ? (
|
||||
<button className="danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--danger, #ef4444)', whiteSpace: 'nowrap' }}>
|
||||
Supprimer définitivement ?
|
||||
</span>
|
||||
<button onClick={() => setConfirmingDelete(false)}>Non</button>
|
||||
<button className="danger" onClick={onDelete}>Oui, supprimer</button>
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Annuler</button>
|
||||
<button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Ajouter'}</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="form-grid">
|
||||
{!locked && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Investissement *</label>
|
||||
<select required value={form.investissement_id || ''}
|
||||
onChange={e => onFieldChange('investissement_id', e.target.value)}>
|
||||
<option value="">— Choisir un investissement —</option>
|
||||
{investissementOptions.map(i => (
|
||||
<option key={i.id} value={i.id}>{i.plateforme_nom} — {i.nom_projet}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label>Montant TTC (€) *</label>
|
||||
<input type="number" step="0.01" min="0" required value={form.montant}
|
||||
onChange={e => onFieldChange('montant', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Date de l'opération *</label>
|
||||
<input type="date" required value={form.date_operation}
|
||||
onChange={e => onFieldChange('date_operation', e.target.value)} />
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Catégorie</label>
|
||||
<select value={form.categorie} onChange={e => onFieldChange('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={form.mode_reglement}
|
||||
onChange={e => onFieldChange('mode_reglement', e.target.value)}>
|
||||
<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>
|
||||
{form.mode_reglement === 'compte_courant' && (
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Compte de règlement *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select required value={form.compte_id} onChange={e => onFieldChange('compte_id', e.target.value)}>
|
||||
<option value="">— Choisir un compte —</option>
|
||||
{comptesInvestisseur.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={form.notes} onChange={e => onFieldChange('notes', e.target.value)} placeholder="Précisions optionnelles…" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
import UserMenu from './UserMenu.jsx';
|
||||
@@ -23,6 +24,7 @@ const IconDeposits = () => <I><line x1="6" y1="14" x2="6" y2="4"/><polyline
|
||||
const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyline points="11,3 15,3 15,7"/></I>;
|
||||
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
|
||||
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
|
||||
const IconFrais = () => <I><circle cx="5.5" cy="5.5" r="2"/><circle cx="12.5" cy="12.5" r="2"/><line x1="13.5" y1="3.5" x2="3.5" y2="13.5"/></I>;
|
||||
|
||||
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
|
||||
function NavIcon({ libFilename, Fallback }) {
|
||||
@@ -124,6 +126,7 @@ function ProjectSearch() {
|
||||
'/': 'Tableau de bord',
|
||||
'/investissements': 'Investissements',
|
||||
'/remboursements': 'Remboursements',
|
||||
'/frais': 'Frais',
|
||||
'/depots-retraits': 'Dépôts / Retraits',
|
||||
'/2778-sd': 'Fiscalité',
|
||||
'/plateformes': 'Plateformes',
|
||||
@@ -247,6 +250,7 @@ const IconBurger = () => (
|
||||
|
||||
export default function Layout() {
|
||||
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
|
||||
const { hasFrais } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
const [burgerOpen, setBurgerOpen] = useState(false);
|
||||
@@ -328,6 +332,11 @@ export default function Layout() {
|
||||
<NavLink to="/remboursements" title="Remboursements">
|
||||
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
|
||||
</NavLink>
|
||||
{hasFrais && (
|
||||
<NavLink to="/frais" title="Frais">
|
||||
<NavIcon libFilename={navIcons.frais} Fallback={IconFrais} /><span className="nav-label">Frais</span>
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
|
||||
@@ -73,9 +73,14 @@ export function AuthProvider({ children }) {
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
// Étape 2 (page dédiée Frais, 02/09/26) — vrai dès qu'au moins un frais a été déclaré
|
||||
// sur une plateforme de l'utilisateur (calculé côté backend, cf. GET /auth/me). Mécanisme
|
||||
// simple assumé : n'apparaît qu'après rechargement de session suivant la toute première
|
||||
// déclaration de frais, pas de mise à jour réactive en direct.
|
||||
const hasFrais = !!user?.has_frais;
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin }}>
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin, hasFrais }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -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