Amélioration des frais

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

No files matched your search

+295
View File
@@ -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"/>
+147
View File
@@ -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>
);
}
+9
View File
@@ -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>