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

+10 -1
View File
@@ -173,7 +173,16 @@ router.post('/login', async (req, res, next) => {
router.get('/me', requireAuth, (req, res) => { router.get('/me', requireAuth, (req, res) => {
const user = db const user = db
.prepare('SELECT id, email, display_name, role, email_verified, totp_enabled FROM users WHERE id = ?') .prepare(`
SELECT id, email, display_name, role, email_verified, totp_enabled,
EXISTS(
SELECT 1 FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE inv.user_id = users.id
) AS has_frais
FROM users WHERE id = ?
`)
.get(req.user.id); .get(req.user.id);
res.json({ user }); res.json({ user });
}); });
+6 -2
View File
@@ -77,7 +77,10 @@ function assertUniqueSouscription(investissementId, categorie, excludeId) {
/** /**
* GET /api/frais-operations?investissement_id=123 * GET /api/frais-operations?investissement_id=123
* Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs). * Sans investissement_id : tous les frais de l'utilisateur (tous investisseurs), enrichis de
* nom_projet/plateforme_nom/investisseur_nom (vue portefeuille, ex. page dédiée Frais — colonne
* Détenteur et onglet "Investissements", Étape 11, 02/09/26) — le filtre par investissement_id
* n'a pas besoin de cet enrichissement, le contexte est déjà connu côté client.
*/ */
router.get('/', (req, res, next) => { router.get('/', (req, res, next) => {
try { try {
@@ -94,10 +97,11 @@ router.get('/', (req, res, next) => {
`).all(Number(investissement_id)); `).all(Number(investissement_id));
} else { } else {
rows = db.prepare(` rows = db.prepare(`
SELECT f.*, c.nom AS compte_nom SELECT f.*, c.nom AS compte_nom, i.nom_projet, p.nom AS plateforme_nom, inv.nom AS investisseur_nom
FROM frais_operations f FROM frais_operations f
JOIN investissements i ON i.id = f.investissement_id JOIN investissements i ON i.id = f.investissement_id
JOIN investisseurs inv ON inv.id = i.investisseur_id JOIN investisseurs inv ON inv.id = i.investisseur_id
JOIN plateformes p ON p.id = i.plateforme_id
LEFT JOIN comptes c ON c.id = f.compte_id LEFT JOIN comptes c ON c.id = f.compte_id
WHERE inv.user_id = ? WHERE inv.user_id = ?
ORDER BY f.date_operation DESC, f.id DESC ORDER BY f.date_operation DESC, f.id DESC
+12
View File
@@ -13,6 +13,7 @@ import DepotsRetraits from './pages/DepotsRetraits.jsx';
import Investissements from './pages/Investissements.jsx'; import Investissements from './pages/Investissements.jsx';
import InvestissementDetail from './pages/InvestissementDetail.jsx'; import InvestissementDetail from './pages/InvestissementDetail.jsx';
import Remboursements from './pages/Remboursements.jsx'; import Remboursements from './pages/Remboursements.jsx';
import Frais from './pages/Frais.jsx';
import SimulRemboursements from './pages/SimulRemboursements.jsx'; import SimulRemboursements from './pages/SimulRemboursements.jsx';
import TaxReport from './pages/TaxReport.jsx'; import TaxReport from './pages/TaxReport.jsx';
import Settings from './pages/Settings.jsx'; import Settings from './pages/Settings.jsx';
@@ -40,6 +41,16 @@ function AdminOnly({ children }) {
return 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() { export default function App() {
const [allowRegistration, setAllowRegistration] = useState(true); const [allowRegistration, setAllowRegistration] = useState(true);
@@ -84,6 +95,7 @@ export default function App() {
<Route path="investissements" element={<Investissements />} /> <Route path="investissements" element={<Investissements />} />
<Route path="investissements/:id" element={<InvestissementDetail />} /> <Route path="investissements/:id" element={<InvestissementDetail />} />
<Route path="remboursements" element={<Remboursements />} /> <Route path="remboursements" element={<Remboursements />} />
<Route path="frais" element={<FraisOnly><Frais /></FraisOnly>} />
<Route path="simul" element={<SimulRemboursements />} /> <Route path="simul" element={<SimulRemboursements />} />
<Route path="taxreport" element={<TaxReport />} /> <Route path="taxreport" element={<TaxReport />} />
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} /> <Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
+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 ──────────────────────────────────────────────── */ /* ── 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 [hoveredIdx, setHoveredIdx] = useState(null);
const [tooltip, setTooltip] = useState(null); const [tooltip, setTooltip] = useState(null);
const svgRef = useRef(null); const svgRef = useRef(null);
const wrapRef = 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(() => { const data = useMemo(() => {
if (!rows?.length) return []; if (!rows?.length) return [];
const byCat = {}; const byKey = {};
for (const r of rows) { 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; 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) .filter(([, v]) => v > 0)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] })); .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 total = data.reduce((s, i) => s + i.value, 0);
const W = 440, H = 290, GAP = 3; const W = 440, H = 290, GAP = 3;
@@ -116,7 +122,7 @@ export default function FraisDistributionChart({ rows }) {
<div className="dist-chart-header"> <div className="dist-chart-header">
<span className="dist-chart-title">Frais</span> <span className="dist-chart-title">Frais</span>
<div className="dist-dropdown"> <div className="dist-dropdown">
par Catégorie {label}
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" <svg width="10" height="10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6"/> <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 { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx'; import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx'; import { useUi } from '../context/UiContext.jsx';
import Logo from './Logo.jsx'; import Logo from './Logo.jsx';
import UserMenu from './UserMenu.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 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 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 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 */ /* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
function NavIcon({ libFilename, Fallback }) { function NavIcon({ libFilename, Fallback }) {
@@ -124,6 +126,7 @@ function ProjectSearch() {
'/': 'Tableau de bord', '/': 'Tableau de bord',
'/investissements': 'Investissements', '/investissements': 'Investissements',
'/remboursements': 'Remboursements', '/remboursements': 'Remboursements',
'/frais': 'Frais',
'/depots-retraits': 'Dépôts / Retraits', '/depots-retraits': 'Dépôts / Retraits',
'/2778-sd': 'Fiscalité', '/2778-sd': 'Fiscalité',
'/plateformes': 'Plateformes', '/plateformes': 'Plateformes',
@@ -247,6 +250,7 @@ const IconBurger = () => (
export default function Layout() { export default function Layout() {
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi(); const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
const { hasFrais } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [navIcons, setNavIcons] = useState({}); const [navIcons, setNavIcons] = useState({});
const [burgerOpen, setBurgerOpen] = useState(false); const [burgerOpen, setBurgerOpen] = useState(false);
@@ -328,6 +332,11 @@ export default function Layout() {
<NavLink to="/remboursements" title="Remboursements"> <NavLink to="/remboursements" title="Remboursements">
<NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span> <NavIcon libFilename={navIcons.remboursement} Fallback={IconRepayments} /><span className="nav-label">Remboursements</span>
</NavLink> </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é"> <NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span> <NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink> </NavLink>
+6 -1
View File
@@ -73,9 +73,14 @@ export function AuthProvider({ children }) {
}; };
const isAdmin = user?.role === 'admin'; 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 ( 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} {children}
</AuthCtx.Provider> </AuthCtx.Provider>
); );
+13 -3
View File
@@ -315,17 +315,27 @@ export default function Aide() {
remboursement), ou autre. Le bloc <em>Frais d'opération</em> de la fiche d'un investissement les recense tous. remboursement), ou autre. Le bloc <em>Frais d'opération</em> de la fiche d'un investissement les recense tous.
</p> </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> <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 }}> <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 <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 formulaire de création ou modification de l'investissement. Un seul frais de cette catégorie par
investissement.</li> investissement.</li>
<li><strong style={{ color: 'var(--text)' }}>Frais de gestion ou autre</strong> — depuis le bouton <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 <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 <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 synchronisée automatiquement dans le bloc <em>Frais d'opération</em> et dans la page <em>Frais</em> ; elle ne
supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li> peut être modifiée ou supprimée que depuis ce remboursement, pour éviter toute désynchronisation.</li>
</ul> </ul>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Mode de règlement et impact</h4> <h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Mode de règlement et impact</h4>
+552
View File
@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import FraisCumulChart from '../components/FraisCumulChart.jsx';
import FraisDistributionChart from '../components/FraisDistributionChart.jsx';
import { FRAIS_CATEGORIE_LABELS } from '../components/MouvementsShared.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
/**
* Page dédiée "Frais" (Étape 6, 02/09/26) — reporting + CRUD complet des frais_operations,
* sur le modèle de l'onglet "Plateformes" de Remboursements.jsx : zone de graphiques (courbe du
* cumul des frais), zone de KPI, onglets (Étape 7, 02/09/26 — restructuration demandée par
* Olivier pour respecter cet "esprit" : Plateformes / Typologie / Opérations, cf.
* project_frais_operation.md). Route protégée (FraisOnly, cf. App.jsx) : n'apparaît qu'après
* déclaration d'au moins un frais (hasFrais, cf. AuthContext.jsx) — le contenu de l'onglet
* "Frais" d'origine a été retiré de Remboursements.jsx au profit de cette page (décision
* Olivier, 02/09/26).
*/
const FRAIS_MODE_LABELS = {
source: 'À la source',
portefeuille: 'Porte-monnaie',
compte_courant: 'Compte courant',
remboursement: 'Retenu sur remboursement',
};
function emptyForm() {
return { investissement_id: '', montant: '', mode_reglement: 'source', compte_id: '', date_operation: today(), notes: '', categorie: 'autre' };
}
export default function Frais() {
const { activeId, activeView } = useInvestisseur();
const [allFraisOps, setAllFraisOps] = useState([]);
const [investissements, setInvestissements] = useState([]);
const [plateformes, setPlateformes] = useState([]);
const [platLoaded, setPlatLoaded] = useState(false);
const [loading, setLoading] = useState(false);
/* Onglets (Étape 7, 02/09/26) */
const [activeTab, setActiveTab] = useState('plateformes');
/* Modale ajout/édition */
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm());
const [err, setErr] = useState(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [comptesInvestisseur, setComptesInvestisseur] = useState([]);
const multiDetenteur = new Set(plateformes.map(p => p.investisseur_id)).size > 1;
const load = async () => {
if (!activeId && activeView !== 'all') return;
setLoading(true);
try {
const scopeParams = activeView === 'all' ? { scope: 'all' } : {};
const [f, inv] = await Promise.all([
api.get('/frais-operations'),
api.get('/investissements', scopeParams),
]);
setAllFraisOps(f);
setInvestissements(inv);
} finally {
setLoading(false);
}
};
useEffect(() => {
api.get('/plateformes').then(d => { setPlateformes(d); setPlatLoaded(true); }).catch(() => setPlatLoaded(true));
}, []);
useEffect(() => { load(); /* eslint-disable-next-line */ }, [activeId, activeView]);
// Charge les comptes du détenteur de l'investissement sélectionné dans le formulaire —
// même pattern que le bloc "Frais d'opération" de InvestissementDetail.jsx, sauf que
// l'investissement (donc le détenteur) peut changer à tout moment via le sélecteur ici.
useEffect(() => {
const inv = investissements.find(i => String(i.id) === String(form.investissement_id));
if (!modalOpen || !inv?.investisseur_id) { setComptesInvestisseur([]); return; }
api.get(`/investissements/comptes-par-investisseur/${inv.investisseur_id}`)
.then(setComptesInvestisseur)
.catch(() => setComptesInvestisseur([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalOpen, form.investissement_id]);
/* Plus de filtres Plateforme/Année sur cette page (retirés le 02/09/26, Étape 10 — demande
Olivier) : la page affiche toujours l'ensemble des frais. */
const fraisFiltered = allFraisOps;
const fraisTotal = useMemo(() => fraisFiltered.reduce((s, f) => s + (f.montant || 0), 0), [fraisFiltered]);
const fraisMoyen = fraisFiltered.length ? fraisTotal / fraisFiltered.length : 0;
const fraisSouscriptionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'souscription').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
const fraisDistributionTotal = useMemo(() =>
fraisFiltered.filter(f => f.categorie === 'distribution').reduce((s, f) => s + (f.montant || 0), 0),
[fraisFiltered]
);
/* ── Répartition par catégorie (onglet "Typologie") ── */
const byCategorie = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.categorie || 'autre';
if (!map[key]) map[key] = { key, label: FRAIS_CATEGORIE_LABELS[key] || key, montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Répartition par plateforme (onglet "Plateformes") ── */
const byPlateforme = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.plateforme_id;
if (!map[key]) map[key] = { key, label: f.plateforme_nom || '—', detenteur: f.investisseur_nom || '—', montant: 0, count: 0 };
map[key].montant += f.montant || 0;
map[key].count += 1;
}
return Object.values(map).sort((a, b) => b.montant - a.montant);
}, [fraisFiltered]);
/* ── Frais par investissement (onglet "Investissements") — uniquement les investissements
concernés par au moins un frais, cf. Étape 11, 02/09/26. ── */
const byInvestissement = useMemo(() => {
const map = {};
for (const f of fraisFiltered) {
const key = f.investissement_id;
if (!map[key]) map[key] = {
key,
plateforme: f.plateforme_nom || '—',
detenteur: f.investisseur_nom || '—',
projet: f.nom_projet || '—',
souscription: 0,
distribution: 0,
total: 0,
};
const cat = f.categorie || 'autre';
if (cat === 'souscription') map[key].souscription += f.montant || 0;
if (cat === 'distribution') map[key].distribution += f.montant || 0;
map[key].total += f.montant || 0;
}
return Object.values(map).sort((a, b) => {
const pa = a.plateforme.toLowerCase(), pb = b.plateforme.toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return a.projet.localeCompare(b.projet, 'fr');
});
}, [fraisFiltered]);
/* ── Tableau détaillé paginé (onglet "Opérations") ── */
const {
pagedItems: pagedFrais, page, setPage, pageSize, setPageSize,
totalPages, totalItems, PAGE_SIZES,
} = usePagination(fraisFiltered, 'cl_pagesize_frais', []);
/* ── Investissements pour le sélecteur de la modale (triés plateforme puis projet) ── */
const investissementOptions = useMemo(() =>
investissements.slice().sort((a, b) => {
const pa = (a.plateforme_nom || '').toLowerCase();
const pb = (b.plateforme_nom || '').toLowerCase();
if (pa !== pb) return pa.localeCompare(pb, 'fr');
return (a.nom_projet || '').localeCompare(b.nom_projet || '', 'fr');
}),
[investissements]
);
/* ── CRUD ── */
const openAdd = () => {
setEditingId(null);
setForm(emptyForm());
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const openEdit = (f) => {
setEditingId(f.id);
setForm({
investissement_id: String(f.investissement_id),
montant: f.montant,
mode_reglement: f.mode_reglement,
compte_id: f.compte_id || '',
date_operation: f.date_operation,
notes: f.notes || '',
categorie: f.categorie || 'autre',
});
setErr(null);
setConfirmingDelete(false);
setModalOpen(true);
};
const closeModal = () => { setModalOpen(false); setErr(null); setConfirmingDelete(false); };
// Callback générique (onFieldChange) — mêmes effets de bord que le formulaire d'origine
// (InvestissementDetail.jsx) : changer mode_reglement réinitialise compte_id sauf en passant
// à 'compte_courant' ; changer d'investissement réinitialise compte_id (détenteur différent).
const setField = (key, value) => {
setForm(f => {
if (key === 'mode_reglement') return { ...f, mode_reglement: value, compte_id: value === 'compte_courant' ? f.compte_id : '' };
if (key === 'investissement_id') return { ...f, investissement_id: value, compte_id: '' };
return { ...f, [key]: value };
});
};
const submit = async (e) => {
e?.preventDefault?.();
setErr(null);
try {
const payload = {
investissement_id: Number(form.investissement_id),
montant: Number(form.montant),
mode_reglement: form.mode_reglement,
compte_id: form.mode_reglement === 'compte_courant' && form.compte_id ? Number(form.compte_id) : null,
date_operation: form.date_operation,
origine: 'manuel',
categorie: form.categorie || 'autre',
notes: form.notes || undefined,
};
if (editingId) await api.put(`/frais-operations/${editingId}`, payload);
else await api.post('/frais-operations', payload);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
const onDelete = async () => {
setErr(null);
try {
await api.del(`/frais-operations/${editingId}`);
closeModal();
await load();
} catch (e) { setErr(e.message); }
};
/* ── Rendu ── */
if (platLoaded && !loading && plateformes.length === 0) return (
<>
<div className="topbar"><h2>Frais</h2></div>
<EmptyState />
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="frais" />Frais</h2>
<button className="primary" onClick={openAdd}>+ Ajouter un frais</button>
</div>
{/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */}
<div className="charts-row">
<FraisCumulChart rows={fraisFiltered} />
<FraisDistributionChart rows={fraisFiltered} groupBy={f => f.plateforme_nom || '—'} label="par Plateforme" />
</div>
{/* ── KPIs ── */}
<div className="dr-kpi-row">
<div className="kpi">
<div className="label">Frais payés</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de souscription</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisSouscriptionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais de distribution</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisDistributionTotal)}</span>
</div>
</div>
<div className="kpi">
<div className="label">Nombre d'opérations</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fraisFiltered.length}</span>
</div>
</div>
<div className="kpi">
<div className="label">Frais moyen / opération</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span style={{ fontSize: '1.35rem', fontWeight: 700 }}>{fmtEUR(fraisMoyen)}</span>
</div>
</div>
</div>
{/* ── Onglets ── */}
<div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'typologie' ? ' active' : ''}`}
onClick={() => setActiveTab('typologie')}>Typologie</button>
<button className={`dr-tab${activeTab === 'investissements' ? ' active' : ''}`}
onClick={() => setActiveTab('investissements')}>Investissements</button>
<button className={`dr-tab${activeTab === 'operations' ? ' active' : ''}`}
onClick={() => setActiveTab('operations')}>Opérations</button>
</div>
{/* ══ ONGLET PLATEFORMES — répartition des frais par plateforme (graphique déjà affiché
en haut de page, à côté de la courbe de cumul — cf. Étape 8, 02/09/26) ══ */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par plateforme</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byPlateforme.length === 0 && (
<tr><td colSpan={multiDetenteur ? 5 : 4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byPlateforme.map(p => {
const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0;
return (
<tr key={p.key}>
<td>{p.label}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur}</td>}
<td className="num">{fmtEUR(p.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{p.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byPlateforme.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 2 : 1}>Total — {byPlateforme.length} plateformes</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET TYPOLOGIE — répartition des frais par catégorie (graphique retiré, cf.
Étape 9, 02/09/26 — demande Olivier) ══ */}
{activeTab === 'typologie' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Répartition par catégorie</h3>
</div>
<table>
<thead>
<tr>
<th>Catégorie</th>
<th className="num">Montant</th>
<th className="num">Nombre</th>
<th className="num" style={{ minWidth: 120 }}>Poids</th>
</tr>
</thead>
<tbody>
{byCategorie.length === 0 && (
<tr><td colSpan={4} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byCategorie.map(c => {
const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0;
return (
<tr key={c.key}>
<td>{c.label}</td>
<td className="num">{fmtEUR(c.montant)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{c.count}</td>
<td className="num">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
<div style={{ width: 48, height: 5, borderRadius: 3, background: 'var(--surface-2)', overflow: 'hidden' }}>
<div style={{ width: `${Math.max(0, poids)}%`, height: '100%', background: 'var(--primary)', borderRadius: 3 }} />
</div>
<span style={{ minWidth: 36, textAlign: 'right', fontSize: 'var(--fs-sm)' }}>
{poids.toFixed(1)} %
</span>
</div>
</td>
</tr>
);
})}
</tbody>
{byCategorie.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total — {byCategorie.length} catégories</td>
<td className="num">{fmtEUR(fraisTotal)}</td>
<td className="num" style={{ color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>{fraisFiltered.length}</td>
<td className="num"><span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>100 %</span></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET INVESTISSEMENTS — liste des frais par investissement, uniquement ceux
concernés (Étape 11, 02/09/26 — demande Olivier) ══ */}
{activeTab === 'investissements' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Frais par investissement</h3>
</div>
<table>
<thead>
<tr>
<th>Plateforme</th>
{multiDetenteur && <th>Détenteur</th>}
<th>Projet</th>
<th className="num">Souscription</th>
<th className="num">Distribution</th>
<th className="num">Total Frais</th>
</tr>
</thead>
<tbody>
{byInvestissement.length === 0 && (
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{byInvestissement.map(inv => (
<tr key={inv.key}>
<td>{inv.plateforme}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{inv.detenteur}</td>}
<td>{inv.projet}</td>
<td className="num">{fmtEUR(inv.souscription)}</td>
<td className="num">{fmtEUR(inv.distribution)}</td>
<td className="num" style={{ fontWeight: 600 }}>{fmtEUR(inv.total)}</td>
</tr>
))}
</tbody>
{byInvestissement.length > 1 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td colSpan={multiDetenteur ? 3 : 2}>Total — {byInvestissement.length} investissements</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.souscription, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.distribution, 0))}</td>
<td className="num">{fmtEUR(byInvestissement.reduce((s, i) => s + i.total, 0))}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)}
{/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */}
{activeTab === 'operations' && (
<div style={{ padding: '0 24px' }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Détail des opérations</h3>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Projet</th><th className="num">Montant</th>
<th>Catégorie</th><th>Prélèvement</th><th>Compte</th><th>Notes</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{pagedFrais.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'}
</td></tr>
)}
{pagedFrais.map(f => (
<tr key={f.id} style={{ cursor: 'pointer' }} title="Modifier ce frais" onClick={() => openEdit(f)}>
<td>{fmtDate(f.date_operation)}</td>
<td>{f.plateforme_nom}</td>
<td>{f.nom_projet}</td>
<td className="num">{fmtEUR(f.montant)}</td>
<td>{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}</td>
<td style={{ fontSize: 'var(--fs-sm)' }}>{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.compte_nom || '—'}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{f.notes || '—'}</td>
<td style={{ width: 36, textAlign: 'center' }} onClick={e => e.stopPropagation()}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--text-muted)', lineHeight: 1, fontSize: 16 }}
title="Modifier"
onClick={() => openEdit(f)}>⋮</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page} setPage={setPage}
pageSize={pageSize} setPageSize={setPageSize}
totalPages={totalPages} totalItems={totalItems}
PAGE_SIZES={PAGE_SIZES}
/>
</div>
</div>
)}
<FraisFormModal
open={modalOpen}
onClose={closeModal}
editingId={editingId}
form={form}
onFieldChange={setField}
err={err}
onSubmit={submit}
onDelete={onDelete}
comptesInvestisseur={comptesInvestisseur}
investissementOptions={investissementOptions}
confirmingDelete={confirmingDelete}
setConfirmingDelete={setConfirmingDelete}
/>
</>
);
}
+19 -64
View File
@@ -6,6 +6,7 @@ import { useUi } from '../context/UiContext.jsx';
import Modal from '../components/Modal.jsx'; import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx';
import RembFormModal from '../components/RembFormModal.jsx'; import RembFormModal from '../components/RembFormModal.jsx';
import FraisFormModal from '../components/FraisFormModal.jsx';
import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx'; import { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import PageIcon from '../components/PageIcon.jsx'; import PageIcon from '../components/PageIcon.jsx';
import InvestissementFormModal from '../components/InvestissementFormModal.jsx'; import InvestissementFormModal from '../components/InvestissementFormModal.jsx';
@@ -356,6 +357,15 @@ export default function InvestissementDetail() {
const closeFraisModal = () => { setFraisModalOpen(false); setFraisErr(null); }; 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) => { const submitFrais = async (e) => {
e?.preventDefault?.(); e?.preventDefault?.();
setFraisErr(null); setFraisErr(null);
@@ -3262,72 +3272,17 @@ export default function InvestissementDetail() {
)} )}
{/* ── Modale "Frais d'opération" (ajout / édition) ─────────────── */} {/* ── Modale "Frais d'opération" (ajout / édition) ─────────────── */}
<Modal <FraisFormModal
open={fraisModalOpen} open={fraisModalOpen}
title={editingFraisId ? 'Modifier le frais' : 'Ajouter un frais'}
onClose={closeFraisModal} onClose={closeFraisModal}
width={480} editingId={editingFraisId}
footer={ form={fraisForm}
<> onFieldChange={setFraisField}
<button className="ghost" onClick={closeFraisModal}>Annuler</button> err={fraisErr}
<button className="primary" onClick={submitFrais}>{editingFraisId ? 'Enregistrer' : 'Ajouter'}</button> onSubmit={submitFrais}
</> comptesInvestisseur={comptesFraisInvestisseur}
} locked
> />
<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>
<ConfirmModal <ConfirmModal
open={!!rowDeleteConfirm} open={!!rowDeleteConfirm}
-127
View File
@@ -10,8 +10,6 @@ import { useUi } from '../context/UiContext.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx'; import ConfirmModal from '../components/ConfirmModal.jsx';
import InteretsChart from '../components/InteretsChart.jsx'; import InteretsChart from '../components/InteretsChart.jsx';
import InteretsDistributionChart from '../components/InteretsDistributionChart.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 TableauInteretsPlateforme from '../components/TableauInteretsPlateforme.jsx';
import DrillCellPanel from '../components/DrillCellPanel.jsx'; import DrillCellPanel from '../components/DrillCellPanel.jsx';
import RembFormModal from '../components/RembFormModal.jsx'; import RembFormModal from '../components/RembFormModal.jsx';
@@ -420,39 +418,6 @@ export default function Remboursements() {
[byPlatform] [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 ── */ /* ── Tableau remboursements : filtrage client-side ── */
const tableRows = useMemo(() => { const tableRows = useMemo(() => {
// Remboursements filtrés // Remboursements filtrés
@@ -941,8 +906,6 @@ export default function Remboursements() {
onClick={() => setActiveTab('remboursements')}>Remboursements</button> onClick={() => setActiveTab('remboursements')}>Remboursements</button>
<button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`} <button className={`dr-tab${activeTab === 'projections' ? ' active' : ''}`}
onClick={() => setActiveTab('projections')}>Projections Échéances</button> onClick={() => setActiveTab('projections')}>Projections Échéances</button>
<button className={`dr-tab${activeTab === 'frais' ? ' active' : ''}`}
onClick={() => setActiveTab('frais')}>Frais</button>
</div>} </div>}
{/* ══ ONGLET PLATEFORMES ══ */} {/* ══ ONGLET PLATEFORMES ══ */}
@@ -1561,96 +1524,6 @@ export default function Remboursements() {
</div> </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 ══ */} {/* ══ MODAL ══ */}
{(() => { {(() => {