Mise en place de la feature de gestion des Frais
This commit is contained in:
1 parent
9ea078302d
commit
0334a63139
27 files changed
+2401
-117
No files matched your search
@@ -462,7 +462,33 @@ export default function DrillCellPanel({
|
||||
if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts;
|
||||
else if (c.key === 'capital') v = r.capital ?? 0;
|
||||
else v = r.cashback ?? 0;
|
||||
return <td key={c.key} style={{ ...tdStyle, ...numStyle }}>{fmtEUR(v)}</td>;
|
||||
// Icône uniquement si le frais réduit réellement ce montant (mode
|
||||
// 'remboursement', par défaut) — cf. RembFormModal.jsx, Étape 5 bis (01/09/26).
|
||||
const showFraisIcon = c.key === 'interets' && !netMode && !!r.frais_ttc && (!r.frais_mode_reglement || r.frais_mode_reglement === 'remboursement');
|
||||
return (
|
||||
<td key={c.key} style={{ ...tdStyle, ...numStyle }}>
|
||||
{fmtEUR(v)}
|
||||
{showFraisIcon && (
|
||||
<span
|
||||
className="cell-tooltip"
|
||||
data-tooltip={
|
||||
`Intérêts bruts avant frais : ${fmtEUR(r.interets_bruts)}\n` +
|
||||
`Frais de distribution : -${fmtEUR(r.frais_ttc)}\n` +
|
||||
`Base après frais (avant prélèvements) : ${fmtEUR(round2(r.interets_bruts - r.frais_ttc))}\n` +
|
||||
`Prélèv. sociaux : ${fmtEUR(r.prelev_sociaux)}\n` +
|
||||
`Impôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}\n` +
|
||||
`Montant net reçu : ${fmtEUR(r.net_recu)}`
|
||||
}
|
||||
style={{ marginLeft: 5, color: 'var(--warning, #b45309)', verticalAlign: 'middle' }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td style={{ ...tdStyle, ...numStyle, fontWeight: 600 }}>{fmtEUR(recuRowValue(r))}</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { FRAIS_CATEGORIE_LABELS } from './MouvementsShared.jsx';
|
||||
|
||||
/* ── Palette ────────────────────────────────────────────────── */
|
||||
const PALETTE = [
|
||||
'#e8547a', '#9333ea', '#0d9488', '#f59e0b',
|
||||
'#3b82f6', '#10b981', '#f97316', '#06b6d4',
|
||||
'#a855f7', '#84cc16', '#ec4899', '#14b8a6',
|
||||
];
|
||||
|
||||
/* ── Algorithme treemap (binary split équilibré) ─────────────── */
|
||||
function buildTreemap(items, x, y, w, h) {
|
||||
if (!items.length) return [];
|
||||
if (items.length === 1) return [{ ...items[0], x, y, w, h }];
|
||||
|
||||
const total = items.reduce((s, i) => s + i.value, 0);
|
||||
let best = 1, bestDiff = Infinity, acc = 0;
|
||||
for (let i = 0; i < items.length - 1; i++) {
|
||||
acc += items[i].value;
|
||||
const diff = Math.abs(acc - (total - acc));
|
||||
if (diff < bestDiff) { bestDiff = diff; best = i + 1; }
|
||||
}
|
||||
|
||||
const g1 = items.slice(0, best);
|
||||
const g2 = items.slice(best);
|
||||
const r1 = g1.reduce((s, i) => s + i.value, 0) / total;
|
||||
|
||||
if (w >= h) {
|
||||
const w1 = w * r1;
|
||||
return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)];
|
||||
} else {
|
||||
const h1 = h * r1;
|
||||
return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)];
|
||||
}
|
||||
}
|
||||
|
||||
function fmtAmount(v) {
|
||||
return Math.round(v).toLocaleString('fr-FR') + ' €';
|
||||
}
|
||||
|
||||
function wrapText(text, maxWidth, fontSize) {
|
||||
const charW = fontSize * 0.58;
|
||||
const maxChars = Math.max(1, Math.floor(maxWidth / charW));
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const candidate = current ? current + ' ' + word : word;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines.slice(0, 3);
|
||||
}
|
||||
|
||||
/* ── Composant ──────────────────────────────────────────────── */
|
||||
export default function FraisDistributionChart({ rows }) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState(null);
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
const svgRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
|
||||
/* Frais cumulés par catégorie */
|
||||
const data = useMemo(() => {
|
||||
if (!rows?.length) return [];
|
||||
const byCat = {};
|
||||
for (const r of rows) {
|
||||
const key = FRAIS_CATEGORIE_LABELS[r.categorie] || r.categorie || 'Autre';
|
||||
const val = r.montant || 0;
|
||||
byCat[key] = (byCat[key] || 0) + val;
|
||||
}
|
||||
return Object.entries(byCat)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] }));
|
||||
}, [rows]);
|
||||
|
||||
const total = data.reduce((s, i) => s + i.value, 0);
|
||||
const W = 440, H = 290, GAP = 3;
|
||||
const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]);
|
||||
|
||||
const handleMouseMove = useCallback((e, cell, idx) => {
|
||||
if (!wrapRef.current) return;
|
||||
const wrapRect = wrapRef.current.getBoundingClientRect();
|
||||
setHoveredIdx(idx);
|
||||
setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell });
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredIdx(null);
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
if (!cells.length) return null;
|
||||
|
||||
const TIP_W = 160, TIP_H = 66;
|
||||
const tipStyle = tooltip ? (() => {
|
||||
const ww = wrapRef.current?.offsetWidth || 440;
|
||||
const wh = wrapRef.current?.offsetHeight || 360;
|
||||
let tx = tooltip.x + 14;
|
||||
let ty = tooltip.y - TIP_H / 2;
|
||||
if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14;
|
||||
if (ty < 4) ty = 4;
|
||||
if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4;
|
||||
return { left: tx, top: ty };
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<div className="dist-chart-wrap" ref={wrapRef}>
|
||||
|
||||
{/* ── En-tête ── */}
|
||||
<div className="dist-chart-header">
|
||||
<span className="dist-chart-title">Frais</span>
|
||||
<div className="dist-dropdown">
|
||||
par Catégorie
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Treemap SVG ── */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fdt-shadow" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#000" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="#000" stopOpacity="0.4" />
|
||||
</linearGradient>
|
||||
<filter id="fdt-txt-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" floodColor="#000" floodOpacity="0.55"/>
|
||||
</filter>
|
||||
{cells.map((cell, i) => {
|
||||
const PAD = 4;
|
||||
const gx = cell.x + GAP / 2 + PAD;
|
||||
const gy = cell.y + GAP / 2 + PAD;
|
||||
const gw = Math.max(cell.w - GAP - PAD * 2, 0);
|
||||
const gh = Math.max(cell.h - GAP - PAD * 2, 0);
|
||||
return (
|
||||
<clipPath key={i} id={`fdt-clip-${i}`}>
|
||||
<rect x={gx} y={gy} width={gw} height={gh} />
|
||||
</clipPath>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
|
||||
{cells.map((cell, i) => {
|
||||
const gx = cell.x + GAP / 2;
|
||||
const gy = cell.y + GAP / 2;
|
||||
const gw = Math.max(cell.w - GAP, 0);
|
||||
const gh = Math.max(cell.h - GAP, 0);
|
||||
const cx = gx + gw / 2;
|
||||
const cy = gy + gh / 2;
|
||||
const pct = ((cell.value / total) * 100).toFixed(0);
|
||||
const amt = fmtAmount(cell.value);
|
||||
const isHovered = hoveredIdx === i;
|
||||
|
||||
const fsAmt = Math.min(15, Math.max(10, gw / 7.5));
|
||||
const fsName = Math.min(12, Math.max(8, gw / 9.5));
|
||||
const fsPct = Math.min(11, Math.max(7, gw / 11));
|
||||
const lineH = 15;
|
||||
|
||||
const canShowAmt = gw > 36 && gh > 20;
|
||||
const isLandscape = gw > gh;
|
||||
const combinedText = `${cell.name} — ${pct} %`;
|
||||
const combinedCharW = fsName * 0.58;
|
||||
const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10);
|
||||
const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : [];
|
||||
const canShowName = nameLines.length > 0;
|
||||
const CLIP_PAD = 4;
|
||||
const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0);
|
||||
const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH;
|
||||
|
||||
const textItems = [];
|
||||
{
|
||||
const slots = [];
|
||||
if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 });
|
||||
if (combinedFits) {
|
||||
slots.push({ type: 'combined', text: combinedText, h: lineH });
|
||||
} else {
|
||||
if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH }));
|
||||
if (canShowPct) slots.push({ type: 'pct', h: lineH });
|
||||
}
|
||||
const totalH = slots.reduce((s, sl) => s + sl.h, 0);
|
||||
let y = cy - totalH / 2 + lineH / 2;
|
||||
for (const sl of slots) {
|
||||
if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y });
|
||||
if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y });
|
||||
if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y });
|
||||
y += sl.h;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={i} style={{ cursor: 'pointer' }}
|
||||
onMouseMove={e => handleMouseMove(e, { ...cell, pct, amt }, i)}
|
||||
>
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill={cell.color}
|
||||
opacity={hoveredIdx !== null && !isHovered ? 0.45 : 0.9}
|
||||
style={{ transition: 'opacity .15s' }}
|
||||
/>
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="url(#fdt-shadow)" opacity="0.35" />
|
||||
{isHovered && (
|
||||
<rect x={gx} y={gy} width={gw} height={gh}
|
||||
rx="5" fill="none"
|
||||
stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
|
||||
)}
|
||||
<g clipPath={`url(#fdt-clip-${i})`}>
|
||||
{textItems.map(l => (
|
||||
<text key={l.key} x={cx} y={l.y}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white"
|
||||
fillOpacity={hoveredIdx !== null && !isHovered ? 0.3 : l.op}
|
||||
fontSize={l.fs} fontWeight={l.fw}
|
||||
fontFamily="system-ui,-apple-system,sans-serif"
|
||||
filter="url(#fdt-txt-shadow)"
|
||||
style={{ transition: 'fill-opacity .15s' }}>
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* ── Tooltip ── */}
|
||||
{tooltip && tipStyle && (
|
||||
<div className="dist-tooltip" style={{ left: tipStyle.left, top: tipStyle.top }}>
|
||||
<div className="dist-tooltip-dot" style={{ background: tooltip.cell.color }} />
|
||||
<div>
|
||||
<div className="dist-tooltip-amt">{tooltip.cell.amt}</div>
|
||||
<div className="dist-tooltip-name">{tooltip.cell.name}</div>
|
||||
<div className="dist-tooltip-pct">{tooltip.cell.pct} %</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,19 +87,41 @@ export default function InvestissementFormModal({
|
||||
if (open) setStep(1);
|
||||
}, [open]);
|
||||
|
||||
const stepTitles = [
|
||||
"Informations générales de l'investissement",
|
||||
'Données financières et échéancier',
|
||||
'Catégories, secteurs, autres',
|
||||
];
|
||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||
const platFrais = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
|
||||
// Étape "Frais" : uniquement à la création (une fois la souscription actée, le frais initial
|
||||
// éventuel a déjà été journalisé ; toute correction ou tout frais complémentaire ultérieur se
|
||||
// gère désormais depuis le bloc "Frais d'opération" de la fiche investissement — cf.
|
||||
// project_frais_operation.md, revu le 31/08/26), et seulement si la plateforme facture des
|
||||
// frais (référentiel frais_plateforme ≠ 'aucun').
|
||||
// Visible en création ET en modification (31/08/26, demande Olivier) : en modification,
|
||||
// cette étape lit/écrit toujours LA MÊME entrée frais_operations de catégorie 'souscription'
|
||||
// pour cet investissement — pré-remplie par le parent (openEdit) si elle existe déjà, gérée
|
||||
// en upsert par le parent au submit (POST si absente, PUT si déjà chargée via form.frais_id,
|
||||
// DELETE si le montant est vidé). Le montant investi n'est PAS réajusté automatiquement en
|
||||
// modification, contrairement à la création — cf. project_frais_operation.md.
|
||||
const showFraisStep = !!platFrais && platFrais !== 'aucun';
|
||||
const totalSteps = showFraisStep ? 4 : 3;
|
||||
|
||||
const stepTitles = showFraisStep
|
||||
? [
|
||||
"Informations générales de l'investissement",
|
||||
'Données financières et échéancier',
|
||||
'Frais liés à la plateforme',
|
||||
'Catégories, secteurs, autres',
|
||||
]
|
||||
: [
|
||||
"Informations générales de l'investissement",
|
||||
'Données financières et échéancier',
|
||||
'Catégories, secteurs, autres',
|
||||
];
|
||||
|
||||
const goNext = () => {
|
||||
if (formRef.current && !formRef.current.reportValidity()) return;
|
||||
setStep(s => Math.min(3, s + 1));
|
||||
setStep(s => Math.min(totalSteps, s + 1));
|
||||
};
|
||||
const goPrev = () => setStep(s => Math.max(1, s - 1));
|
||||
|
||||
const platMethode = plats.find(p => String(p.id) === String(form.plateforme_id))?.methode_remboursement;
|
||||
const showFinDeMois = (form.type_remb === 'in_fine' || form.type_remb === 'differe')
|
||||
&& dayOfMonth(form.date_premiere_echeance) > 27;
|
||||
|
||||
@@ -155,6 +177,10 @@ export default function InvestissementFormModal({
|
||||
onFormPatch({ compte_id: compteId, nom_compte_courant: c?.nom || '' });
|
||||
};
|
||||
|
||||
const handleFraisReglementChange = (mode) => {
|
||||
onFormPatch({ frais_mode_reglement: mode, frais_compte_id: mode === 'compte_courant' ? form.frais_compte_id : '' });
|
||||
};
|
||||
|
||||
const handleTypeRembChange = (t) => {
|
||||
const next = {
|
||||
...form, type_remb: t,
|
||||
@@ -280,8 +306,8 @@ export default function InvestissementFormModal({
|
||||
</div>
|
||||
{step === 1 && <button onClick={onClose}>Annuler</button>}
|
||||
{step > 1 && <button onClick={goPrev}>Précédent</button>}
|
||||
{step < 3 && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||
{step === 3 && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||
{step < totalSteps && <button className="primary" onClick={goNext}>Suivant</button>}
|
||||
{step === totalSteps && <button className="primary" onClick={onSubmit}>{editingId ? 'Enregistrer' : 'Créer'}</button>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -291,7 +317,7 @@ export default function InvestissementFormModal({
|
||||
Étape {step} : {stepTitles[step - 1]}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{[1, 2, 3].map(n => (
|
||||
{Array.from({ length: totalSteps }, (_, i) => i + 1).map(n => (
|
||||
<div key={n} style={{
|
||||
flex: 1, height: 5, borderRadius: 4,
|
||||
background: n <= step ? 'var(--primary)' : 'var(--border)',
|
||||
@@ -502,8 +528,68 @@ export default function InvestissementFormModal({
|
||||
{restructurationBlock}
|
||||
</>)}
|
||||
|
||||
{/* ══ Étape 3 : Catégories, secteurs, autres ══ */}
|
||||
{step === 3 && (<>
|
||||
{/* ══ Étape 3 (si frais) : Frais liés à la plateforme ══ */}
|
||||
{step === 3 && showFraisStep && (<>
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: '0 0 4px' }}>
|
||||
{editingId
|
||||
? "Cette plateforme facture des frais (entrée, gestion…). Le frais de souscription déjà documenté pour cet investissement, s'il existe, est pré-rempli ci-dessous — modifiez-le ou videz le montant pour le supprimer."
|
||||
: "Cette plateforme facture des frais (entrée, gestion…). Indiquez le montant TTC constaté pour cette souscription, ou laissez vide si aucun frais ne s'applique cette fois-ci."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>Mode de saisie</label>
|
||||
<select value={form.frais_mode_saisie} onChange={e => onFieldChange('frais_mode_saisie', e.target.value)}>
|
||||
<option value="montant">Montant (€)</option>
|
||||
<option value="pourcentage">Pourcentage (%)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>{form.frais_mode_saisie === 'pourcentage' ? 'Frais (% du montant investi)' : 'Montant des frais TTC (€)'}</label>
|
||||
<input type="number" step="0.01" min="0" value={form.frais_valeur}
|
||||
onChange={e => onFieldChange('frais_valeur', e.target.value)}
|
||||
placeholder="0" />
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Prélèvement</label>
|
||||
<select value={form.frais_mode_reglement} onChange={e => handleFraisReglementChange(e.target.value)}>
|
||||
<option value="source">À la source (réduit le capital net investi, 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.frais_mode_reglement === 'compte_courant' && (
|
||||
<div>
|
||||
<label>Compte de règlement *</label>
|
||||
{comptesInvestisseur.length > 0 ? (
|
||||
<select required value={form.frais_compte_id} onChange={e => onFieldChange('frais_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>
|
||||
)}
|
||||
{form.frais_mode_reglement === 'source' && form.frais_valeur !== '' && Number(form.frais_valeur) > 0 && (
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', margin: 0 }}>
|
||||
{editingId
|
||||
? "Ce frais est journalisé indépendamment du montant investi — ajustez ce dernier vous-même à l'étape précédente si besoin."
|
||||
: `Capital net qui sera enregistré : ${fmtEUR(Math.max(0, Math.round((Number(form.montant_investi || 0) - (form.frais_mode_saisie === 'pourcentage'
|
||||
? Number(form.montant_investi || 0) * Number(form.frais_valeur) / 100
|
||||
: Number(form.frais_valeur))) * 100) / 100))}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{/* ══ Étape suivante : Catégories, secteurs, autres ══ */}
|
||||
{step === (showFraisStep ? 4 : 3) && (<>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Notes</label>
|
||||
<textarea rows={3} value={form.notes}
|
||||
|
||||
@@ -28,6 +28,19 @@ export const DEBIT_TYPES = new Set(['retrait', 'souscription', 'prelevement_ir',
|
||||
// depot/remboursement/bonus → crédit (+) ; retrait/souscription/prélèvements → débit (−) ;
|
||||
// correction → déjà signé en base (montant peut être positif ou négatif).
|
||||
export function signedMontant(r) {
|
||||
// Frais d'opération : impact réel sur le solde uniquement si réglé 'source' (débit du
|
||||
// montant brut, cf. cas Fundora confirmé par Olivier le 31/08/26 — [[project_frais_operation]])
|
||||
// ou 'portefeuille' (versement complémentaire) ; 'compte_courant' n'a jamais d'effet sur le
|
||||
// porte-monnaie (sortie d'argent ailleurs) — on l'affiche quand même dans la liste, à titre
|
||||
// de traçabilité, avec un badge "hors porte-monnaie", mais sans effet sur le solde indicatif.
|
||||
// 'remboursement' (Étape 4, 31/08/26) : frais de distribution synchronisé depuis le champ
|
||||
// "Frais" d'un remboursement — DOIT être un débit réel ici (comme un prélèvement), pas 0 :
|
||||
// la ligne "remboursement" du tableau crédite le coupon BRUT (interets_bruts, cf.
|
||||
// normalizedRembMouvements), et les prélèvements sont déjà des lignes de débit séparées pour
|
||||
// retomber sur interets_nets — le frais doit suivre exactement la même convention, sinon la
|
||||
// chaîne ne retombe jamais à 0 après un retrait AUTO (confirmé par Olivier le 01/09/26 : le
|
||||
// reliquat observé était exactement égal au montant du frais).
|
||||
if (r.type === 'frais') return r.mode_reglement === 'compte_courant' ? 0 : -Math.abs(r.montant);
|
||||
if (DEBIT_TYPES.has(r.type)) return -Math.abs(r.montant);
|
||||
if (r.type === 'correction') return r.montant;
|
||||
return Math.abs(r.montant);
|
||||
@@ -39,8 +52,19 @@ export const MOUV_TYPE_LABELS = {
|
||||
prelevement_ir: 'Prélèvement IR', prelevement_csg: 'Prélèvement CSG',
|
||||
prelevement_local: "Prélèvement impôts à l'étranger",
|
||||
interets_plateforme: 'Intérêts plateforme',
|
||||
frais: "Frais d'opération",
|
||||
};
|
||||
|
||||
// Labels de mode de règlement / origine d'un frais d'opération — dupliqués depuis
|
||||
// InvestissementDetail.jsx (bloc "Frais d'opération") pour l'affichage dans les
|
||||
// Mouvements ; garder synchronisés en cas d'évolution des libellés.
|
||||
export const FRAIS_MODE_LABELS = { source: 'Déduit du versement lors de la souscription', portefeuille: 'Porte-monnaie', compte_courant: 'Débité du compte courant', remboursement: 'Déduit du remboursement' };
|
||||
export const FRAIS_ORIGINE_LABELS = { souscription: 'Souscription', remboursement: 'Remboursement', manuel: 'Manuel' };
|
||||
// Catégorie de frais (31/08/26, demande Olivier) — classement visible côté utilisateur,
|
||||
// distinct de FRAIS_ORIGINE_LABELS (détail technique de création). Dupliqué depuis
|
||||
// InvestissementDetail.jsx, à garder synchronisé.
|
||||
export const FRAIS_CATEGORIE_LABELS = { souscription: 'Souscription', gestion: 'Gestion', distribution: 'Distribution', autre: 'Autre' };
|
||||
|
||||
/* ── Tri des mouvements pour une même date ───────────────────────────
|
||||
1) Toutes les lignes d'une même plateforme, pour une même date, sont regroupées
|
||||
ensemble (ordre alphabétique des plateformes entre elles — décidé avec Olivier
|
||||
@@ -96,7 +120,7 @@ const MOUV_FAMILY_RANK = {
|
||||
remboursement: 1, interets_plateforme: 1, bonus: 1, cashback: 1,
|
||||
prelevement_local: 1, prelevement_ir: 1, prelevement_csg: 1,
|
||||
depot: 2,
|
||||
souscription: 3,
|
||||
souscription: 3, frais: 3,
|
||||
retrait: 4,
|
||||
correction: 5,
|
||||
};
|
||||
@@ -104,7 +128,12 @@ const MOUV_FAMILY_RANK = {
|
||||
// Position au sein d'une chaîne "remboursement" (lignes partageant le même _rembId)
|
||||
const MOUV_REMB_CHAIN_POS = {
|
||||
remboursement: 0, interets_plateforme: 0, bonus: 0,
|
||||
cashback: 1, prelevement_local: 2, prelevement_ir: 3, prelevement_csg: 4,
|
||||
// frais (Étape 4, 31/08/26) : déduit du coupon brut AVANT calcul des prélèvements (base
|
||||
// imposable = interets_bruts - frais_ttc, cf. computeChamps) — doit donc apparaître entre le
|
||||
// remboursement et les prélèvements dans la chaîne, pas au-dessus de tout (demande Olivier,
|
||||
// 01/09/26, avec l'avis d'opéré Kyoseil-AM/Fundora à l'appui).
|
||||
frais: 1,
|
||||
cashback: 2, prelevement_local: 3, prelevement_ir: 4, prelevement_csg: 5,
|
||||
};
|
||||
|
||||
export function sortMouvements(list) {
|
||||
@@ -124,7 +153,7 @@ export function sortMouvements(list) {
|
||||
const enriched = list.map(row => {
|
||||
// Retrait AUTO (sweep compte_courant) : reste collé juste après le remboursement qui le déclenche.
|
||||
if (row.type === 'retrait' && row.source === 'auto_remboursement' && row.remboursement_id != null) {
|
||||
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row.remboursement_id, pos: 5 };
|
||||
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row.remboursement_id, pos: 6 };
|
||||
}
|
||||
// Cashback "prime à la souscription" : pas de ligne crédit propre + souscription du même
|
||||
// jour sur le même investissement → rattaché à cette souscription plutôt qu'isolé.
|
||||
@@ -132,6 +161,15 @@ export function sortMouvements(list) {
|
||||
const sub = subByInvAndDate.get(`${row._invId}|${row.date_operation}`);
|
||||
if (sub) return { row, family: MOUV_FAMILY_RANK.souscription, anchor: sub._invId, pos: 1 };
|
||||
}
|
||||
// Frais lié à un remboursement (mode_reglement='remboursement', Étape 4, 31/08/26) :
|
||||
// rejoint la chaîne du remboursement lui-même (même family que prélèvements/retrait AUTO),
|
||||
// positionné juste après le remboursement et avant les prélèvements — cf.
|
||||
// MOUV_REMB_CHAIN_POS.frais ci-dessus. Doit être vérifié avant le bloc générique
|
||||
// `row._rembId != null` plus bas, qui sinon l'attraperait avec la family 'frais' (3),
|
||||
// qui n'a rien à voir avec la chaîne du remboursement.
|
||||
if (row.type === 'frais' && row._rembId != null) {
|
||||
return { row, family: MOUV_FAMILY_RANK.remboursement, anchor: row._rembId, pos: MOUV_REMB_CHAIN_POS.frais };
|
||||
}
|
||||
// Lignes d'un remboursement (parent + cashback resté attaché + prélèvements)
|
||||
if (row._rembId != null) {
|
||||
return {
|
||||
@@ -141,6 +179,14 @@ export function sortMouvements(list) {
|
||||
pos: MOUV_REMB_CHAIN_POS[row.type] ?? 0,
|
||||
};
|
||||
}
|
||||
// Frais d'opération : rattaché à l'investissement concerné (même anchor que sa
|
||||
// souscription éventuelle) — pos élevé pour apparaître au-dessus d'elle le même jour,
|
||||
// cohérent avec le principe "plus récent d'abord" (le frais est perçu comme consécutif
|
||||
// à la souscription). Ne rejoint jamais la chaîne d'un remboursement (_rembId) même si
|
||||
// origine='remboursement' : pas encore de lien fin construit ici (cf. [[project_frais_operation]]).
|
||||
if (row.type === 'frais') {
|
||||
return { row, family: MOUV_FAMILY_RANK.frais, anchor: row._invId, pos: 2 };
|
||||
}
|
||||
// Souscription / réinvestissement (parent de sa propre chaîne)
|
||||
if (row.type === 'souscription') {
|
||||
return { row, family: MOUV_FAMILY_RANK.souscription, anchor: row._invId, pos: 0 };
|
||||
@@ -413,6 +459,9 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
const isRemb = row.type === 'remboursement' || isBonus || isCashback || isInteretsPlateforme;
|
||||
const isSouscription = row.type === 'souscription';
|
||||
const isPrelevement = row.type === 'prelevement_ir' || row.type === 'prelevement_csg' || row.type === 'prelevement_local';
|
||||
const isFrais = row.type === 'frais';
|
||||
const isFraisHorsPM = isFrais && row.mode_reglement === 'compte_courant';
|
||||
const isFraisRembLinked = isFrais && row.mode_reglement === 'remboursement';
|
||||
const isAuto = row.source === 'auto_remboursement';
|
||||
|
||||
const typeLabel = isCorrection ? 'Correction de solde'
|
||||
@@ -424,6 +473,7 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
: row.type === 'prelevement_csg' ? 'Prélèvement CSG'
|
||||
: row.type === 'prelevement_local' ? "Prélèvement impôts à l'étranger"
|
||||
: isSouscription ? (row._isReinvest ? 'Réinvestissement' : 'Souscription à un investissement')
|
||||
: isFrais ? "Frais d'opération" + (isFraisHorsPM ? ' (hors porte-monnaie)' : isFraisRembLinked ? ' (déduit du remboursement)' : '')
|
||||
: row.type === 'depot' ? 'Dépôt' : 'Retrait';
|
||||
|
||||
const editLabel = isCorrection ? 'Modifier la correction de solde'
|
||||
@@ -431,7 +481,7 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
|
||||
// Débit (−) pour retrait/souscription/prélèvements, crédit (+) pour dépôt/remboursement/bonus/cashback ;
|
||||
// la correction est déjà signée en base.
|
||||
const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement;
|
||||
const isDebitType = row.type === 'retrait' || isSouscription || isPrelevement || isFrais;
|
||||
const isPos = isCorrection ? row.montant >= 0 : !isDebitType;
|
||||
const sign = isPos ? '+' : '−';
|
||||
const montantFmt = `${sign} ${fmtEUR(Math.abs(row.montant))}`;
|
||||
@@ -456,7 +506,12 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
: 'Étranger — indicatif, aucun prélèvement à la source ici' },
|
||||
isPrelevement && row.libelle && { label: 'Projet', value: row.libelle },
|
||||
isSouscription && row.libelle && { label: 'Projet', value: row.libelle },
|
||||
!isCorrection && !isRemb && !isSouscription && !isPrelevement && row.libelle && { label: 'Libellé', value: row.libelle },
|
||||
isFrais && row.libelle && { label: 'Projet', value: row.libelle },
|
||||
isFrais && { label: 'Mode de règlement', value: FRAIS_MODE_LABELS[row.mode_reglement] || row.mode_reglement },
|
||||
isFrais && row.mode_reglement === 'compte_courant' && row.compte_nom && { label: 'Compte', value: row.compte_nom },
|
||||
isFrais && row.categorie && { label: 'Catégorie', value: FRAIS_CATEGORIE_LABELS[row.categorie] || row.categorie },
|
||||
isFrais && row.origine && { label: 'Origine', value: FRAIS_ORIGINE_LABELS[row.origine] || row.origine },
|
||||
!isCorrection && !isRemb && !isSouscription && !isPrelevement && !isFrais && row.libelle && { label: 'Libellé', value: row.libelle },
|
||||
!isCorrection && !isRemb && row.reference && { label: 'Référence', value: row.reference },
|
||||
row.notes && { label: 'Notes', value: row.notes },
|
||||
].filter(Boolean);
|
||||
@@ -562,6 +617,34 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFrais && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
margin: '5px 5px 12px',
|
||||
padding: '9px 12px', borderRadius: 6,
|
||||
background: `color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b'} 25%, transparent)`,
|
||||
fontSize: 'var(--fs-sm)', color: 'var(--text-muted)',
|
||||
lineHeight: 1.4,
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ marginTop: 1, flexShrink: 0, color: isFraisHorsPM ? 'var(--text-muted)' : '#f59e0b' }}>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
<span>
|
||||
{isFraisHorsPM
|
||||
? "Frais réglé via un compte courant du détenteur — n'a jamais d'effet sur le solde du porte-monnaie de la plateforme, affiché ici à titre de traçabilité uniquement."
|
||||
: isFraisRembLinked
|
||||
? "Frais de distribution déduit du coupon brut de ce remboursement, avant calcul des prélèvements — réduit d'autant le montant net reçu (cf. avis d'opéré de la plateforme)."
|
||||
: row.mode_reglement === 'source'
|
||||
? "Frais prélevé à la source par la plateforme sur le montant transféré — sort du porte-monnaie en plus du capital net déjà déduit de la souscription."
|
||||
: "Versement complémentaire réglé depuis le porte-monnaie de la plateforme — sort du porte-monnaie indépendamment du capital investi."}
|
||||
{' '}{isFraisRembLinked ? "Modifiable depuis le remboursement." : "Modifiable depuis la fiche de l'investissement."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dr-detail-fields">
|
||||
{fields.map(f => (
|
||||
<div className="dr-detail-field" key={f.label}>
|
||||
@@ -576,7 +659,11 @@ export function DetailPanel({ row, onEdit, onDeleteCorrection, onViewRemb, onVie
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
|
||||
Voir / modifier le remboursement
|
||||
</button>
|
||||
) : isSouscription ? (
|
||||
) : isFraisRembLinked ? (
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewRemb(row)}>
|
||||
Voir / modifier le remboursement
|
||||
</button>
|
||||
) : isSouscription || isFrais ? (
|
||||
<button className="dr-detail-edit-btn" onClick={() => onViewInvestissement(row)}>
|
||||
Voir l'investissement
|
||||
</button>
|
||||
|
||||
@@ -16,7 +16,7 @@ function getIcons() {
|
||||
return _promise;
|
||||
}
|
||||
|
||||
export default function PageIcon({ name, size = 40 }) {
|
||||
export default function PageIcon({ name, size = 40, style }) {
|
||||
const [filename, setFilename] = useState(() => _cache?.[name] ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +38,7 @@ export default function PageIcon({ name, size = 40 }) {
|
||||
marginRight: 10,
|
||||
objectFit: 'contain',
|
||||
opacity: 0.85,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,13 @@ import { fmtEUR } from '../utils/format.js';
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
const fmtRate = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…';
|
||||
|
||||
// Le frais ne réduit interets_nets que lorsqu'il est retenu SUR ce remboursement (mode
|
||||
// 'remboursement', par défaut) — un frais réglé à part ('portefeuille'/'compte_courant',
|
||||
// Étape 5 bis, 01/09/26 — cas Baltis) n'a aucun impact ici, cf. computeChamps() côté backend.
|
||||
const fraisReduitBase = (f) => !f.frais_mode_reglement || f.frais_mode_reglement === 'remboursement';
|
||||
|
||||
const computeInteretsNets = (f) =>
|
||||
round2(Number(f.interets_bruts || 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||
round2(Number(f.interets_bruts || 0) - (fraisReduitBase(f) ? Number(f.frais_ttc || 0) : 0) - Number(f.prelev_sociaux || 0) - Number(f.prelev_forfaitaire || 0));
|
||||
|
||||
const computeNet = (f) =>
|
||||
round2(Number(f.capital || 0) + Number(f.cashback || 0) + computeInteretsNets(f));
|
||||
@@ -86,10 +91,20 @@ export default function RembFormModal({
|
||||
const hasLocalTax = !isBonus && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale;
|
||||
const isExonere = !isBonus && currentInv?.fiscalite_override === 'exonere';
|
||||
const isIndicatif = Boolean(isExonere || (!isBonus && currentPlat && currentPlat.fiscalite !== 'flat_tax'));
|
||||
// Cas indicatif (plateforme étrangère ou exonérée) : le montant versé est net de frais mais
|
||||
// pas des prélèvements (indicatifs) — même formule que net_recu côté backend (computeChamps,
|
||||
// branche isTaxIndicatif) — cf. Étape 4, 31/08/26.
|
||||
const montantVerse = isIndicatif
|
||||
? round2(Number(form.capital || 0) + Number(form.cashback || 0) + Number(form.interets_bruts || 0))
|
||||
? round2(Number(form.capital || 0) + Number(form.cashback || 0) + Number(form.interets_bruts || 0) - (fraisReduitBase(form) ? Number(form.frais_ttc || 0) : 0))
|
||||
: computeNet(form);
|
||||
|
||||
// Section "Frais" (frais de distribution, Étape 4) : visible si la plateforme facture des
|
||||
// frais (référentiel frais_plateforme ≠ 'aucun'), ou si un montant est déjà saisi (garde
|
||||
// l'accès même si la config de la plateforme change ensuite) — même logique que le bloc
|
||||
// "Frais d'opération" de la fiche investissement, cf. project_frais_operation.md.
|
||||
const showFraisSection = !isBonus
|
||||
&& ((currentPlat?.frais_plateforme && currentPlat.frais_plateforme !== 'aucun') || Number(form.frais_ttc) > 0);
|
||||
|
||||
const modalTitle = title ?? (editingId ? 'Modifier le remboursement' : (locked ? 'Saisir le remboursement' : 'Nouveau remboursement'));
|
||||
|
||||
const handleMethodeChange = (m) => {
|
||||
@@ -97,6 +112,16 @@ export default function RembFormModal({
|
||||
onFormPatch({ methode_remboursement: m, compte_id: def ? String(def.id) : '' });
|
||||
};
|
||||
|
||||
// Prélèvement du frais (Étape 5 bis, 01/09/26) : passe par onFieldChange pour déclencher le
|
||||
// recalcul des prélèvements (comme un changement de frais_ttc, cf. setField/setRembField dans
|
||||
// les 3 pages appelantes), puis pré-sélectionne un compte par défaut si on bascule sur
|
||||
// 'compte_courant' — même pattern que handleFraisReglementChange côté investissement.
|
||||
const handleFraisModeChange = (mode) => {
|
||||
onFieldChange('frais_mode_reglement', mode);
|
||||
const def = mode === 'compte_courant' ? (comptesInvestisseur?.find(c => c.type === 'compte_courant') ?? comptesInvestisseur?.[0]) : null;
|
||||
onFormPatch({ frais_compte_id: def ? String(def.id) : '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -202,6 +227,53 @@ export default function RembFormModal({
|
||||
onChange={e => onFieldChange('cashback', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{showFraisSection && (<>
|
||||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Frais</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Montant des frais (€ TTC)</label>
|
||||
<input type="number" step="0.01" min="0" value={form.frais_ttc ?? 0}
|
||||
onChange={e => onFieldChange('frais_ttc', e.target.value)} />
|
||||
</div>
|
||||
{!isInteretsPlateforme ? (<>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label>Prélèvement</label>
|
||||
<select value={form.frais_mode_reglement || 'remboursement'} onChange={e => handleFraisModeChange(e.target.value)}>
|
||||
<option value="remboursement">Retenu sur ce remboursement (réduit la base imposable et le montant versé)</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.frais_mode_reglement === 'compte_courant' && (
|
||||
<div style={{ gridColumn: 'span 3' }}>
|
||||
<label>Compte de règlement des frais *</label>
|
||||
{comptesInvestisseur?.length > 0 ? (
|
||||
<select required value={form.frais_compte_id || ''} onChange={e => onFieldChange('frais_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. <a href="/settings?section=comptes" target="_blank" rel="noreferrer">Créer un compte →</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ gridColumn: 'span 3', display: 'flex', alignItems: 'flex-end', fontSize: 12, color: 'var(--text-muted)', paddingBottom: 6 }}>
|
||||
{form.frais_mode_reglement === 'compte_courant' || form.frais_mode_reglement === 'portefeuille'
|
||||
? "Frais réglé à part (comme pour la souscription) — aucun impact sur l'imposition ni le montant versé de ce remboursement ; compté séparément dans le Frais payés et le XIRR net."
|
||||
: "Frais d'entrée/de gestion prélevés par la plateforme sur cette distribution (ex. avis d'opéré) — vient réduire la base imposable des intérêts bruts avant calcul des prélèvements."}
|
||||
</div>
|
||||
</>) : (
|
||||
<div style={{ gridColumn: 'span 2', display: 'flex', alignItems: 'flex-end', fontSize: 12, color: 'var(--text-muted)', paddingBottom: 6 }}>
|
||||
Frais d'entrée/de gestion prélevés par la plateforme sur cette distribution (ex. avis d'opéré) —
|
||||
vient réduire la base imposable des intérêts bruts avant calcul des prélèvements.
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
<div style={{ gridColumn: 'span 3', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>Imposition{isIndicatif ? ' — indicatif' : ''}</span>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user