From 3e8508d48b278111e55ce14d1beb5244f8e74a6f Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 4 Sep 2026 08:38:32 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9lioration=20des=20frais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/auth.js | 11 +- backend/src/routes/fraisOperations.js | 8 +- frontend/src/App.jsx | 12 + frontend/src/components/FraisCumulChart.jsx | 295 ++++++++++ .../src/components/FraisDistributionChart.jsx | 22 +- frontend/src/components/FraisFormModal.jsx | 147 +++++ frontend/src/components/Layout.jsx | 9 + frontend/src/context/AuthContext.jsx | 7 +- frontend/src/pages/Aide.jsx | 16 +- frontend/src/pages/Frais.jsx | 552 ++++++++++++++++++ frontend/src/pages/InvestissementDetail.jsx | 83 +-- frontend/src/pages/Remboursements.jsx | 127 ---- 12 files changed, 1083 insertions(+), 206 deletions(-) create mode 100644 frontend/src/components/FraisCumulChart.jsx create mode 100644 frontend/src/components/FraisFormModal.jsx create mode 100644 frontend/src/pages/Frais.jsx diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 5cce195..214a894 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -173,7 +173,16 @@ router.post('/login', async (req, res, next) => { router.get('/me', requireAuth, (req, res) => { 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); res.json({ user }); }); diff --git a/backend/src/routes/fraisOperations.js b/backend/src/routes/fraisOperations.js index 6ac7667..e8acef9 100644 --- a/backend/src/routes/fraisOperations.js +++ b/backend/src/routes/fraisOperations.js @@ -77,7 +77,10 @@ function assertUniqueSouscription(investissementId, categorie, excludeId) { /** * 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) => { try { @@ -94,10 +97,11 @@ router.get('/', (req, res, next) => { `).all(Number(investissement_id)); } else { 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 JOIN investissements i ON i.id = f.investissement_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 WHERE inv.user_id = ? ORDER BY f.date_operation DESC, f.id DESC diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1da1932..7e4cccd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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
Chargement…
; + if (!hasFrais) return ; + return children; +} + export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); @@ -84,6 +95,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/FraisCumulChart.jsx b/frontend/src/components/FraisCumulChart.jsx new file mode 100644 index 0000000..c78069b --- /dev/null +++ b/frontend/src/components/FraisCumulChart.jsx @@ -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 ( +
+ + {/* ── En-tête ── */} +
+
+
{displayDate}
+
{displayValue}
+
+
+ {/* Plages temporelles */} +
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Remplissage dégradé */} + + + {/* Ligne principale */} + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Ligne verticale + point hover */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/FraisDistributionChart.jsx b/frontend/src/components/FraisDistributionChart.jsx index 0f049b0..8ef5cf5 100644 --- a/frontend/src/components/FraisDistributionChart.jsx +++ b/frontend/src/components/FraisDistributionChart.jsx @@ -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 }) {
Frais
- par Catégorie + {label} diff --git a/frontend/src/components/FraisFormModal.jsx b/frontend/src/components/FraisFormModal.jsx new file mode 100644 index 0000000..0cc7108 --- /dev/null +++ b/frontend/src/components/FraisFormModal.jsx @@ -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
+ {editingId && onDelete && ( + !confirmingDelete ? ( + + ) : ( + + + Supprimer définitivement ? + + + + + ) + )} +
+ + +
+ } + > +
+ {err &&
{err}
} +
+ {!locked && ( +
+ + +
+ )} +
+ + onFieldChange('montant', e.target.value)} /> +
+
+ + onFieldChange('date_operation', e.target.value)} /> +
+
+ + +
+
+ + +
+ {form.mode_reglement === 'compte_courant' && ( +
+ + {comptesInvestisseur.length > 0 ? ( + + ) : ( +

+ Aucun compte défini pour ce détenteur. Créer un compte → +

+ )} +
+ )} +
+ + onFieldChange('notes', e.target.value)} placeholder="Précisions optionnelles…" /> +
+
+
+ + ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 258f949..8c0aec1 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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 = () => ; const IconRepayments = () => ; const IconFlatTax = () => ; +const IconFrais = () => ; /* 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() { Remboursements + {hasFrais && ( + + Frais + + )} Fiscalité diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 5db4ecf..94bd5fd 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( - + {children} ); diff --git a/frontend/src/pages/Aide.jsx b/frontend/src/pages/Aide.jsx index 7475b9b..4a69f50 100644 --- a/frontend/src/pages/Aide.jsx +++ b/frontend/src/pages/Aide.jsx @@ -315,17 +315,27 @@ export default function Aide() { remboursement), ou autre. Le bloc Frais d'opération de la fiche d'un investissement les recense tous.

+

+ Dès qu'au moins un frais a été déclaré sur l'une de vos plateformes, une page Frais 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. +

+

Où saisir un frais ?

  • Frais de souscription — depuis l'étape « Frais » du formulaire de création ou modification de l'investissement. Un seul frais de cette catégorie par investissement.
  • Frais de gestion ou autre — depuis le bouton - + Ajouter du bloc Frais d'opération sur la fiche.
  • + + Ajouter du bloc Frais d'opération sur la fiche + de l'investissement, ou depuis la page Frais dédiée (avec sélecteur d'investissement).
  • Frais sur distribution — depuis le champ Frais du formulaire d'un remboursement. La ligne est créée et - synchronisée automatiquement dans le bloc Frais d'opération ; elle ne peut être modifiée ou - supprimée que depuis ce remboursement, pour éviter toute désynchronisation.
  • + synchronisée automatiquement dans le bloc Frais d'opération et dans la page Frais ; elle ne + peut être modifiée ou supprimée que depuis ce remboursement, pour éviter toute désynchronisation.

Mode de règlement et impact

diff --git a/frontend/src/pages/Frais.jsx b/frontend/src/pages/Frais.jsx new file mode 100644 index 0000000..d9d856f --- /dev/null +++ b/frontend/src/pages/Frais.jsx @@ -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 ( + <> +

Frais

+ + + ); + + return ( + <> +
+

Frais

+ +
+ + {/* ── Graphiques : courbe du cumul des frais + répartition par plateforme ── */} +
+ + f.plateforme_nom || '—'} label="par Plateforme" /> +
+ + {/* ── KPIs ── */} +
+
+
Frais payés
+
+ {fmtEUR(fraisTotal)} +
+
+
+
Frais de souscription
+
+ {fmtEUR(fraisSouscriptionTotal)} +
+
+
+
Frais de distribution
+
+ {fmtEUR(fraisDistributionTotal)} +
+
+
+
Nombre d'opérations
+
+ {fraisFiltered.length} +
+
+
+
Frais moyen / opération
+
+ {fmtEUR(fraisMoyen)} +
+
+
+ + {/* ── Onglets ── */} +
+ + + + +
+ + {/* ══ 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' && ( +
+
+
+

Répartition par plateforme

+
+ + + + + + {multiDetenteur && } + + + + + + + {byPlateforme.length === 0 && ( + + )} + {byPlateforme.map(p => { + const poids = fraisTotal !== 0 ? (p.montant / fraisTotal) * 100 : 0; + return ( + + + {multiDetenteur && } + + + + + ); + })} + + {byPlateforme.length > 1 && ( + + + + + + + + + )} +
PlateformeDétenteurMontantNombrePoids
+ {loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'} +
{p.label}{p.detenteur}{fmtEUR(p.montant)}{p.count} +
+
+
+
+ + {poids.toFixed(1)} % + +
+
Total — {byPlateforme.length} plateformes{fmtEUR(fraisTotal)}{fraisFiltered.length}100 %
+
+
+ )} + + {/* ══ ONGLET TYPOLOGIE — répartition des frais par catégorie (graphique retiré, cf. + Étape 9, 02/09/26 — demande Olivier) ══ */} + {activeTab === 'typologie' && ( +
+
+
+

Répartition par catégorie

+
+ + + + + + + + + + + + {byCategorie.length === 0 && ( + + )} + {byCategorie.map(c => { + const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0; + return ( + + + + + + + ); + })} + + {byCategorie.length > 1 && ( + + + + + + + + + )} +
CatégorieMontantNombrePoids
+ {loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'} +
{c.label}{fmtEUR(c.montant)}{c.count} +
+
+
+
+ + {poids.toFixed(1)} % + +
+
Total — {byCategorie.length} catégories{fmtEUR(fraisTotal)}{fraisFiltered.length}100 %
+
+
+ )} + + {/* ══ ONGLET INVESTISSEMENTS — liste des frais par investissement, uniquement ceux + concernés (Étape 11, 02/09/26 — demande Olivier) ══ */} + {activeTab === 'investissements' && ( +
+
+
+

Frais par investissement

+
+ + + + + + {multiDetenteur && } + + + + + + + + {byInvestissement.length === 0 && ( + + )} + {byInvestissement.map(inv => ( + + + {multiDetenteur && } + + + + + + ))} + + {byInvestissement.length > 1 && ( + + + + + + + + + )} +
PlateformeDétenteurProjetSouscriptionDistributionTotal Frais
+ {loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'} +
{inv.plateforme}{inv.detenteur}{inv.projet}{fmtEUR(inv.souscription)}{fmtEUR(inv.distribution)}{fmtEUR(inv.total)}
Total — {byInvestissement.length} investissements{fmtEUR(byInvestissement.reduce((s, i) => s + i.souscription, 0))}{fmtEUR(byInvestissement.reduce((s, i) => s + i.distribution, 0))}{fmtEUR(byInvestissement.reduce((s, i) => s + i.total, 0))}
+
+
+ )} + + {/* ══ ONGLET OPÉRATIONS — liste détaillée (CRUD) ══ */} + {activeTab === 'operations' && ( +
+
+
+

Détail des opérations

+
+ + + + + + + + + + + {pagedFrais.length === 0 && ( + + )} + {pagedFrais.map(f => ( + openEdit(f)}> + + + + + + + + + + + ))} + +
DatePlateformeProjetMontantCatégoriePrélèvementCompteNotes
+ {loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'} +
{fmtDate(f.date_operation)}{f.plateforme_nom}{f.nom_projet}{fmtEUR(f.montant)}{FRAIS_CATEGORIE_LABELS[f.categorie] || f.categorie}{FRAIS_MODE_LABELS[f.mode_reglement] || f.mode_reglement}{f.compte_nom || '—'}{f.notes || '—'} e.stopPropagation()}> + +
+ +
+
+ )} + + + + ); +} diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index 487ce89..4d2dedb 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -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) ─────────────── */} - - - - - } - > -
- {fraisErr &&
{fraisErr}
} -
-
- - setFraisForm(f => ({ ...f, montant: e.target.value }))} /> -
-
- - setFraisForm(f => ({ ...f, date_operation: e.target.value }))} /> -
-
- - -
-
- - -
- {fraisForm.mode_reglement === 'compte_courant' && ( -
- - {comptesFraisInvestisseur.length > 0 ? ( - - ) : ( -

- Aucun compte défini pour ce détenteur. Créer un compte → -

- )} -
- )} -
- - setFraisForm(f => ({ ...f, notes: e.target.value }))} placeholder="Précisions optionnelles…" /> -
-
-
-
+ editingId={editingFraisId} + form={fraisForm} + onFieldChange={setFraisField} + err={fraisErr} + onSubmit={submitFrais} + comptesInvestisseur={comptesFraisInvestisseur} + locked + /> - 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 -
} {/* ══ ONGLET PLATEFORMES ══ */} @@ -1561,96 +1524,6 @@ export default function Remboursements() { )} - {/* ══ ONGLET FRAIS (Étape 5 — reporting consolidé) ══ */} - {activeTab === 'frais' && ( -
- {/* ── KPI ── */} -
-
-
- Frais payés{rembPlatYear && ` en ${rembPlatYear}`} -
-
- {fmtEUR(fraisTotal)} - {rembPlatYear && } -
- {rembPlatYear && ( -
- {fmtEUR(fraisPrevTotal)} en {rembPrevYear} -
- )} -
-
- - {/* ── Graphique ── */} -
- -
- - {/* ── Tableau par catégorie ── */} -
-
-

- Répartition par catégorie - {rembPlatYear && pour l'année {rembPlatYear}} -

- {filterPlatId && ( - - Filtré sur cette plateforme - - )} -
- - - - - - - - - - - - {byCategorie.length === 0 && ( - - )} - {byCategorie.map(c => { - const poids = fraisTotal !== 0 ? (c.montant / fraisTotal) * 100 : 0; - return ( - - - - - - - ); - })} - - {byCategorie.length > 1 && ( - - - - - - - - - )} -
CatégorieMontantNombrePoids
- {loading ? 'Chargement…' : 'Aucun frais enregistré pour ces critères'} -
{FRAIS_CATEGORIE_LABELS[c.categorie] || c.categorie}{fmtEUR(c.montant)}{c.count} -
-
-
-
- - {poids.toFixed(1)} % - -
-
Total — {byCategorie.length} catégories{fmtEUR(fraisTotal)}{fraisFiltered.length}100 %
-
-
- )} {/* ══ MODAL ══ */} {(() => {